@bastani/atomic 0.5.0-1

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 (68) hide show
  1. package/LICENSE +24 -0
  2. package/README.md +956 -0
  3. package/assets/settings.schema.json +52 -0
  4. package/package.json +68 -0
  5. package/src/cli.ts +197 -0
  6. package/src/commands/cli/chat/client.ts +18 -0
  7. package/src/commands/cli/chat/index.ts +247 -0
  8. package/src/commands/cli/chat.ts +8 -0
  9. package/src/commands/cli/config.ts +55 -0
  10. package/src/commands/cli/init/index.ts +452 -0
  11. package/src/commands/cli/init/onboarding.ts +45 -0
  12. package/src/commands/cli/init/scm.ts +190 -0
  13. package/src/commands/cli/init.ts +8 -0
  14. package/src/commands/cli/update.ts +46 -0
  15. package/src/commands/cli/workflow.ts +164 -0
  16. package/src/lib/merge.ts +65 -0
  17. package/src/lib/path-root-guard.ts +38 -0
  18. package/src/lib/spawn.ts +467 -0
  19. package/src/scripts/bump-version.ts +94 -0
  20. package/src/scripts/constants-base.ts +14 -0
  21. package/src/scripts/constants.ts +34 -0
  22. package/src/sdk/components/color-utils.ts +20 -0
  23. package/src/sdk/components/connectors.test.ts +661 -0
  24. package/src/sdk/components/connectors.ts +156 -0
  25. package/src/sdk/components/edge.tsx +11 -0
  26. package/src/sdk/components/error-boundary.tsx +38 -0
  27. package/src/sdk/components/graph-theme.ts +36 -0
  28. package/src/sdk/components/header.tsx +60 -0
  29. package/src/sdk/components/layout.test.ts +924 -0
  30. package/src/sdk/components/layout.ts +186 -0
  31. package/src/sdk/components/node-card.tsx +68 -0
  32. package/src/sdk/components/orchestrator-panel-contexts.ts +26 -0
  33. package/src/sdk/components/orchestrator-panel-store.test.ts +561 -0
  34. package/src/sdk/components/orchestrator-panel-store.ts +118 -0
  35. package/src/sdk/components/orchestrator-panel-types.ts +21 -0
  36. package/src/sdk/components/orchestrator-panel.tsx +143 -0
  37. package/src/sdk/components/session-graph-panel.tsx +364 -0
  38. package/src/sdk/components/status-helpers.ts +32 -0
  39. package/src/sdk/components/statusline.tsx +63 -0
  40. package/src/sdk/define-workflow.ts +98 -0
  41. package/src/sdk/errors.ts +39 -0
  42. package/src/sdk/index.ts +38 -0
  43. package/src/sdk/providers/claude.ts +316 -0
  44. package/src/sdk/providers/copilot.ts +43 -0
  45. package/src/sdk/providers/opencode.ts +43 -0
  46. package/src/sdk/runtime/discovery.ts +172 -0
  47. package/src/sdk/runtime/executor.test.ts +415 -0
  48. package/src/sdk/runtime/executor.ts +695 -0
  49. package/src/sdk/runtime/loader.ts +372 -0
  50. package/src/sdk/runtime/panel.tsx +9 -0
  51. package/src/sdk/runtime/theme.ts +76 -0
  52. package/src/sdk/runtime/tmux.ts +542 -0
  53. package/src/sdk/types.ts +114 -0
  54. package/src/sdk/workflows.ts +85 -0
  55. package/src/services/config/atomic-config.ts +124 -0
  56. package/src/services/config/atomic-global-config.ts +361 -0
  57. package/src/services/config/config-path.ts +19 -0
  58. package/src/services/config/definitions.ts +176 -0
  59. package/src/services/config/index.ts +7 -0
  60. package/src/services/config/settings-schema.ts +2 -0
  61. package/src/services/config/settings.ts +149 -0
  62. package/src/services/system/copy.ts +381 -0
  63. package/src/services/system/detect.ts +161 -0
  64. package/src/services/system/download.ts +325 -0
  65. package/src/services/system/file-lock.ts +289 -0
  66. package/src/services/system/skills.ts +67 -0
  67. package/src/theme/colors.ts +25 -0
  68. package/src/version.ts +7 -0
