@bastani/atomic 0.5.4 → 0.5.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.
Files changed (173) hide show
  1. package/README.md +44 -1
  2. package/dist/lib/path-root-guard.d.ts +4 -0
  3. package/dist/lib/path-root-guard.d.ts.map +1 -0
  4. package/dist/sdk/components/color-utils.d.ts +1 -0
  5. package/dist/sdk/components/color-utils.d.ts.map +1 -0
  6. package/dist/sdk/components/connectors.d.ts +3 -2
  7. package/dist/sdk/components/connectors.d.ts.map +1 -0
  8. package/dist/sdk/components/connectors.test.d.ts +1 -0
  9. package/dist/sdk/components/connectors.test.d.ts.map +1 -0
  10. package/dist/sdk/components/edge.d.ts +2 -1
  11. package/dist/sdk/components/edge.d.ts.map +1 -0
  12. package/dist/sdk/components/error-boundary.d.ts +1 -0
  13. package/dist/sdk/components/error-boundary.d.ts.map +1 -0
  14. package/dist/sdk/components/graph-theme.d.ts +2 -1
  15. package/dist/sdk/components/graph-theme.d.ts.map +1 -0
  16. package/dist/sdk/components/header.d.ts +1 -0
  17. package/dist/sdk/components/header.d.ts.map +1 -0
  18. package/dist/sdk/components/hooks.d.ts +15 -0
  19. package/dist/sdk/components/hooks.d.ts.map +1 -0
  20. package/dist/sdk/components/layout.d.ts +2 -1
  21. package/dist/sdk/components/layout.d.ts.map +1 -0
  22. package/dist/sdk/components/layout.test.d.ts +1 -0
  23. package/dist/sdk/components/layout.test.d.ts.map +1 -0
  24. package/dist/sdk/components/node-card.d.ts +5 -3
  25. package/dist/sdk/components/node-card.d.ts.map +1 -0
  26. package/dist/sdk/components/orchestrator-panel-contexts.d.ts +3 -2
  27. package/dist/sdk/components/orchestrator-panel-contexts.d.ts.map +1 -0
  28. package/dist/sdk/components/orchestrator-panel-store.d.ts +2 -1
  29. package/dist/sdk/components/orchestrator-panel-store.d.ts.map +1 -0
  30. package/dist/sdk/components/orchestrator-panel-store.test.d.ts +1 -0
  31. package/dist/sdk/components/orchestrator-panel-store.test.d.ts.map +1 -0
  32. package/dist/sdk/components/orchestrator-panel-types.d.ts +1 -0
  33. package/dist/sdk/components/orchestrator-panel-types.d.ts.map +1 -0
  34. package/dist/sdk/components/orchestrator-panel.d.ts +2 -1
  35. package/dist/sdk/components/orchestrator-panel.d.ts.map +1 -0
  36. package/dist/sdk/components/session-graph-panel.d.ts +1 -0
  37. package/dist/sdk/components/session-graph-panel.d.ts.map +1 -0
  38. package/dist/sdk/components/status-helpers.d.ts +2 -1
  39. package/dist/sdk/components/status-helpers.d.ts.map +1 -0
  40. package/dist/sdk/components/statusline.d.ts +2 -1
  41. package/dist/sdk/components/statusline.d.ts.map +1 -0
  42. package/dist/sdk/components/workflow-picker-panel.d.ts +11 -8
  43. package/dist/sdk/components/workflow-picker-panel.d.ts.map +1 -0
  44. package/dist/sdk/define-workflow.d.ts +2 -1
  45. package/dist/sdk/define-workflow.d.ts.map +1 -0
  46. package/dist/sdk/define-workflow.test.d.ts +1 -0
  47. package/dist/sdk/define-workflow.test.d.ts.map +1 -0
  48. package/dist/sdk/errors.d.ts +3 -0
  49. package/dist/sdk/errors.d.ts.map +1 -0
  50. package/dist/sdk/errors.test.d.ts +2 -0
  51. package/dist/sdk/errors.test.d.ts.map +1 -0
  52. package/dist/sdk/index.d.ts +7 -6
  53. package/dist/sdk/index.d.ts.map +1 -0
  54. package/dist/sdk/providers/claude.d.ts +17 -6
  55. package/dist/sdk/providers/claude.d.ts.map +1 -0
  56. package/dist/sdk/providers/copilot.d.ts +2 -5
  57. package/dist/sdk/providers/copilot.d.ts.map +1 -0
  58. package/dist/sdk/providers/opencode.d.ts +2 -5
  59. package/dist/sdk/providers/opencode.d.ts.map +1 -0
  60. package/dist/sdk/runtime/discovery.d.ts +2 -1
  61. package/dist/sdk/runtime/discovery.d.ts.map +1 -0
  62. package/dist/sdk/runtime/executor-entry.d.ts +1 -0
  63. package/dist/sdk/runtime/executor-entry.d.ts.map +1 -0
  64. package/dist/sdk/runtime/executor.d.ts +3 -6
  65. package/dist/sdk/runtime/executor.d.ts.map +1 -0
  66. package/dist/sdk/runtime/executor.test.d.ts +1 -0
  67. package/dist/sdk/runtime/executor.test.d.ts.map +1 -0
  68. package/dist/sdk/runtime/graph-inference.d.ts +1 -0
  69. package/dist/sdk/runtime/graph-inference.d.ts.map +1 -0
  70. package/dist/sdk/runtime/loader.d.ts +5 -7
  71. package/dist/sdk/runtime/loader.d.ts.map +1 -0
  72. package/dist/sdk/runtime/panel.d.ts +3 -2
  73. package/dist/sdk/runtime/panel.d.ts.map +1 -0
  74. package/dist/sdk/runtime/theme.d.ts +1 -0
  75. package/dist/sdk/runtime/theme.d.ts.map +1 -0
  76. package/dist/sdk/runtime/tmux.d.ts +26 -8
  77. package/dist/sdk/runtime/tmux.d.ts.map +1 -0
  78. package/dist/sdk/types.d.ts +23 -1
  79. package/dist/sdk/types.d.ts.map +1 -0
  80. package/dist/sdk/workflows/builtin/deep-research-codebase/claude/index.d.ts +1 -0
  81. package/dist/sdk/workflows/builtin/deep-research-codebase/claude/index.d.ts.map +1 -0
  82. package/dist/sdk/workflows/builtin/deep-research-codebase/copilot/index.d.ts +1 -0
  83. package/dist/sdk/workflows/builtin/deep-research-codebase/copilot/index.d.ts.map +1 -0
  84. package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/heuristic.d.ts +1 -0
  85. package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/heuristic.d.ts.map +1 -0
  86. package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.d.ts +2 -1
  87. package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.d.ts.map +1 -0
  88. package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/scout.d.ts +1 -0
  89. package/dist/sdk/workflows/builtin/deep-research-codebase/helpers/scout.d.ts.map +1 -0
  90. package/dist/sdk/workflows/builtin/deep-research-codebase/opencode/index.d.ts +1 -0
  91. package/dist/sdk/workflows/builtin/deep-research-codebase/opencode/index.d.ts.map +1 -0
  92. package/dist/sdk/workflows/builtin/ralph/claude/index.d.ts +1 -0
  93. package/dist/sdk/workflows/builtin/ralph/claude/index.d.ts.map +1 -0
  94. package/dist/sdk/workflows/builtin/ralph/copilot/index.d.ts +1 -0
  95. package/dist/sdk/workflows/builtin/ralph/copilot/index.d.ts.map +1 -0
  96. package/dist/sdk/workflows/builtin/ralph/helpers/git.d.ts +1 -0
  97. package/dist/sdk/workflows/builtin/ralph/helpers/git.d.ts.map +1 -0
  98. package/dist/sdk/workflows/builtin/ralph/helpers/prompts.d.ts +1 -0
  99. package/dist/sdk/workflows/builtin/ralph/helpers/prompts.d.ts.map +1 -0
  100. package/dist/sdk/workflows/builtin/ralph/helpers/review.d.ts +2 -1
  101. package/dist/sdk/workflows/builtin/ralph/helpers/review.d.ts.map +1 -0
  102. package/dist/sdk/workflows/builtin/ralph/opencode/index.d.ts +1 -0
  103. package/dist/sdk/workflows/builtin/ralph/opencode/index.d.ts.map +1 -0
  104. package/dist/sdk/workflows/index.d.ts +14 -14
  105. package/dist/sdk/workflows/index.d.ts.map +1 -0
  106. package/dist/services/config/definitions.d.ts +85 -0
  107. package/dist/services/config/definitions.d.ts.map +1 -0
  108. package/dist/services/system/copy.d.ts +77 -0
  109. package/dist/services/system/copy.d.ts.map +1 -0
  110. package/dist/services/system/detect.d.ts +75 -0
  111. package/dist/services/system/detect.d.ts.map +1 -0
  112. package/package.json +13 -34
  113. package/src/cli.ts +11 -10
  114. package/src/commands/cli/chat/index.ts +11 -11
  115. package/src/commands/cli/chat.ts +1 -1
  116. package/src/commands/cli/config.ts +10 -9
  117. package/src/commands/cli/init/index.ts +11 -11
  118. package/src/commands/cli/init/onboarding.ts +4 -4
  119. package/src/commands/cli/init/scm.ts +5 -5
  120. package/src/commands/cli/init.ts +1 -1
  121. package/src/commands/cli/workflow-command.test.ts +19 -11
  122. package/src/commands/cli/workflow.test.ts +2 -2
  123. package/src/commands/cli/workflow.ts +6 -6
  124. package/src/lib/merge.ts +17 -31
  125. package/src/lib/path-root-guard.ts +2 -2
  126. package/src/lib/spawn.ts +13 -7
  127. package/src/scripts/bump-version.ts +1 -1
  128. package/src/scripts/constants.ts +2 -2
  129. package/src/sdk/components/header.tsx +21 -23
  130. package/src/sdk/components/hooks.ts +21 -0
  131. package/src/sdk/components/node-card.tsx +3 -2
  132. package/src/sdk/components/session-graph-panel.tsx +14 -18
  133. package/src/sdk/components/workflow-picker-panel.tsx +201 -216
  134. package/src/sdk/errors.test.ts +56 -0
  135. package/src/sdk/errors.ts +5 -0
  136. package/src/sdk/providers/claude.ts +279 -70
  137. package/src/sdk/providers/copilot.ts +17 -27
  138. package/src/sdk/providers/opencode.ts +17 -27
  139. package/src/sdk/runtime/discovery.ts +18 -18
  140. package/src/sdk/runtime/executor.test.ts +15 -48
  141. package/src/sdk/runtime/executor.ts +152 -121
  142. package/src/sdk/runtime/loader.ts +16 -21
  143. package/src/sdk/runtime/tmux.ts +95 -32
  144. package/src/sdk/types.ts +45 -0
  145. package/src/sdk/workflows/builtin/deep-research-codebase/claude/index.ts +27 -0
  146. package/src/sdk/workflows/builtin/deep-research-codebase/helpers/prompts.ts +25 -16
  147. package/src/sdk/workflows/builtin/deep-research-codebase/helpers/scout.ts +25 -24
  148. package/src/sdk/workflows/builtin/ralph/claude/index.ts +5 -0
  149. package/src/sdk/workflows/index.ts +3 -3
  150. package/src/services/config/atomic-config.ts +7 -8
  151. package/src/services/config/atomic-global-config.ts +9 -9
  152. package/src/services/config/config-path.ts +1 -1
  153. package/src/services/config/definitions.ts +3 -4
  154. package/src/services/config/index.ts +1 -1
  155. package/src/services/config/settings.ts +30 -36
  156. package/src/services/system/agents.ts +3 -3
  157. package/src/services/system/auto-sync.ts +9 -9
  158. package/src/services/system/copy.ts +9 -9
  159. package/src/services/system/file-lock.ts +2 -2
  160. package/src/services/system/install-ui.ts +2 -2
  161. package/src/services/system/skills.ts +1 -1
  162. package/src/theme/colors.ts +1 -1
  163. package/src/theme/logo.ts +1 -1
  164. package/tsconfig.json +3 -4
  165. package/dist/chunk-1gb5qxz9.js +0 -1
  166. package/dist/chunk-fdk7tact.js +0 -417
  167. package/dist/chunk-xkxndz5g.js +0 -1041
  168. package/dist/sdk/index.js +0 -52
  169. package/dist/sdk/workflows/builtin/ralph/claude/index.js +0 -96
  170. package/dist/sdk/workflows/builtin/ralph/copilot/index.js +0 -119
  171. package/dist/sdk/workflows/builtin/ralph/opencode/index.js +0 -148
  172. package/dist/sdk/workflows/index.js +0 -100
  173. package/src/commands/cli/chat/client.ts +0 -18
