@bastani/atomic 0.6.4-0 → 0.6.5-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/.agents/skills/create-spec/SKILL.md +6 -3
- package/.agents/skills/tdd/SKILL.md +107 -0
- package/.agents/skills/tdd/deep-modules.md +33 -0
- package/.agents/skills/tdd/interface-design.md +31 -0
- package/.agents/skills/tdd/mocking.md +59 -0
- package/.agents/skills/tdd/refactoring.md +10 -0
- package/.agents/skills/tdd/tests.md +61 -0
- package/.agents/skills/workflow-creator/SKILL.md +550 -0
- package/.agents/skills/workflow-creator/references/agent-sessions.md +891 -0
- package/.agents/skills/workflow-creator/references/agent-setup-recipe.md +266 -0
- package/.agents/skills/workflow-creator/references/computation-and-validation.md +201 -0
- package/.agents/skills/workflow-creator/references/control-flow.md +470 -0
- package/.agents/skills/workflow-creator/references/failure-modes.md +1014 -0
- package/.agents/skills/workflow-creator/references/getting-started.md +392 -0
- package/.agents/skills/workflow-creator/references/registry-and-validation.md +141 -0
- package/.agents/skills/workflow-creator/references/running-workflows.md +418 -0
- package/.agents/skills/workflow-creator/references/session-config.md +384 -0
- package/.agents/skills/workflow-creator/references/state-and-data-flow.md +356 -0
- package/.agents/skills/workflow-creator/references/user-input.md +234 -0
- package/.agents/skills/workflow-creator/references/workflow-inputs.md +392 -0
- package/.claude/agents/debugger.md +2 -2
- package/.claude/agents/reviewer.md +1 -1
- package/.claude/agents/worker.md +2 -2
- package/.github/agents/debugger.md +1 -1
- package/.github/agents/worker.md +1 -1
- package/.mcp.json +5 -1
- package/.opencode/agents/debugger.md +1 -1
- package/.opencode/agents/worker.md +1 -1
- package/README.md +236 -201
- package/dist/sdk/define-workflow.d.ts +11 -6
- package/dist/sdk/define-workflow.d.ts.map +1 -1
- package/dist/sdk/errors.d.ts +10 -0
- package/dist/sdk/errors.d.ts.map +1 -1
- package/dist/sdk/index.d.ts +21 -9
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/primitives/inputs.d.ts +36 -0
- package/dist/sdk/primitives/inputs.d.ts.map +1 -0
- package/dist/sdk/primitives/metadata.d.ts +40 -0
- package/dist/sdk/primitives/metadata.d.ts.map +1 -0
- package/dist/sdk/primitives/run.d.ts +57 -0
- package/dist/sdk/primitives/run.d.ts.map +1 -0
- package/dist/sdk/primitives/sessions.d.ts +128 -0
- package/dist/sdk/primitives/sessions.d.ts.map +1 -0
- package/dist/sdk/runtime/executor.d.ts +24 -56
- package/dist/sdk/runtime/executor.d.ts.map +1 -1
- package/dist/sdk/runtime/orchestrator-entry.d.ts +26 -0
- package/dist/sdk/runtime/orchestrator-entry.d.ts.map +1 -0
- package/dist/sdk/runtime/tmux.d.ts +20 -0
- package/dist/sdk/runtime/tmux.d.ts.map +1 -1
- package/dist/sdk/types.d.ts +26 -86
- package/dist/sdk/types.d.ts.map +1 -1
- package/dist/sdk/workflows/builtin/deep-research-codebase/claude/index.d.ts.map +1 -1
- package/dist/sdk/workflows/builtin/deep-research-codebase/copilot/index.d.ts.map +1 -1
- package/dist/sdk/workflows/builtin/deep-research-codebase/opencode/index.d.ts.map +1 -1
- package/dist/sdk/workflows/builtin/open-claude-design/claude/index.d.ts.map +1 -1
- package/dist/sdk/workflows/builtin/open-claude-design/copilot/index.d.ts.map +1 -1
- package/dist/sdk/workflows/builtin/open-claude-design/opencode/index.d.ts.map +1 -1
- package/dist/sdk/workflows/builtin/ralph/claude/index.d.ts.map +1 -1
- package/dist/sdk/workflows/builtin/ralph/copilot/index.d.ts.map +1 -1
- package/dist/sdk/workflows/builtin/ralph/opencode/index.d.ts.map +1 -1
- package/dist/sdk/workflows/index.d.ts +20 -12
- package/dist/sdk/workflows/index.d.ts.map +1 -1
- package/dist/services/config/additional-instructions.d.ts +1 -1
- package/dist/services/config/additional-instructions.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/cli.ts +39 -56
- package/src/commands/builtin-registry.ts +37 -0
- package/src/commands/cli/chat/index.ts +1 -3
- package/src/{sdk → commands/cli}/management-commands.ts +15 -55
- package/src/commands/cli/session.ts +1 -1
- package/src/commands/cli/workflow-command.test.ts +250 -16
- package/src/commands/cli/workflow-inputs.test.ts +1 -0
- package/src/commands/cli/workflow-inputs.ts +13 -3
- package/src/commands/cli/workflow-list.test.ts +1 -0
- package/src/commands/cli/workflow-list.ts +0 -0
- package/src/commands/cli/workflow-status.ts +1 -1
- package/src/commands/cli/workflow.ts +191 -11
- package/src/sdk/define-workflow.test.ts +47 -16
- package/src/sdk/define-workflow.ts +24 -6
- package/src/sdk/errors.test.ts +11 -0
- package/src/sdk/errors.ts +13 -0
- package/src/sdk/index.test.ts +92 -0
- package/src/sdk/index.ts +71 -15
- package/src/sdk/primitives/inputs.ts +48 -0
- package/src/sdk/primitives/metadata.ts +63 -0
- package/src/sdk/primitives/run.ts +81 -0
- package/src/sdk/primitives/sessions.test.ts +594 -0
- package/src/sdk/primitives/sessions.ts +328 -0
- package/src/sdk/runtime/executor.ts +36 -115
- package/src/sdk/runtime/orchestrator-entry.ts +110 -0
- package/src/sdk/runtime/tmux.ts +33 -0
- package/src/sdk/types.ts +26 -91
- package/src/sdk/workflows/builtin/deep-research-codebase/claude/index.ts +1 -0
- package/src/sdk/workflows/builtin/deep-research-codebase/copilot/index.ts +1 -0
- package/src/sdk/workflows/builtin/deep-research-codebase/opencode/index.ts +1 -0
- package/src/sdk/workflows/builtin/open-claude-design/claude/index.ts +1 -0
- package/src/sdk/workflows/builtin/open-claude-design/copilot/index.ts +1 -0
- package/src/sdk/workflows/builtin/open-claude-design/opencode/index.ts +1 -0
- package/src/sdk/workflows/builtin/ralph/claude/index.ts +1 -0
- package/src/sdk/workflows/builtin/ralph/copilot/index.ts +1 -0
- package/src/sdk/workflows/builtin/ralph/opencode/index.ts +1 -0
- package/src/sdk/workflows/index.ts +68 -51
- package/src/services/config/additional-instructions.ts +1 -1
- package/.agents/skills/test-driven-development/SKILL.md +0 -371
- package/.agents/skills/test-driven-development/testing-anti-patterns.md +0 -299
- package/dist/commands/cli/session.d.ts +0 -67
- package/dist/commands/cli/session.d.ts.map +0 -1
- package/dist/commands/cli/workflow-status.d.ts +0 -63
- package/dist/commands/cli/workflow-status.d.ts.map +0 -1
- package/dist/sdk/commander.d.ts +0 -74
- package/dist/sdk/commander.d.ts.map +0 -1
- package/dist/sdk/management-commands.d.ts +0 -42
- package/dist/sdk/management-commands.d.ts.map +0 -1
- package/dist/sdk/workflow-cli.d.ts +0 -103
- package/dist/sdk/workflow-cli.d.ts.map +0 -1
- package/dist/sdk/workflows/builtin-registry.d.ts +0 -113
- package/dist/sdk/workflows/builtin-registry.d.ts.map +0 -1
- package/src/sdk/commander.ts +0 -161
- package/src/sdk/workflow-cli.ts +0 -409
- 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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
10
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* re-entry
|
|
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<
|
|
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
|
-
//
|
|
505
|
-
//
|
|
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(
|
|
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(
|
|
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
|
-
*
|
|
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
|
-
*
|
|
1931
|
-
*
|
|
1932
|
-
*
|
|
1933
|
-
*
|
|
1934
|
-
*
|
|
1935
|
-
*
|
|
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
|
+
}
|