@@ -0,0 +1,143 @@
1
+ /** @jsxImportSource @opentui/react */
2
+ /**
3
+ * OrchestratorPanel — public API class that bridges the imperative
4
+ * executor interface with the React-based session graph TUI.
5
+ */
6
+
7
+ import { createCliRenderer, type CliRenderer } from "@opentui/core";
8
+ import { createRoot } from "@opentui/react";
9
+ import { resolveTheme } from "../runtime/theme.ts";
10
+ import { deriveGraphTheme } from "./graph-theme.ts";
11
+ import type { GraphTheme } from "./graph-theme.ts";
12
+ import { PanelStore } from "./orchestrator-panel-store.ts";
13
+ import { StoreContext, ThemeContext, TmuxSessionContext } from "./orchestrator-panel-contexts.ts";
14
+ import type { PanelSession, PanelOptions } from "./orchestrator-panel-types.ts";
15
+ import { SessionGraphPanel } from "./session-graph-panel.tsx";
16
+ import { ErrorBoundary } from "./error-boundary.tsx";
17
+
18
+ export class OrchestratorPanel {
19
+ private store: PanelStore;
20
+ private renderer: CliRenderer;
21
+ private destroyed = false;
22
+
23
+ private constructor(
24
+ renderer: CliRenderer,
25
+ store: PanelStore,
26
+ graphTheme: GraphTheme,
27
+ tmuxSession: string,
28
+ ) {
29
+ this.renderer = renderer;
30
+ this.store = store;
31
+
32
+ createRoot(renderer).render(
33
+ <StoreContext.Provider value={store}>
34
+ <ThemeContext.Provider value={graphTheme}>
35
+ <TmuxSessionContext.Provider value={tmuxSession}>
36
+ <ErrorBoundary
37
+ fallback={(err) => (
38
+ <box
39
+ width="100%"
40
+ height="100%"
41
+ justifyContent="center"
42
+ alignItems="center"
43
+ backgroundColor={graphTheme.background}
44
+ >
45
+ <text>
46
+ <span fg={graphTheme.error}>
47
+ {`Fatal render error: ${err.message}`}
48
+ </span>
49
+ </text>
50
+ </box>
51
+ )}
52
+ >
53
+ <SessionGraphPanel />
54
+ </ErrorBoundary>
55
+ </TmuxSessionContext.Provider>
56
+ </ThemeContext.Provider>
57
+ </StoreContext.Provider>,
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Create a new OrchestratorPanel with the default CLI renderer.
63
+ *
64
+ * This is the primary entry point — it initialises the terminal renderer
65
+ * and mounts the React-based session graph TUI.
66
+ */
67
+ static async create(options: PanelOptions): Promise<OrchestratorPanel> {
68
+ const renderer = await createCliRenderer({
69
+ exitOnCtrlC: false,
70
+ exitSignals: ["SIGTERM", "SIGQUIT", "SIGABRT", "SIGHUP", "SIGPIPE", "SIGBUS", "SIGFPE"],
71
+ });
72
+ return OrchestratorPanel.createWithRenderer(renderer, options);
73
+ }
74
+
75
+ /** Create with an externally-provided renderer (e.g. a test renderer). */
76
+ static createWithRenderer(
77
+ renderer: CliRenderer,
78
+ options: PanelOptions,
79
+ ): OrchestratorPanel {
80
+ const termTheme = resolveTheme(renderer.themeMode);
81
+ const graphTheme = deriveGraphTheme(termTheme);
82
+ const store = new PanelStore();
83
+ return new OrchestratorPanel(renderer, store, graphTheme, options.tmuxSession);
84
+ }
85
+
86
+ /**
87
+ * Display the workflow overview in the TUI — name, agent, session graph,
88
+ * and the user prompt. Call once after construction before sessions start.
89
+ */
90
+ showWorkflowInfo(
91
+ name: string,
92
+ agent: string,
93
+ sessions: PanelSession[],
94
+ prompt: string,
95
+ ): void {
96
+ this.store.setWorkflowInfo(name, agent, sessions, prompt);
97
+ }
98
+
99
+ /** Mark a session as running in the graph UI. */
100
+ sessionStart(name: string): void {
101
+ this.store.startSession(name);
102
+ }
103
+
104
+ /** Mark a session as successfully completed in the graph UI. */
105
+ sessionSuccess(name: string): void {
106
+ this.store.completeSession(name);
107
+ }
108
+
109
+ /** Mark a session as failed in the graph UI and display the error message. */
110
+ sessionError(name: string, message: string): void {
111
+ this.store.failSession(name, message);
112
+ }
113
+
114
+ /** Show the workflow-complete banner with a link to saved transcripts. */
115
+ showCompletion(workflowName: string, transcriptsPath: string): void {
116
+ this.store.setCompletion(workflowName, transcriptsPath);
117
+ }
118
+
119
+ /** Display a fatal error banner in the TUI. */
120
+ showFatalError(message: string): void {
121
+ this.store.setFatalError(message);
122
+ }
123
+
124
+ /**
125
+ * Block until the user presses `q` or `Ctrl+C` in the TUI.
126
+ * Call after {@link showCompletion} or {@link showFatalError}.
127
+ */
128
+ waitForExit(): Promise<void> {
129
+ this.store.markCompletionReached();
130
+ return new Promise<void>((resolve) => {
131
+ this.store.exitResolve = resolve;
132
+ });
133
+ }
134
+
135
+ /** Tear down the terminal renderer and release resources. Idempotent. */
136
+ destroy(): void {
137
+ if (this.destroyed) return;
138
+ this.destroyed = true;
139
+ try {
140
+ this.renderer.destroy();
141
+ } catch {}
142
+ }
143
+ }
@@ -0,0 +1,364 @@
1
+ /** @jsxImportSource @opentui/react */
2
+ /**
3
+ * Main graph component — renders the navigable session tree with
4
+ * keyboard navigation, scroll management, and live animations.
5
+ */
6
+
7
+ import type { ScrollBoxRenderable } from "@opentui/core";
8
+ import {
9
+ useKeyboard,
10
+ useTerminalDimensions,
11
+ useRenderer,
12
+ } from "@opentui/react";
13
+ import {
14
+ useState,
15
+ useEffect,
16
+ useMemo,
17
+ useCallback,
18
+ useRef,
19
+ useContext,
20
+ } from "react";
21
+ import { tmuxRun } from "../runtime/tmux.ts";
22
+ import {
23
+ useStore,
24
+ useGraphTheme,
25
+ useStoreSubscription,
26
+ TmuxSessionContext,
27
+ } from "./orchestrator-panel-contexts.ts";
28
+ import { computeLayout } from "./layout.ts";
29
+ import { NODE_W, NODE_H } from "./layout.ts";
30
+ import type { LayoutNode } from "./layout.ts";
31
+ import { buildConnector, buildMergeConnector } from "./connectors.ts";
32
+ import type { ConnectorResult } from "./connectors.ts";
33
+ import { NodeCard } from "./node-card.tsx";
34
+ import { Edge } from "./edge.tsx";
35
+ import { Header } from "./header.tsx";
36
+ import { Statusline } from "./statusline.tsx";
37
+
38
+ /** Interval (ms) between pulse animation frames — ~60fps feel. */
39
+ const PULSE_INTERVAL_MS = 60;
40
+ /** Total frames in one pulse cycle (~2s at 60ms/frame). */
41
+ const PULSE_FRAME_COUNT = 32;
42
+ /** Timeout (ms) for "gg" double-tap to jump to root node. */
43
+ const GG_DOUBLE_TAP_MS = 300;
44
+ /** Duration (ms) to display the attach flash message in the statusline. */
45
+ const ATTACH_MSG_DISPLAY_MS = 2400;
46
+
47
+ export function SessionGraphPanel() {
48
+ const store = useStore();
49
+ const theme = useGraphTheme();
50
+ const tmuxSession = useContext(TmuxSessionContext);
51
+ useRenderer();
52
+ const { width: termW, height: termH } = useTerminalDimensions();
53
+
54
+ useStoreSubscription(store);
55
+
56
+ // Compute layout from current session data
57
+ const layout = useMemo(() => computeLayout(store.sessions), [store.version]);
58
+ const nodeList = useMemo(() => Object.values(layout.map), [layout]);
59
+
60
+ const connectors = useMemo(() => {
61
+ const result: ConnectorResult[] = [];
62
+ for (const n of nodeList) {
63
+ // Fan-out: parent → children
64
+ const conn = buildConnector(n, layout.rowH, theme);
65
+ if (conn) result.push(conn);
66
+ // Fan-in: multiple parents → merge child
67
+ if (n.parents.length > 1) {
68
+ const mergeConn = buildMergeConnector(n, layout.rowH, layout.map, theme);
69
+ if (mergeConn) result.push(mergeConn);
70
+ }
71
+ }
72
+ return result;
73
+ }, [nodeList, layout.rowH, theme]);
74
+
75
+ // Focus tracking
76
+ const [focusedId, setFocusedId] = useState("");
77
+ const focusedIdRef = useRef(focusedId);
78
+ focusedIdRef.current = focusedId;
79
+
80
+ // Update focus when sessions first appear
81
+ useEffect(() => {
82
+ if (store.sessions.length > 0 && !layout.map[focusedId]) {
83
+ setFocusedId(store.sessions[0]!.name);
84
+ }
85
+ }, [store.version]);
86
+
87
+ // Pulse animation for running nodes — paused when nothing is running
88
+ const hasRunning = store.sessions.some((s) => s.status === "running");
89
+ const [pulsePhase, setPulsePhase] = useState(0);
90
+ useEffect(() => {
91
+ if (!hasRunning) return;
92
+ const id = setInterval(
93
+ () => setPulsePhase((p: number) => (p + 1) % PULSE_FRAME_COUNT),
94
+ PULSE_INTERVAL_MS,
95
+ );
96
+ return () => clearInterval(id);
97
+ }, [hasRunning]);
98
+
99
+ // Live timer refresh — re-render every second while any session is running
100
+ const [, setTick] = useState(0);
101
+ useEffect(() => {
102
+ const hasRunning = store.sessions.some((s) => s.status === "running");
103
+ if (!hasRunning) return;
104
+ const id = setInterval(() => setTick((t) => t + 1), 1000);
105
+ return () => clearInterval(id);
106
+ }, [store.version]);
107
+
108
+ // Attach flash message
109
+ const [attachMsg, setAttachMsg] = useState("");
110
+ const attachTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
111
+
112
+ // Clear attach timer on unmount to prevent state updates after teardown
113
+ useEffect(() => {
114
+ return () => {
115
+ if (attachTimerRef.current) clearTimeout(attachTimerRef.current);
116
+ };
117
+ }, []);
118
+
119
+ const doAttach = useCallback(
120
+ (id: string) => {
121
+ const n = layout.map[id];
122
+ if (!n) return;
123
+ // Only attach to started sessions (not pending)
124
+ const session = store.sessions.find((s) => s.name === id);
125
+ if (!session || session.status === "pending") return;
126
+
127
+ if (attachTimerRef.current) clearTimeout(attachTimerRef.current);
128
+ setAttachMsg(`\u2192 ${n.name}`);
129
+ attachTimerRef.current = setTimeout(() => setAttachMsg(""), ATTACH_MSG_DISPLAY_MS);
130
+
131
+ try {
132
+ tmuxRun(["select-window", "-t", `${tmuxSession}:${n.name}`]);
133
+ } catch {}
134
+ },
135
+ [layout.map, tmuxSession, store.sessions],
136
+ );
137
+
138
+ // Spatial navigation
139
+ const navigate = useCallback(
140
+ (dir: "left" | "right" | "up" | "down") => {
141
+ const cur = layout.map[focusedId];
142
+ if (!cur) return;
143
+ const cx = cur.x + NODE_W / 2;
144
+ const cy = cur.y + NODE_H / 2;
145
+ let best: LayoutNode | null = null;
146
+ let bestDist = Infinity;
147
+
148
+ for (const n of nodeList) {
149
+ if (n.name === focusedId) continue;
150
+ const nx = n.x + NODE_W / 2;
151
+ const ny = n.y + NODE_H / 2;
152
+ const dx = nx - cx;
153
+ const dy = ny - cy;
154
+
155
+ let valid = false;
156
+ if (dir === "left" && dx < -1) valid = true;
157
+ if (dir === "right" && dx > 1) valid = true;
158
+ if (dir === "up" && dy < -1) valid = true;
159
+ if (dir === "down" && dy > 1) valid = true;
160
+ if (!valid) continue;
161
+
162
+ // Weight: prefer movement along the intended axis
163
+ const dist =
164
+ dir === "left" || dir === "right"
165
+ ? Math.abs(dx) + Math.abs(dy) * 3
166
+ : Math.abs(dy) + Math.abs(dx) * 3;
167
+ if (dist < bestDist) {
168
+ bestDist = dist;
169
+ best = n;
170
+ }
171
+ }
172
+
173
+ if (best) setFocusedId(best.name);
174
+ },
175
+ [focusedId, layout.map, nodeList],
176
+ );
177
+
178
+ // gg double-tap tracking
179
+ const lastKeyRef = useRef({ key: "", time: 0 });
180
+
181
+ // Keyboard handling
182
+ useKeyboard((key) => {
183
+ // Ctrl+C always exits
184
+ if (key.ctrl && key.name === "c") {
185
+ store.resolveExit();
186
+ return;
187
+ }
188
+
189
+ // After completion: only q exits (Enter is for attach, Escape is too easy to hit)
190
+ if (store.completionReached && key.name === "q") {
191
+ store.resolveExit();
192
+ return;
193
+ }
194
+
195
+ // Arrow keys + hjkl navigation
196
+ if (key.name === "left" || key.name === "h") {
197
+ navigate("left");
198
+ return;
199
+ }
200
+ if (key.name === "right" || key.name === "l") {
201
+ navigate("right");
202
+ return;
203
+ }
204
+ if (key.name === "up" || key.name === "k") {
205
+ navigate("up");
206
+ return;
207
+ }
208
+ if (key.name === "down" || key.name === "j") {
209
+ navigate("down");
210
+ return;
211
+ }
212
+ if (key.name === "tab") {
213
+ navigate(key.shift ? "left" : "right");
214
+ return;
215
+ }
216
+
217
+ // Enter: attach to focused node's tmux window
218
+ if (key.name === "return") {
219
+ doAttach(focusedIdRef.current);
220
+ return;
221
+ }
222
+
223
+ // G: focus deepest leaf (rightmost in DFS order)
224
+ if (key.name === "g" && key.shift) {
225
+ let deepest: LayoutNode | null = null;
226
+ for (const n of nodeList) {
227
+ if (
228
+ !deepest ||
229
+ n.depth > deepest.depth ||
230
+ (n.depth === deepest.depth && n.x > deepest.x)
231
+ ) {
232
+ deepest = n;
233
+ }
234
+ }
235
+ if (deepest) setFocusedId(deepest.name);
236
+ return;
237
+ }
238
+
239
+ // gg: focus root (double-tap within 300ms)
240
+ if (key.name === "g" && !key.shift) {
241
+ const now = Date.now();
242
+ if (lastKeyRef.current.key === "g" && now - lastKeyRef.current.time < GG_DOUBLE_TAP_MS) {
243
+ setFocusedId(store.sessions[0]?.name ?? "");
244
+ lastKeyRef.current.key = "";
245
+ } else {
246
+ lastKeyRef.current.key = "g";
247
+ lastKeyRef.current.time = now;
248
+ }
249
+ return;
250
+ }
251
+ });
252
+
253
+ // Auto-scroll to keep focused node visible
254
+ const scrollboxRef = useRef<ScrollBoxRenderable | null>(null);
255
+ const focused = layout.map[focusedId];
256
+
257
+ // Center the graph when it's smaller than the viewport.
258
+ // viewportH = terminal height minus header (1) and statusline (1).
259
+ const viewportH = Math.max(0, termH - 2);
260
+ const padX = Math.max(0, Math.floor((termW - layout.width) / 2));
261
+ const padY = Math.max(0, Math.floor((viewportH - layout.height) / 2));
262
+ const canvasW = Math.max(layout.width, termW) + padX;
263
+ const canvasH = Math.max(layout.height, viewportH) + padY;
264
+
265
+ useEffect(() => {
266
+ const sb = scrollboxRef.current;
267
+ if (!sb || !focused) return;
268
+
269
+ // Node bounds in canvas coordinates (with centering offset)
270
+ const nodeLeft = focused.x + padX;
271
+ const nodeTop = focused.y + padY;
272
+ const nodeRight = nodeLeft + NODE_W;
273
+ const nodeBottom = nodeTop + (layout.rowH[focused.depth] ?? NODE_H);
274
+
275
+ // Current visible viewport bounds
276
+ const curX = sb.scrollLeft;
277
+ const curY = sb.scrollTop;
278
+ const margin = 2;
279
+
280
+ let targetX = curX;
281
+ let targetY = curY;
282
+
283
+ // Only scroll if the node extends outside the visible area
284
+ if (nodeLeft - margin < curX) {
285
+ targetX = Math.max(0, nodeLeft - margin);
286
+ } else if (nodeRight + margin > curX + termW) {
287
+ targetX = Math.max(0, nodeRight + margin - termW);
288
+ }
289
+
290
+ if (nodeTop - margin < curY) {
291
+ targetY = Math.max(0, nodeTop - margin);
292
+ } else if (nodeBottom + margin > curY + viewportH) {
293
+ targetY = Math.max(0, nodeBottom + margin - viewportH);
294
+ }
295
+
296
+ if (targetX !== curX || targetY !== curY) {
297
+ sb.scrollTo({ x: targetX, y: targetY });
298
+ }
299
+ }, [focusedId, focused, termW, termH, padX, padY, viewportH, layout.rowH]);
300
+
301
+ return (
302
+ <box width="100%" height="100%" flexDirection="column" backgroundColor={theme.background}>
303
+ <Header />
304
+
305
+ {/* Graph canvas — scrollable both axes, centered when smaller than viewport */}
306
+ <scrollbox
307
+ ref={scrollboxRef}
308
+ scrollX
309
+ scrollY
310
+ focused
311
+ style={{
312
+ flexGrow: 1,
313
+ rootOptions: {
314
+ backgroundColor: theme.background,
315
+ border: false,
316
+ },
317
+ contentOptions: {
318
+ minHeight: 0,
319
+ minWidth: 0,
320
+ },
321
+ scrollbarOptions: {
322
+ visible: false,
323
+ showArrows: false,
324
+ trackOptions: {
325
+ foregroundColor: theme.borderActive,
326
+ backgroundColor: theme.background,
327
+ },
328
+ },
329
+ horizontalScrollbarOptions: {
330
+ visible: false,
331
+ showArrows: false,
332
+ trackOptions: {
333
+ foregroundColor: theme.borderActive,
334
+ backgroundColor: theme.background,
335
+ },
336
+ },
337
+ }}
338
+ >
339
+ <box width={canvasW} height={canvasH} position="relative">
340
+ {/* Offset all content by padding to center the graph */}
341
+ <box position="absolute" left={padX} top={padY} width={layout.width} height={layout.height}>
342
+ {/* Connectors (rendered behind nodes) */}
343
+ {connectors.map((conn, i) => (
344
+ <Edge key={`e${i}`} {...conn} />
345
+ ))}
346
+
347
+ {/* Node cards */}
348
+ {nodeList.map((n) => (
349
+ <NodeCard
350
+ key={n.name}
351
+ node={n}
352
+ focused={n.name === focusedId}
353
+ pulsePhase={pulsePhase}
354
+ displayH={layout.rowH[n.depth] ?? NODE_H}
355
+ />
356
+ ))}
357
+ </box>
358
+ </box>
359
+ </scrollbox>
360
+
361
+ <Statusline focusedNode={focused} attachMsg={attachMsg} />
362
+ </box>
363
+ );
364
+ }
@@ -0,0 +1,32 @@
1
+ // ─── Status Helpers ───────────────────────────────
2
+
3
+ import type { GraphTheme } from "./graph-theme.ts";
4
+
5
+ export function statusColor(status: string, theme: GraphTheme): string {
6
+ return (
7
+ {
8
+ running: theme.warning,
9
+ complete: theme.success,
10
+ pending: theme.textDim,
11
+ error: theme.error,
12
+ }[status] ?? theme.textDim
13
+ );
14
+ }
15
+
16
+ export function statusLabel(status: string): string {
17
+ return (
18
+ { running: "running", complete: "done", pending: "waiting", error: "failed" }[status] ??
19
+ status
20
+ );
21
+ }
22
+
23
+ export function statusIcon(status: string): string {
24
+ return { running: "●", complete: "✓", pending: "○", error: "✗" }[status] ?? "○";
25
+ }
26
+
27
+ // ─── Duration ─────────────────────────────────────
28
+
29
+ export function fmtDuration(ms: number): string {
30
+ const sec = Math.max(0, Math.floor(ms / 1000));
31
+ return `${Math.floor(sec / 60)}m ${String(sec % 60).padStart(2, "0")}s`;
32
+ }
@@ -0,0 +1,63 @@
1
+ /** @jsxImportSource @opentui/react */
2
+
3
+ import { useStore, useGraphTheme } from "./orchestrator-panel-contexts.ts";
4
+ import { statusIcon, statusColor, statusLabel } from "./status-helpers.ts";
5
+ import type { LayoutNode } from "./layout.ts";
6
+
7
+ export function Statusline({
8
+ focusedNode,
9
+ attachMsg,
10
+ }: {
11
+ focusedNode: LayoutNode | undefined;
12
+ attachMsg: string;
13
+ }) {
14
+ const store = useStore();
15
+ const theme = useGraphTheme();
16
+ const ni = focusedNode ? statusIcon(focusedNode.status) : "";
17
+ const nc = focusedNode ? statusColor(focusedNode.status, theme) : theme.textDim;
18
+ const canExit = store.completionReached;
19
+
20
+ return (
21
+ <box height={1} flexDirection="row" backgroundColor={theme.backgroundElement}>
22
+ <box backgroundColor={theme.primary} paddingLeft={1} paddingRight={1} alignItems="center">
23
+ <text fg={theme.backgroundElement}>
24
+ <strong>GRAPH</strong>
25
+ </text>
26
+ </box>
27
+
28
+ {focusedNode ? (
29
+ <box backgroundColor="transparent" paddingLeft={1} paddingRight={1} alignItems="center">
30
+ <text>
31
+ <span fg={nc}>{ni} </span>
32
+ <span fg={theme.text}>{focusedNode.name}</span>
33
+ <span fg={theme.textMuted}> {"\u00B7"} {statusLabel(focusedNode.status)}</span>
34
+ {focusedNode.error ? (
35
+ <span fg={theme.error}> {"\u00B7"} {focusedNode.error}</span>
36
+ ) : null}
37
+ </text>
38
+ </box>
39
+ ) : null}
40
+
41
+ <box flexGrow={1} />
42
+
43
+ <box paddingRight={2} alignItems="center">
44
+ {attachMsg ? (
45
+ <text fg={theme.text}>
46
+ <strong>{attachMsg}</strong>
47
+ </text>
48
+ ) : (
49
+ <text>
50
+ <span fg={theme.text}>{"\u2191"} {"\u2193"} {"\u2190"} {"\u2192"}</span>
51
+ <span fg={theme.textMuted}> navigate</span>
52
+ <span fg={theme.textDim}> {"\u00B7"} </span>
53
+ <span fg={theme.text}>{"\u21B5"}</span>
54
+ <span fg={theme.textMuted}> attach</span>
55
+ {canExit ? <span fg={theme.textDim}> {"\u00B7"} </span> : null}
56
+ {canExit ? <span fg={theme.text}>q</span> : null}
57
+ {canExit ? <span fg={theme.textMuted}> quit</span> : null}
58
+ </text>
59
+ )}
60
+ </box>
61
+ </box>
62
+ );
63
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Workflow Builder — chainable DSL for defining multi-session workflows.
3
+ *
4
+ * Usage:
5
+ * defineWorkflow({ name: "ralph", description: "..." })
6
+ * .session({ name: "research", run: async (ctx) => { ... } })
7
+ * .session({ name: "plan", run: async (ctx) => { ... } })
8
+ * .compile()
9
+ */
10
+
11
+ import type { WorkflowOptions, SessionOptions, WorkflowDefinition } from "./types.ts";
12
+
13
+ /**
14
+ * Chainable workflow builder. Records session definitions in order,
15
+ * then .compile() seals them into a WorkflowDefinition.
16
+ */
17
+ export class WorkflowBuilder {
18
+ /** @internal Brand for detection across package boundaries */
19
+ readonly __brand = "WorkflowBuilder" as const;
20
+ private readonly options: WorkflowOptions;
21
+ private readonly stepDefs: SessionOptions[][] = [];
22
+ private readonly namesSeen = new Set<string>();
23
+
24
+ constructor(options: WorkflowOptions) {
25
+ this.options = options;
26
+ }
27
+
28
+ /**
29
+ * Add a session (or parallel group of sessions) to the workflow.
30
+ *
31
+ * Pass a single SessionOptions for sequential execution.
32
+ * Pass an array of SessionOptions for parallel execution —
33
+ * all sessions in the array run concurrently, and the next
34
+ * .session() call waits for the entire group to complete.
35
+ */
36
+ session(opts: SessionOptions | SessionOptions[]): this {
37
+ const step = Array.isArray(opts) ? opts : [opts];
38
+ if (step.length === 0) {
39
+ throw new Error("session() requires at least one SessionOptions.");
40
+ }
41
+ for (const s of step) {
42
+ if (!s.name || s.name.trim() === "") {
43
+ throw new Error("Session name is required.");
44
+ }
45
+ if (typeof s.run !== "function") {
46
+ throw new Error(`Session "${s.name}": run must be a function, got ${typeof s.run}.`);
47
+ }
48
+ if (this.namesSeen.has(s.name)) {
49
+ throw new Error(`Duplicate session name: "${s.name}"`);
50
+ }
51
+ this.namesSeen.add(s.name);
52
+ }
53
+ this.stepDefs.push(step);
54
+ return this;
55
+ }
56
+
57
+ /**
58
+ * Compile the workflow into a sealed WorkflowDefinition.
59
+ *
60
+ * After calling compile(), no more sessions can be added.
61
+ * The returned object is consumed by the Atomic CLI runtime.
62
+ */
63
+ compile(): WorkflowDefinition {
64
+ if (this.stepDefs.length === 0) {
65
+ throw new Error(`Workflow "${this.options.name}" has no sessions. Add at least one .session() call.`);
66
+ }
67
+
68
+ return {
69
+ __brand: "WorkflowDefinition" as const,
70
+ name: this.options.name,
71
+ description: this.options.description ?? "",
72
+ steps: Object.freeze(this.stepDefs.map((step) => Object.freeze([...step]))),
73
+ };
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Entry point for defining a workflow.
79
+ *
80
+ * @example
81
+ * ```typescript
82
+ * import { defineWorkflow } from "@bastani/atomic/workflows";
83
+ *
84
+ * export default defineWorkflow({
85
+ * name: "ralph",
86
+ * description: "Research, plan, implement",
87
+ * })
88
+ * .session({ name: "research", run: async (ctx) => { ... } })
89
+ * .session({ name: "plan", run: async (ctx) => { ... } })
90
+ * .compile();
91
+ * ```
92
+ */
93
+ export function defineWorkflow(options: WorkflowOptions): WorkflowBuilder {
94
+ if (!options.name || options.name.trim() === "") {
95
+ throw new Error("Workflow name is required.");
96
+ }
97
+ return new WorkflowBuilder(options);
98
+ }