@@ -8,10 +8,9 @@
8
8
  * Project-local workflows take precedence over global ones with the same name.
9
9
  */
10
10
 
11
- import { join } from "path";
12
- import { readdir, writeFile } from "fs/promises";
13
- import { existsSync, readdirSync } from "fs";
14
- import { homedir } from "os";
11
+ import { join } from "node:path";
12
+ import { readdir } from "node:fs/promises";
13
+ import { homedir } from "node:os";
15
14
  import ignore from "ignore";
16
15
  import type { AgentType, WorkflowInput } from "../types.ts";
17
16
  import { WorkflowLoader } from "./loader.ts";
@@ -61,7 +60,7 @@ async function loadWorkflowsGitignore(workflowsDir: string): Promise<ignore.Igno
61
60
  content = await Bun.file(gitignorePath).text();
62
61
  } catch {
63
62
  // Missing — regenerate from the canonical template
64
- await writeFile(gitignorePath, WORKFLOWS_GITIGNORE);
63
+ await Bun.write(gitignorePath, WORKFLOWS_GITIGNORE);
65
64
  content = WORKFLOWS_GITIGNORE;
66
65
  }
67
66
  return ignore().add(content);
@@ -147,25 +146,27 @@ const BUILTIN_WORKFLOWS_DIR = join(
147
146
  * Scans `src/sdk/workflows/builtin/<name>/<agent>/index.ts` for known
148
147
  * workflow directories. Returns entries with `source: "builtin"`.
149
148
  */
150
- function discoverBuiltinWorkflows(
149
+ async function discoverBuiltinWorkflows(
151
150
  agentFilter?: AgentType,
152
- ): DiscoveredWorkflow[] {
151
+ ): Promise<DiscoveredWorkflow[]> {
153
152
  const results: DiscoveredWorkflow[] = [];
154
153
  const agents = agentFilter ? [agentFilter] : AGENTS;
155
154
 
156
- let workflowNames: string[];
155
+ let workflowEntries;
157
156
  try {
158
- workflowNames = readdirSync(BUILTIN_WORKFLOWS_DIR, { withFileTypes: true })
159
- .filter((d) => d.isDirectory())
160
- .map((d) => d.name);
157
+ workflowEntries = await readdir(BUILTIN_WORKFLOWS_DIR, { withFileTypes: true });
161
158
  } catch {
162
159
  return results;
163
160
  }
164
161
 
162
+ const workflowNames = workflowEntries
163
+ .filter((d) => d.isDirectory())
164
+ .map((d) => d.name);
165
+
165
166
  for (const name of workflowNames) {
166
167
  for (const agent of agents) {
167
168
  const indexPath = join(BUILTIN_WORKFLOWS_DIR, name, agent, "index.ts");
168
- if (existsSync(indexPath)) {
169
+ if (await Bun.file(indexPath).exists()) {
169
170
  results.push({ name, agent, path: indexPath, source: "builtin" });
170
171
  }
171
172
  }
@@ -219,17 +220,16 @@ export async function discoverWorkflows(
219
220
  // name-based across every agent: a local `ralph` for copilot is still
220
221
  // reserved by a builtin `ralph` for claude, even when the discovery
221
222
  // call was filtered to copilot.
222
- const allBuiltins = discoverBuiltinWorkflows();
223
+ const [allBuiltins, globalResults, localResults] = await Promise.all([
224
+ discoverBuiltinWorkflows(),
225
+ discoverFromBaseDir(globalDir, "global", agentFilter),
226
+ discoverFromBaseDir(localDir, "local", agentFilter),
227
+ ]);
223
228
  const reservedNames = new Set<string>(allBuiltins.map((w) => w.name));
224
229
  const builtinResults = agentFilter
225
230
  ? allBuiltins.filter((w) => w.agent === agentFilter)
226
231
  : allBuiltins;
227
232
 
228
- const [globalResults, localResults] = await Promise.all([
229
- discoverFromBaseDir(globalDir, "global", agentFilter),
230
- discoverFromBaseDir(localDir, "local", agentFilter),
231
- ]);
232
-
233
233
  // Drop any local/global workflow whose name matches a reserved
234
234
  // builtin. This happens BEFORE both merge and unmerged code paths so
235
235
  // reserved names never leak into `findWorkflow`, the picker, or
@@ -2,7 +2,6 @@ import { test, expect, describe } from "bun:test";
2
2
  import {
3
3
  renderMessagesToText,
4
4
  hasContent,
5
- isTextBlockArray,
6
5
  escBash,
7
6
  escPwsh,
8
7
  } from "./executor.ts";
@@ -193,10 +192,23 @@ describe("renderMessagesToText", () => {
193
192
  expect(renderMessagesToText(messages)).toBe("");
194
193
  });
195
194
 
196
- test("falls back to JSON.stringify for claude assistant with unknown message shape", () => {
195
+ test("returns empty string for claude assistant with unknown message shape", () => {
197
196
  const unknownMsg = { weird: "shape", count: 99 };
198
197
  const messages: SavedMessage[] = [makeClaudeMessage("assistant", unknownMsg)];
199
- expect(renderMessagesToText(messages)).toBe(JSON.stringify(unknownMsg));
198
+ expect(renderMessagesToText(messages)).toBe("");
199
+ });
200
+
201
+ test("extracts text blocks from mixed claude content array (text + tool_use)", () => {
202
+ const messages: SavedMessage[] = [
203
+ makeClaudeMessage("assistant", {
204
+ content: [
205
+ { type: "text", text: "I'll read the file" },
206
+ { type: "tool_use", id: "tu-1", name: "Read", input: { path: "/tmp/foo" } },
207
+ { type: "text", text: "Here's what I found" },
208
+ ],
209
+ }),
210
+ ];
211
+ expect(renderMessagesToText(messages)).toBe("I'll read the file\nHere's what I found");
200
212
  });
201
213
 
202
214
  // --- Mixed providers ---
@@ -252,51 +264,6 @@ describe("hasContent", () => {
252
264
  });
253
265
  });
254
266
 
255
- // ---------------------------------------------------------------------------
256
- // isTextBlockArray type guard
257
- // ---------------------------------------------------------------------------
258
-
259
- describe("isTextBlockArray", () => {
260
- test("returns true for a valid array of text blocks", () => {
261
- expect(isTextBlockArray([{ type: "text", text: "hi" }])).toBe(true);
262
- });
263
-
264
- test("returns true for an array with multiple text blocks", () => {
265
- expect(
266
- isTextBlockArray([
267
- { type: "text", text: "first" },
268
- { type: "text", text: "second" },
269
- ]),
270
- ).toBe(true);
271
- });
272
-
273
- test("returns true for an empty array (vacuously satisfies every element check)", () => {
274
- // Array.prototype.every returns true on empty arrays — the empty array
275
- // satisfies the type guard because there are no elements that violate it.
276
- expect(isTextBlockArray([])).toBe(true);
277
- });
278
-
279
- test("returns false for array with wrong block shape (missing text)", () => {
280
- expect(isTextBlockArray([{ type: "text" }])).toBe(false);
281
- });
282
-
283
- test("returns false for array with wrong type value", () => {
284
- expect(isTextBlockArray([{ type: "tool_use", text: "hi" }])).toBe(false);
285
- });
286
-
287
- test("returns false for non-array value", () => {
288
- expect(isTextBlockArray("not an array")).toBe(false);
289
- });
290
-
291
- test("returns false for null", () => {
292
- expect(isTextBlockArray(null)).toBe(false);
293
- });
294
-
295
- test("returns false when array elements are not objects", () => {
296
- expect(isTextBlockArray(["text"])).toBe(false);
297
- });
298
- });
299
-
300
267
  // ---------------------------------------------------------------------------
301
268
  // escBash — shell escaping for bash double-quoted strings
302
269
  // ---------------------------------------------------------------------------
@@ -14,9 +14,9 @@
14
14
  * the entry point and reached through package.json `exports` self-referencing.
15
15
  */
16
16
 
17
- import { join, resolve } from "path";
18
- import { homedir } from "os";
19
- import { mkdir, writeFile, readFile } from "fs/promises";
17
+ import { join, resolve } from "node:path";
18
+ import { homedir } from "node:os";
19
+ import { writeFile } from "node:fs/promises";
20
20
  import type {
21
21
  WorkflowDefinition,
22
22
  WorkflowContext,
@@ -30,7 +30,11 @@ import type {
30
30
  SaveTranscript,
31
31
  StageClientOptions,
32
32
  StageSessionOptions,
33
+ ProviderClient,
34
+ ProviderSession,
33
35
  } from "../types.ts";
36
+ import { isValidAgent } from "../../services/config/definitions.ts";
37
+ import { ensureDir } from "../../services/system/copy.ts";
34
38
  import type { SessionEvent } from "@github/copilot-sdk";
35
39
  import type { SessionPromptResponse } from "@opencode-ai/sdk/v2";
36
40
  import type { SessionMessage } from "@anthropic-ai/claude-agent-sdk";
@@ -44,6 +48,7 @@ import {
44
48
  } from "../providers/claude.ts";
45
49
  import { OrchestratorPanel } from "./panel.tsx";
46
50
  import { GraphFrontierTracker } from "./graph-inference.ts";
51
+ import { errorMessage } from "../errors.ts";
47
52
 
48
53
  /** Maximum time (ms) to wait for an agent's server to become reachable. */
49
54
  const SERVER_WAIT_TIMEOUT_MS = 60_000;
@@ -85,6 +90,21 @@ class WorkflowAbortError extends Error {
85
90
  }
86
91
  }
87
92
 
93
+ /** Compile-time exhaustiveness guard for discriminated unions. */
94
+ function assertNever(value: never): never {
95
+ throw new Error(`Unhandled agent type: ${String(value)}`);
96
+ }
97
+
98
+ // Re-export for backward compatibility (tests import from here)
99
+ export { errorMessage } from "../errors.ts";
100
+
101
+ /** Runtime guard for deserialized SavedMessage objects. */
102
+ function isValidSavedMessage(msg: unknown): msg is SavedMessage {
103
+ if (!msg || typeof msg !== "object") return false;
104
+ const m = msg as Record<string, unknown>;
105
+ return m.provider === "copilot" || m.provider === "opencode" || m.provider === "claude";
106
+ }
107
+
88
108
  export interface WorkflowRunOptions {
89
109
  /** The compiled workflow definition */
90
110
  definition: WorkflowDefinition;
@@ -180,10 +200,7 @@ function buildPaneCommand(
180
200
  envVars,
181
201
  };
182
202
  default:
183
- return {
184
- command: [cmd, ...chatFlags].join(" "),
185
- envVars,
186
- };
203
+ return assertNever(agent);
187
204
  }
188
205
  }
189
206
 
@@ -226,10 +243,6 @@ async function waitForServer(
226
243
  return serverUrl;
227
244
  }
228
245
 
229
- async function ensureDir(dir: string): Promise<void> {
230
- await mkdir(dir, { recursive: true });
231
- }
232
-
233
246
  /**
234
247
  * Escape a string for safe interpolation inside a bash double-quoted string.
235
248
  *
@@ -274,7 +287,7 @@ export function parseInputsEnv(raw: string | undefined): Record<string, string>
274
287
  return {};
275
288
  }
276
289
  const out: Record<string, string> = {};
277
- for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
290
+ for (const [k, v] of Object.entries(parsed)) {
278
291
  if (typeof v === "string") out[k] = v;
279
292
  }
280
293
  return out;
@@ -402,22 +415,6 @@ export function hasContent(value: unknown): value is { content: string } {
402
415
  );
403
416
  }
404
417
 
405
- /** Type guard for Claude message objects whose `content` is an array of text blocks. */
406
- export function isTextBlockArray(
407
- value: unknown,
408
- ): value is Array<{ type: "text"; text: string }> {
409
- return (
410
- Array.isArray(value) &&
411
- value.every(
412
- (b) =>
413
- typeof b === "object" &&
414
- b !== null &&
415
- b.type === "text" &&
416
- typeof b.text === "string",
417
- )
418
- );
419
- }
420
-
421
418
  export function renderMessagesToText(messages: SavedMessage[]): string {
422
419
  return messages
423
420
  .map((m) => {
@@ -444,11 +441,25 @@ export function renderMessagesToText(messages: SavedMessage[]): string {
444
441
  if (msg && typeof msg === "object" && "content" in msg) {
445
442
  const { content } = msg as { content: unknown };
446
443
  if (typeof content === "string") return content;
447
- if (isTextBlockArray(content)) {
448
- return content.map((b) => b.text).join("\n");
444
+ // Claude messages often have mixed content arrays (text +
445
+ // tool_use + thinking blocks). Filter for text blocks instead
446
+ // of requiring ALL blocks to be text — the old isTextBlockArray
447
+ // check caused a JSON.stringify fallback that embedded raw
448
+ // message objects into downstream prompts.
449
+ if (Array.isArray(content)) {
450
+ const textParts = content
451
+ .filter(
452
+ (b): b is { type: "text"; text: string } =>
453
+ typeof b === "object" &&
454
+ b !== null &&
455
+ b.type === "text" &&
456
+ typeof b.text === "string",
457
+ )
458
+ .map((b) => b.text);
459
+ if (textParts.length > 0) return textParts.join("\n");
449
460
  }
450
461
  }
451
- return JSON.stringify(msg);
462
+ return "";
452
463
  }
453
464
  }
454
465
  })
@@ -461,6 +472,61 @@ function resolveRef(ref: SessionRef): string {
461
472
  return typeof ref === "string" ? ref : ref.name;
462
473
  }
463
474
 
475
+ // ============================================================================
476
+ // Shared transcript / message readers
477
+ // ============================================================================
478
+
479
+ /**
480
+ * Create a `transcript(ref)` function bound to a completed-session registry.
481
+ * Used by both the top-level WorkflowContext and per-session SessionContext
482
+ * so the implementation is defined once.
483
+ */
484
+ function createTranscriptReader(
485
+ completedRegistry: Map<string, SessionResult>,
486
+ ): (ref: SessionRef) => Promise<Transcript> {
487
+ return async (ref) => {
488
+ const refName = resolveRef(ref);
489
+ const prev = completedRegistry.get(refName);
490
+ if (!prev) {
491
+ const available =
492
+ [...completedRegistry.keys()].join(", ") || "(none)";
493
+ throw new Error(
494
+ `No transcript for "${refName}". Available: ${available}`,
495
+ );
496
+ }
497
+ const filePath = join(prev.sessionDir, "inbox.md");
498
+ const content = await Bun.file(filePath).text();
499
+ return { path: filePath, content };
500
+ };
501
+ }
502
+
503
+ /**
504
+ * Create a `getMessages(ref)` function bound to a completed-session registry.
505
+ * Used by both the top-level WorkflowContext and per-session SessionContext.
506
+ */
507
+ function createMessagesReader(
508
+ completedRegistry: Map<string, SessionResult>,
509
+ ): (ref: SessionRef) => Promise<SavedMessage[]> {
510
+ return async (ref) => {
511
+ const refName = resolveRef(ref);
512
+ const prev = completedRegistry.get(refName);
513
+ if (!prev) {
514
+ const available =
515
+ [...completedRegistry.keys()].join(", ") || "(none)";
516
+ throw new Error(
517
+ `No messages for "${refName}". Available: ${available}`,
518
+ );
519
+ }
520
+ const filePath = join(prev.sessionDir, "messages.json");
521
+ const raw = await Bun.file(filePath).text();
522
+ const parsed: unknown = JSON.parse(raw);
523
+ if (!Array.isArray(parsed)) {
524
+ throw new Error(`Invalid messages file for "${refName}": expected array`);
525
+ }
526
+ return parsed.filter(isValidSavedMessage);
527
+ };
528
+ }
529
+
464
530
  // ============================================================================
465
531
  // Session runner — implements ctx.stage() lifecycle
466
532
  // ============================================================================
@@ -489,15 +555,23 @@ interface SharedRunnerState {
489
555
  /**
490
556
  * Create the provider-specific client and session for a stage.
491
557
  * Called by the session runner after server readiness is confirmed.
558
+ *
559
+ * Generic over `A` so callers receive typed `ProviderClient<A>` /
560
+ * `ProviderSession<A>` without unsafe casts. The internal `switch`
561
+ * branches know the concrete types being constructed, so the `as`
562
+ * assertions here are producer-side (correct by construction) rather
563
+ * than consumer-side (trusting the caller to guess right).
492
564
  */
493
- async function initProviderClientAndSession(
494
- agent: AgentType,
565
+ async function initProviderClientAndSession<A extends AgentType>(
566
+ agent: A,
495
567
  serverUrl: string,
496
568
  paneId: string,
497
569
  sessionId: string,
498
- clientOpts: StageClientOptions<AgentType>,
499
- sessionOpts: StageSessionOptions<AgentType>,
500
- ): Promise<{ client: unknown; session: unknown }> {
570
+ clientOpts: StageClientOptions<A>,
571
+ sessionOpts: StageSessionOptions<A>,
572
+ ): Promise<{ client: ProviderClient<A>; session: ProviderSession<A> }> {
573
+ type Result = { client: ProviderClient<A>; session: ProviderSession<A> };
574
+
501
575
  switch (agent) {
502
576
  case "copilot": {
503
577
  const { CopilotClient, approveAll } = await import("@github/copilot-sdk");
@@ -510,7 +584,7 @@ async function initProviderClientAndSession(
510
584
  ...copilotSessionOpts,
511
585
  });
512
586
  await client.setForegroundSessionId(session.sessionId);
513
- return { client, session };
587
+ return { client, session } as Result;
514
588
  }
515
589
  case "opencode": {
516
590
  const { createOpencodeClient } = await import("@opencode-ai/sdk/v2");
@@ -519,7 +593,7 @@ async function initProviderClientAndSession(
519
593
  const client = createOpencodeClient({ ...ocClientOpts, baseUrl: serverUrl });
520
594
  const sessionResult = await client.session.create(ocSessionOpts);
521
595
  await client.tui.selectSession({ sessionID: sessionResult.data!.id });
522
- return { client, session: sessionResult.data! };
596
+ return { client, session: sessionResult.data! } as Result;
523
597
  }
524
598
  case "claude": {
525
599
  const claudeClientOpts = clientOpts as StageClientOptions<"claude">;
@@ -527,35 +601,41 @@ async function initProviderClientAndSession(
527
601
  const client = new ClaudeClientWrapper(paneId, claudeClientOpts);
528
602
  await client.start();
529
603
  const session = new ClaudeSessionWrapper(paneId, sessionId, claudeSessionOpts);
530
- return { client, session };
604
+ return { client, session } as Result;
531
605
  }
606
+ default:
607
+ return assertNever(agent);
532
608
  }
533
609
  }
534
610
 
535
611
  /**
536
612
  * Clean up provider-specific resources after a stage callback completes.
537
613
  * Errors are silently caught — cleanup must not mask callback errors.
614
+ *
615
+ * The `switch (agent)` already narrows the type, so we call
616
+ * disconnect/stop directly without redundant `instanceof` checks or
617
+ * dynamic imports.
538
618
  */
539
- async function cleanupProvider(
540
- agent: AgentType,
541
- providerClient: unknown,
542
- providerSession: unknown,
619
+ async function cleanupProvider<A extends AgentType>(
620
+ agent: A,
621
+ providerClient: ProviderClient<A>,
622
+ providerSession: ProviderSession<A>,
543
623
  paneId: string,
544
624
  ): Promise<void> {
545
625
  switch (agent) {
546
626
  case "copilot": {
547
- const { CopilotSession: CopilotSessionClass } = await import("@github/copilot-sdk");
627
+ const session = providerSession as ProviderSession<"copilot">;
628
+ const client = providerClient as ProviderClient<"copilot">;
548
629
  try {
549
- if (providerSession instanceof CopilotSessionClass) {
550
- await providerSession.disconnect();
551
- }
552
- } catch {}
630
+ await session.disconnect();
631
+ } catch (e) {
632
+ console.warn(`[cleanup] copilot session disconnect failed: ${errorMessage(e)}`);
633
+ }
553
634
  try {
554
- const { CopilotClient: CopilotClientClass } = await import("@github/copilot-sdk");
555
- if (providerClient instanceof CopilotClientClass) {
556
- await providerClient.stop();
557
- }
558
- } catch {}
635
+ await client.stop();
636
+ } catch (e) {
637
+ console.warn(`[cleanup] copilot client stop failed: ${errorMessage(e)}`);
638
+ }
559
639
  break;
560
640
  }
561
641
  case "opencode":
@@ -564,6 +644,8 @@ async function cleanupProvider(
564
644
  case "claude":
565
645
  clearClaudeSession(paneId);
566
646
  break;
647
+ default:
648
+ assertNever(agent);
567
649
  }
568
650
  }
569
651
 
@@ -752,37 +834,8 @@ function createSessionRunner(
752
834
  }) as SaveTranscript;
753
835
 
754
836
  // ── Transcript/messages access (reads only from completedRegistry) ──
755
- const transcriptFn = async (ref: SessionRef): Promise<Transcript> => {
756
- const refName = resolveRef(ref);
757
- const prev = shared.completedRegistry.get(refName);
758
- if (!prev) {
759
- const available =
760
- [...shared.completedRegistry.keys()].join(", ") || "(none)";
761
- throw new Error(
762
- `No transcript for "${refName}". Available: ${available}`,
763
- );
764
- }
765
- const filePath = join(prev.sessionDir, "inbox.md");
766
- const content = await readFile(filePath, "utf-8");
767
- return { path: filePath, content };
768
- };
769
-
770
- const getMessagesFn = async (
771
- ref: SessionRef,
772
- ): Promise<SavedMessage[]> => {
773
- const refName = resolveRef(ref);
774
- const prev = shared.completedRegistry.get(refName);
775
- if (!prev) {
776
- const available =
777
- [...shared.completedRegistry.keys()].join(", ") || "(none)";
778
- throw new Error(
779
- `No messages for "${refName}". Available: ${available}`,
780
- );
781
- }
782
- const filePath = join(prev.sessionDir, "messages.json");
783
- const raw = await readFile(filePath, "utf-8");
784
- return JSON.parse(raw) as SavedMessage[];
785
- };
837
+ const transcriptFn = createTranscriptReader(shared.completedRegistry);
838
+ const getMessagesFn = createMessagesReader(shared.completedRegistry);
786
839
 
787
840
  // ── 12. Auto-create provider client and session ──
788
841
  const { client: providerClient, session: providerSession } =
@@ -801,8 +854,8 @@ function createSessionRunner(
801
854
  // A single uniform access pattern means workflow code never has
802
855
  // to branch on "is this workflow structured or free-form".
803
856
  const ctx: SessionContext = {
804
- client: providerClient as SessionContext["client"],
805
- session: providerSession as SessionContext["session"],
857
+ client: providerClient,
858
+ session: providerSession,
806
859
  inputs: shared.inputs,
807
860
  agent: shared.agent,
808
861
  sessionDir,
@@ -815,7 +868,7 @@ function createSessionRunner(
815
868
  };
816
869
 
817
870
  // ── Write session metadata ──
818
- await writeFile(
871
+ await Bun.write(
819
872
  join(sessionDir, "metadata.json"),
820
873
  JSON.stringify(
821
874
  {
@@ -839,8 +892,8 @@ function createSessionRunner(
839
892
  if (pendingSaves.length > 0) await Promise.all(pendingSaves);
840
893
  } catch (error) {
841
894
  const message =
842
- error instanceof Error ? error.message : String(error);
843
- await writeFile(join(sessionDir, "error.txt"), message).catch(
895
+ errorMessage(error);
896
+ await Bun.write(join(sessionDir, "error.txt"), message).catch(
844
897
  () => {},
845
898
  );
846
899
  shared.panel.sessionError(name, message);
@@ -861,7 +914,7 @@ function createSessionRunner(
861
914
  graphTracker.onSettle(name);
862
915
  return { name, id: sessionId, result: callbackResult! };
863
916
  } catch (error) {
864
- const message = error instanceof Error ? error.message : String(error);
917
+ const message = errorMessage(error);
865
918
  if (panelSessionAdded) {
866
919
  shared.panel.sessionError(name, message);
867
920
  }
@@ -900,7 +953,11 @@ export async function runOrchestrator(): Promise<void> {
900
953
 
901
954
  const workflowRunId = process.env.ATOMIC_WF_ID!;
902
955
  const tmuxSessionName = process.env.ATOMIC_WF_TMUX!;
903
- const agent = process.env.ATOMIC_WF_AGENT! as AgentType;
956
+ const rawAgent = process.env.ATOMIC_WF_AGENT!;
957
+ if (!isValidAgent(rawAgent)) {
958
+ throw new Error(`Invalid ATOMIC_WF_AGENT: "${rawAgent}". Expected one of: copilot, opencode, claude`);
959
+ }
960
+ const agent: AgentType = rawAgent;
904
961
  // ATOMIC_WF_INPUTS carries the full input payload. Free-form
905
962
  // workflows store their single positional prompt under the `prompt`
906
963
  // key so workflow authors always read it via `ctx.inputs.prompt`.
@@ -971,7 +1028,7 @@ export async function runOrchestrator(): Promise<void> {
971
1028
  }
972
1029
  const definition = loaded.value.definition;
973
1030
 
974
- await writeFile(
1031
+ await Bun.write(
975
1032
  join(sessionsBaseDir, "metadata.json"),
976
1033
  JSON.stringify(
977
1034
  {
@@ -996,34 +1053,8 @@ export async function runOrchestrator(): Promise<void> {
996
1053
  inputs,
997
1054
  agent,
998
1055
  stage: sessionRunner as WorkflowContext["stage"],
999
- transcript: async (ref: SessionRef): Promise<Transcript> => {
1000
- const refName = resolveRef(ref);
1001
- const prev = shared.completedRegistry.get(refName);
1002
- if (!prev) {
1003
- const available =
1004
- [...shared.completedRegistry.keys()].join(", ") || "(none)";
1005
- throw new Error(
1006
- `No transcript for "${refName}". Available: ${available}`,
1007
- );
1008
- }
1009
- const filePath = join(prev.sessionDir, "inbox.md");
1010
- const content = await readFile(filePath, "utf-8");
1011
- return { path: filePath, content };
1012
- },
1013
- getMessages: async (ref: SessionRef): Promise<SavedMessage[]> => {
1014
- const refName = resolveRef(ref);
1015
- const prev = shared.completedRegistry.get(refName);
1016
- if (!prev) {
1017
- const available =
1018
- [...shared.completedRegistry.keys()].join(", ") || "(none)";
1019
- throw new Error(
1020
- `No messages for "${refName}". Available: ${available}`,
1021
- );
1022
- }
1023
- const filePath = join(prev.sessionDir, "messages.json");
1024
- const raw = await readFile(filePath, "utf-8");
1025
- return JSON.parse(raw) as SavedMessage[];
1026
- },
1056
+ transcript: createTranscriptReader(shared.completedRegistry),
1057
+ getMessages: createMessagesReader(shared.completedRegistry),
1027
1058
  };
1028
1059
 
1029
1060
  // Run the workflow, racing against user abort (q / Ctrl+C)
@@ -1046,7 +1077,7 @@ export async function runOrchestrator(): Promise<void> {
1046
1077
  if (error instanceof WorkflowAbortError) {
1047
1078
  shutdown(0);
1048
1079
  } else {
1049
- const message = error instanceof Error ? error.message : String(error);
1080
+ const message = errorMessage(error);
1050
1081
  try {
1051
1082
  panel.showFatalError(message);
1052
1083
  await panel.waitForExit();