@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
@@ -12,6 +12,7 @@
12
12
 
13
13
  import type { WorkflowDefinition, AgentType } from "../types.ts";
14
14
  import type { DiscoveredWorkflow } from "./discovery.ts";
15
+ import { errorMessage, WorkflowNotCompiledError, InvalidWorkflowError } from "../errors.ts";
15
16
  import { validateCopilotWorkflow } from "../providers/copilot.ts";
16
17
  import { validateOpenCodeWorkflow } from "../providers/opencode.ts";
17
18
  import { validateClaudeWorkflow } from "../providers/claude.ts";
@@ -44,11 +45,8 @@ export namespace WorkflowLoader {
44
45
  /** Output of the resolve stage. */
45
46
  export type Resolved = Plan;
46
47
 
47
- /** A source validation warning (agent-specific). */
48
- export interface ValidationWarning {
49
- rule: string;
50
- message: string;
51
- }
48
+ /** Source validation warning — alias of the canonical type from types.ts. */
49
+ export type ValidationWarning = import("../types.ts").ValidationWarning;
52
50
 
53
51
  /** Output of the validate stage. */
54
52
  export type Validated = Resolved & {
@@ -97,7 +95,7 @@ export namespace WorkflowLoader {
97
95
  ok: false,
98
96
  stage: "resolve",
99
97
  error,
100
- message: error instanceof Error ? error.message : String(error),
98
+ message: errorMessage(error),
101
99
  };
102
100
  }
103
101
  }
@@ -115,8 +113,10 @@ export namespace WorkflowLoader {
115
113
  return validateOpenCodeWorkflow(source);
116
114
  case "claude":
117
115
  return validateClaudeWorkflow(source);
118
- default:
119
- return [];
116
+ default: {
117
+ const _exhaustive: never = agent;
118
+ return _exhaustive;
119
+ }
120
120
  }
121
121
  }
122
122
 
@@ -140,7 +140,7 @@ export namespace WorkflowLoader {
140
140
  ok: false,
141
141
  stage: "validate",
142
142
  error,
143
- message: error instanceof Error ? error.message : String(error),
143
+ message: errorMessage(error),
144
144
  };
145
145
  }
146
146
  }
@@ -162,26 +162,21 @@ export namespace WorkflowLoader {
162
162
 
163
163
  if (!definition || definition.__brand !== "WorkflowDefinition") {
164
164
  if (definition && definition.__brand === "WorkflowBuilder") {
165
+ const err = new WorkflowNotCompiledError(validated.path);
165
166
  return {
166
167
  ok: false,
167
168
  stage: "load",
168
- error: new Error("Workflow not compiled"),
169
- message:
170
- `Workflow at ${validated.path} was defined but not compiled.\n` +
171
- ` Add .compile() at the end of your defineWorkflow() chain:\n\n` +
172
- ` export default defineWorkflow({ ... })\n` +
173
- ` .run(async (ctx) => { ... })\n` +
174
- ` .compile();`,
169
+ error: err,
170
+ message: err.message,
175
171
  };
176
172
  }
177
173
 
174
+ const err = new InvalidWorkflowError(validated.path);
178
175
  return {
179
176
  ok: false,
180
177
  stage: "load",
181
- error: new Error("Invalid workflow export"),
182
- message:
183
- `${validated.path} does not export a valid WorkflowDefinition.\n` +
184
- ` Make sure it exports defineWorkflow(...).run(...).compile() as the default export.`,
178
+ error: err,
179
+ message: err.message,
185
180
  };
186
181
  }
187
182
 
@@ -194,7 +189,7 @@ export namespace WorkflowLoader {
194
189
  ok: false,
195
190
  stage: "load",
196
191
  error,
197
- message: error instanceof Error ? error.message : String(error),
192
+ message: errorMessage(error),
198
193
  };
199
194
  }
200
195
  }
@@ -6,7 +6,9 @@
6
6
  * sending keystrokes, and pane state detection.
7
7
  */
8
8
 
9
- import { join } from "path";
9
+ import { join } from "node:path";
10
+ import { writeFileSync, unlinkSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
10
12
  import type { Subprocess } from "bun";
11
13
 
12
14
  // ---------------------------------------------------------------------------
@@ -19,6 +21,11 @@ export const SOCKET_NAME = "atomic";
19
21
  /** Path to the bundled tmux config (shared by tmux and psmux). */
20
22
  const CONFIG_PATH = join(import.meta.dir, "tmux.conf");
21
23
 
24
+ /** Discriminated result from a tmux command execution. */
25
+ export type TmuxResult =
26
+ | { ok: true; stdout: string }
27
+ | { ok: false; stderr: string };
28
+
22
29
  // ---------------------------------------------------------------------------
23
30
  // Core tmux primitives
24
31
  // ---------------------------------------------------------------------------
@@ -81,7 +88,7 @@ export function isInsideTmux(): boolean {
81
88
  * Prefers this over the throwing `tmux()` for cases where callers
82
89
  * need to handle failure gracefully.
83
90
  */
84
- export function tmuxRun(args: string[]): { ok: true; stdout: string } | { ok: false; stderr: string } {
91
+ export function tmuxRun(args: string[]): TmuxResult {
85
92
  const binary = getMuxBinary();
86
93
  if (!binary) {
87
94
  return { ok: false, stderr: "No terminal multiplexer (tmux/psmux) found on PATH" };
@@ -93,10 +100,9 @@ export function tmuxRun(args: string[]): { ok: true; stdout: string } | { ok: fa
93
100
  stderr: "pipe",
94
101
  });
95
102
  if (!result.success) {
96
- const stderr = new TextDecoder().decode(result.stderr).trim();
97
- return { ok: false, stderr };
103
+ return { ok: false, stderr: result.stderr.toString().trim() };
98
104
  }
99
- return { ok: true, stdout: new TextDecoder().decode(result.stdout).trim() };
105
+ return { ok: true, stdout: result.stdout.toString().trim() };
100
106
  }
101
107
 
102
108
  /**
@@ -223,14 +229,64 @@ export function createPane(sessionName: string, command: string): string {
223
229
  // Keystroke sending
224
230
  // ---------------------------------------------------------------------------
225
231
 
232
+ /**
233
+ * Maximum bytes per `send-keys -l` invocation.
234
+ *
235
+ * tmux passes the text as a single command-line argument to the child
236
+ * process. On Linux the per-argument limit (`MAX_ARG_STRLEN`) is 128 KB;
237
+ * on macOS the total `ARG_MAX` is ~1 MB but shared across all args.
238
+ * We stay well under both limits with 50 KB chunks.
239
+ */
240
+ const SEND_KEYS_CHUNK_SIZE = 50_000;
241
+
226
242
  /**
227
243
  * Send literal text to a tmux pane using `-l` flag (no special key interpretation).
228
244
  * Uses `--` to prevent text starting with `-` from being parsed as flags.
245
+ *
246
+ * Long texts are chunked to avoid OS `ARG_MAX` / `MAX_ARG_STRLEN` limits
247
+ * that cause `tmux send-keys` to fail with "command too long".
229
248
  */
230
249
  export function sendLiteralText(paneId: string, text: string): void {
231
250
  // Replace newlines with spaces to avoid premature submission
232
251
  const normalized = text.replace(/[\r\n]+/g, " ");
233
- tmuxExec(["send-keys", "-t", paneId, "-l", "--", normalized]);
252
+
253
+ if (normalized.length <= SEND_KEYS_CHUNK_SIZE) {
254
+ tmuxExec(["send-keys", "-t", paneId, "-l", "--", normalized]);
255
+ return;
256
+ }
257
+
258
+ for (let offset = 0; offset < normalized.length; offset += SEND_KEYS_CHUNK_SIZE) {
259
+ const chunk = normalized.slice(offset, offset + SEND_KEYS_CHUNK_SIZE);
260
+ tmuxExec(["send-keys", "-t", paneId, "-l", "--", chunk]);
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Send text to a tmux pane via the paste buffer.
266
+ *
267
+ * More reliable than `send-keys -l` for large text:
268
+ * - No OS ARG_MAX / MAX_ARG_STRLEN limits (text goes through a temp file)
269
+ * - Atomic delivery — the entire text is pasted at once
270
+ * - No chunking needed
271
+ *
272
+ * Newlines are normalized to spaces to prevent premature submission,
273
+ * matching `sendLiteralText`'s behavior.
274
+ */
275
+ export function sendViaPasteBuffer(paneId: string, text: string): void {
276
+ const normalized = text.replace(/[\r\n]+/g, " ");
277
+ const tmp = join(tmpdir(), `atomic-paste-${process.pid}-${Date.now()}.txt`);
278
+
279
+ writeFileSync(tmp, normalized, "utf-8");
280
+ try {
281
+ tmuxExec(["load-buffer", tmp]);
282
+ tmuxExec(["paste-buffer", "-t", paneId, "-d"]);
283
+ } finally {
284
+ try {
285
+ unlinkSync(tmp);
286
+ } catch {
287
+ // Temp file cleanup is best-effort
288
+ }
289
+ }
234
290
  }
235
291
 
236
292
  /**
@@ -247,17 +303,17 @@ export function sendSpecialKey(paneId: string, key: string): void {
247
303
  * @param presses - Number of C-m presses (default: 1)
248
304
  * @param delayMs - Delay between presses in ms (default: 100)
249
305
  */
250
- export function sendKeysAndSubmit(
306
+ export async function sendKeysAndSubmit(
251
307
  paneId: string,
252
308
  text: string,
253
309
  presses = 1,
254
310
  delayMs = 100
255
- ): void {
311
+ ): Promise<void> {
256
312
  sendLiteralText(paneId, text);
257
313
 
258
314
  for (let i = 0; i < presses; i++) {
259
315
  if (i > 0 && delayMs > 0) {
260
- Bun.sleepSync(delayMs);
316
+ await Bun.sleep(delayMs);
261
317
  }
262
318
  sendSpecialKey(paneId, "C-m");
263
319
  }
@@ -281,6 +337,16 @@ export function capturePane(paneId: string, start?: number): string {
281
337
  return tmux(args);
282
338
  }
283
339
 
340
+ /** Internal capture helper — returns empty string on failure. */
341
+ function capturePaneRaw(paneId: string, scrollbackLines?: number): string {
342
+ const args = ["capture-pane", "-t", paneId, "-p"];
343
+ if (scrollbackLines !== undefined) {
344
+ args.push("-S", `-${scrollbackLines}`);
345
+ }
346
+ const result = tmuxRun(args);
347
+ return result.ok ? result.stdout : "";
348
+ }
349
+
284
350
  /**
285
351
  * Capture only the visible portion of a pane (no scrollback).
286
352
  * Preferred for state detection (ready/busy) to avoid stale prompt lines
@@ -288,9 +354,7 @@ export function capturePane(paneId: string, start?: number): string {
288
354
  * Returns empty string on failure instead of throwing.
289
355
  */
290
356
  export function capturePaneVisible(paneId: string): string {
291
- const result = tmuxRun(["capture-pane", "-t", paneId, "-p"]);
292
- if (!result.ok) return "";
293
- return result.stdout;
357
+ return capturePaneRaw(paneId);
294
358
  }
295
359
 
296
360
  /**
@@ -299,9 +363,7 @@ export function capturePaneVisible(paneId: string): string {
299
363
  * Returns empty string on failure instead of throwing.
300
364
  */
301
365
  export function capturePaneScrollback(paneId: string, lines = 200): string {
302
- const result = tmuxRun(["capture-pane", "-t", paneId, "-p", "-S", `-${lines}`]);
303
- if (!result.ok) return "";
304
- return result.stdout;
366
+ return capturePaneRaw(paneId, lines);
305
367
  }
306
368
 
307
369
  // ---------------------------------------------------------------------------
@@ -336,22 +398,28 @@ export function sessionExists(sessionName: string): boolean {
336
398
  return result.ok;
337
399
  }
338
400
 
339
- /**
340
- * Attach to an existing tmux session (takes over the current terminal).
341
- */
342
- export function attachSession(sessionName: string): void {
401
+ /** Build the full argument list for an attach-session command. */
402
+ function buildAttachArgs(sessionName: string): string[] {
343
403
  const binary = getMuxBinary();
344
404
  if (!binary) {
345
405
  throw new Error("No terminal multiplexer (tmux/psmux) found on PATH");
346
406
  }
407
+ return [binary, "-f", CONFIG_PATH, "-L", SOCKET_NAME, "attach-session", "-t", sessionName];
408
+ }
409
+
410
+ /**
411
+ * Attach to an existing tmux session (takes over the current terminal).
412
+ */
413
+ export function attachSession(sessionName: string): void {
414
+ const cmd = buildAttachArgs(sessionName);
347
415
  const proc = Bun.spawnSync({
348
- cmd: [binary, "-f", CONFIG_PATH, "-L", SOCKET_NAME, "attach-session", "-t", sessionName],
416
+ cmd,
349
417
  stdin: "inherit",
350
418
  stdout: "inherit",
351
419
  stderr: "pipe",
352
420
  });
353
421
  if (!proc.success) {
354
- const stderr = new TextDecoder().decode(proc.stderr).trim();
422
+ const stderr = proc.stderr.toString().trim();
355
423
  throw new Error(`Failed to attach to session: ${sessionName}${stderr ? ` (${stderr})` : ""}`);
356
424
  }
357
425
  }
@@ -362,14 +430,9 @@ export function attachSession(sessionName: string): void {
362
430
  * Used by all async attach call sites (executor, chat).
363
431
  */
364
432
  export function spawnMuxAttach(sessionName: string): Subprocess {
365
- const binary = getMuxBinary();
366
- if (!binary) {
367
- throw new Error("No terminal multiplexer (tmux/psmux) found on PATH");
368
- }
369
- return Bun.spawn(
370
- [binary, "-f", CONFIG_PATH, "-L", SOCKET_NAME, "attach-session", "-t", sessionName],
371
- { stdio: ["inherit", "inherit", "inherit"] },
372
- );
433
+ return Bun.spawn(buildAttachArgs(sessionName), {
434
+ stdio: ["inherit", "inherit", "inherit"],
435
+ });
373
436
  }
374
437
 
375
438
  /**
@@ -485,10 +548,10 @@ export function paneHasActiveTask(captured: string): boolean {
485
548
  .map((line) => line.trim())
486
549
  .slice(-40);
487
550
 
488
- if (tail.some((l) => /\b\d+\s+background terminal running\b/i.test(l))) return true;
489
- if (tail.some((l) => /esc to interrupt/i.test(l))) return true;
490
- if (tail.some((l) => /\bbackground terminal running\b/i.test(l))) return true;
491
551
  return tail.some((l) =>
552
+ /\b\d+\s+background terminal running\b/i.test(l) ||
553
+ /esc to interrupt/i.test(l) ||
554
+ /\bbackground terminal running\b/i.test(l) ||
492
555
  /^[·✻]\s+[A-Za-z][A-Za-z0-9''-]*(?:\s+[A-Za-z][A-Za-z0-9''-]*){0,3}(?:…|\.{3})$/u.test(l),
493
556
  );
494
557
  }
package/src/sdk/types.ts CHANGED
@@ -95,6 +95,51 @@ export type {
95
95
  ClaudeQueryDefaults,
96
96
  };
97
97
 
98
+ // ─── Validation ─────────────────────────────────────────────────────────────
99
+
100
+ /** A source validation warning emitted by provider-specific workflow validators. */
101
+ export interface ValidationWarning {
102
+ rule: string;
103
+ message: string;
104
+ }
105
+
106
+ /** A declarative validation rule: pattern to match + warning to emit. */
107
+ export interface ValidationRule {
108
+ pattern: RegExp;
109
+ rule: string;
110
+ message: string;
111
+ }
112
+
113
+ /**
114
+ * Run a set of regex-based validation rules against workflow source code.
115
+ * Returns a warning for each matching pattern.
116
+ */
117
+ export function validateWorkflowSource(
118
+ source: string,
119
+ rules: readonly ValidationRule[],
120
+ ): ValidationWarning[] {
121
+ // Strip single-line comments to avoid false positives from patterns
122
+ // that appear only in comments (e.g., a comment mentioning claudeQuery).
123
+ const stripped = source.replace(/\/\/.*$/gm, "");
124
+ const warnings: ValidationWarning[] = [];
125
+ for (const { pattern, rule, message } of rules) {
126
+ if (pattern.test(stripped)) {
127
+ warnings.push({ rule, message });
128
+ }
129
+ }
130
+ return warnings;
131
+ }
132
+
133
+ /**
134
+ * Create a provider-specific workflow validator from a set of rules.
135
+ * Eliminates boilerplate — each provider file only needs to declare its rules.
136
+ */
137
+ export function createProviderValidator(
138
+ rules: readonly ValidationRule[],
139
+ ): (source: string) => ValidationWarning[] {
140
+ return (source) => validateWorkflowSource(source, rules);
141
+ }
142
+
98
143
  // ─── Workflow input schemas ─────────────────────────────────────────────────
99
144
 
100
145
  /**
@@ -79,6 +79,23 @@ import {
79
79
  slugifyPrompt,
80
80
  } from "../helpers/prompts.ts";
81
81
 
82
+ // ── Timeouts ────────────────────────────────────────────────────────────────
83
+ // Every s.session.query() call passes one of these explicitly — never relying
84
+ // on the 300-second default. Explorer and aggregator stages dispatch sub-agents
85
+ // and can easily run 30+ minutes; a premature timeout causes the stage to
86
+ // complete early, which makes Promise.all resolve and the next stage to launch
87
+ // before parallel stages finish.
88
+ const SCOUT_TIMEOUT_MS = 15 * 60 * 1000; // 15 min — short orientation call
89
+ const HISTORY_TIMEOUT_MS = 20 * 60 * 1000; // 20 min — reads research/ docs
90
+ const EXPLORER_TIMEOUT_MS = 45 * 60 * 1000; // 45 min — multi-step sub-agent dispatch
91
+ const AGGREGATOR_TIMEOUT_MS = 45 * 60 * 1000; // 45 min — reads N explorer reports
92
+
93
+ // Between sub-agent dispatches Claude's TUI briefly shows the prompt indicator
94
+ // without an active-task spinner. Requiring 3 consecutive idle detections
95
+ // prevents the query from returning during these transient gaps.
96
+ const EXPLORER_IDLE_CONFIRM = 3;
97
+ const AGGREGATOR_IDLE_CONFIRM = 3;
98
+
82
99
  export default defineWorkflow<"claude">({
83
100
  name: "deep-research-codebase",
84
101
  description:
@@ -149,6 +166,7 @@ export default defineWorkflow<"claude">({
149
166
  explorerCount: actualCount,
150
167
  partitionPreview: partitions,
151
168
  }),
169
+ { timeoutMs: SCOUT_TIMEOUT_MS },
152
170
  );
153
171
  s.save(s.sessionId);
154
172
 
@@ -177,6 +195,7 @@ export default defineWorkflow<"claude">({
177
195
  // synthesis as prose (no file write — consumed via transcript).
178
196
  await s.session.query(
179
197
  buildHistoryPrompt({ question: prompt, root }),
198
+ { timeoutMs: HISTORY_TIMEOUT_MS },
180
199
  );
181
200
  s.save(s.sessionId);
182
201
  },
@@ -236,6 +255,10 @@ export default defineWorkflow<"claude">({
236
255
  scratchPath,
237
256
  root,
238
257
  }),
258
+ {
259
+ timeoutMs: EXPLORER_TIMEOUT_MS,
260
+ idleConfirmCount: EXPLORER_IDLE_CONFIRM,
261
+ },
239
262
  );
240
263
  s.save(s.sessionId);
241
264
 
@@ -286,6 +309,10 @@ export default defineWorkflow<"claude">({
286
309
  scoutOverview,
287
310
  historyOverview,
288
311
  }),
312
+ {
313
+ timeoutMs: AGGREGATOR_TIMEOUT_MS,
314
+ idleConfirmCount: AGGREGATOR_IDLE_CONFIRM,
315
+ },
289
316
  );
290
317
  s.save(s.sessionId);
291
318
  },
@@ -137,9 +137,10 @@ export function buildExplorerPrompt(opts: {
137
137
  .map((u) => `\`${path.join(opts.root, u.path)}\``)
138
138
  .join(", ");
139
139
 
140
- const orientation = opts.scoutOverview.trim().length > 0
141
- ? opts.scoutOverview.trim()
142
- : "(scout overview unavailable — proceed without)";
140
+ const orientation =
141
+ opts.scoutOverview.trim().length > 0
142
+ ? opts.scoutOverview.trim()
143
+ : "(scout overview unavailable — proceed without)";
143
144
 
144
145
  return [
145
146
  `<RESEARCH_QUESTION>`,
@@ -418,13 +419,15 @@ export function buildExplorerPromptGeneric(opts: {
418
419
  .map((u) => `\`${path.join(opts.root, u.path)}\``)
419
420
  .join(", ");
420
421
 
421
- const orientation = opts.scoutOverview.trim().length > 0
422
- ? opts.scoutOverview.trim()
423
- : "(scout overview unavailable — proceed without)";
422
+ const orientation =
423
+ opts.scoutOverview.trim().length > 0
424
+ ? opts.scoutOverview.trim()
425
+ : "(scout overview unavailable — proceed without)";
424
426
 
425
- const history = opts.historyOverview.trim().length > 0
426
- ? opts.historyOverview.trim()
427
- : "(no historical research surfaced)";
427
+ const history =
428
+ opts.historyOverview.trim().length > 0
429
+ ? opts.historyOverview.trim()
430
+ : "(no historical research surfaced)";
428
431
 
429
432
  return [
430
433
  `<RESEARCH_QUESTION>`,
@@ -670,7 +673,11 @@ export function buildAggregatorPrompt(opts: {
670
673
  totalLoc: number;
671
674
  totalFiles: number;
672
675
  explorerCount: number;
673
- explorerFiles: { index: number; scratchPath: string; partition: PartitionUnit[] }[];
676
+ explorerFiles: {
677
+ index: number;
678
+ scratchPath: string;
679
+ partition: PartitionUnit[];
680
+ }[];
674
681
  finalPath: string;
675
682
  scoutOverview: string;
676
683
  historyOverview: string;
@@ -682,13 +689,15 @@ export function buildAggregatorPrompt(opts: {
682
689
  })
683
690
  .join("\n");
684
691
 
685
- const orientation = opts.scoutOverview.trim().length > 0
686
- ? opts.scoutOverview.trim()
687
- : "(scout overview unavailable)";
692
+ const orientation =
693
+ opts.scoutOverview.trim().length > 0
694
+ ? opts.scoutOverview.trim()
695
+ : "(scout overview unavailable)";
688
696
 
689
- const history = opts.historyOverview.trim().length > 0
690
- ? opts.historyOverview.trim()
691
- : "(no historical research surfaced)";
697
+ const history =
698
+ opts.historyOverview.trim().length > 0
699
+ ? opts.historyOverview.trim()
700
+ : "(no historical research surfaced)";
692
701
 
693
702
  return [
694
703
  `<RESEARCH_QUESTION>`,
@@ -13,7 +13,7 @@
13
13
  * Everything here is pure TypeScript + child_process — no LLM calls.
14
14
  */
15
15
 
16
- import { spawnSync } from "node:child_process";
16
+ // Use Bun.spawnSync instead of node:child_process for consistency with the rest of the codebase.
17
17
 
18
18
  /** Source-file extensions we treat as "code" for LOC accounting. */
19
19
  const CODE_EXTENSIONS = new Set<string>([
@@ -72,12 +72,13 @@ export type CodebaseScout = {
72
72
 
73
73
  /** Resolve the project root. Prefers `git rev-parse --show-toplevel`. */
74
74
  export function getCodebaseRoot(): string {
75
- const r = spawnSync("git", ["rev-parse", "--show-toplevel"], {
76
- encoding: "utf8",
77
- stdio: ["ignore", "pipe", "ignore"],
75
+ const r = Bun.spawnSync({
76
+ cmd: ["git", "rev-parse", "--show-toplevel"],
77
+ stdout: "pipe",
78
+ stderr: "pipe",
78
79
  });
79
- if (r.status === 0 && r.stdout) {
80
- return r.stdout.trim();
80
+ if (r.success && r.stdout) {
81
+ return r.stdout.toString().trim();
81
82
  }
82
83
  return process.cwd();
83
84
  }
@@ -91,29 +92,29 @@ function isCodeFile(p: string): boolean {
91
92
 
92
93
  /** List all files in the repository. Prefers git ls-files (respects .gitignore). */
93
94
  function listAllFiles(root: string): string[] {
94
- const git = spawnSync("git", ["ls-files"], {
95
+ const git = Bun.spawnSync({
96
+ cmd: ["git", "ls-files"],
95
97
  cwd: root,
96
- encoding: "utf8",
97
- maxBuffer: 64 * 1024 * 1024,
98
- stdio: ["ignore", "pipe", "ignore"],
98
+ stdout: "pipe",
99
+ stderr: "pipe",
99
100
  });
100
- if (git.status === 0 && git.stdout) {
101
- return git.stdout.split("\n").filter((l) => l.length > 0);
101
+ if (git.success && git.stdout) {
102
+ return git.stdout.toString().split("\n").filter((l) => l.length > 0);
102
103
  }
103
104
 
104
105
  // Fallback: shell out to find with the standard ignore patterns.
105
- const args: string[] = [".", "-type", "f"];
106
+ const args: string[] = ["find", ".", "-type", "f"];
106
107
  for (const pattern of FIND_IGNORE_PATTERNS) {
107
108
  args.push("-not", "-path", `*/${pattern}/*`);
108
109
  }
109
- const find = spawnSync("find", args, {
110
+ const find = Bun.spawnSync({
111
+ cmd: args,
110
112
  cwd: root,
111
- encoding: "utf8",
112
- maxBuffer: 64 * 1024 * 1024,
113
- stdio: ["ignore", "pipe", "ignore"],
113
+ stdout: "pipe",
114
+ stderr: "pipe",
114
115
  });
115
- if (find.status === 0 && find.stdout) {
116
- return find.stdout
116
+ if (find.success && find.stdout) {
117
+ return find.stdout.toString()
117
118
  .split("\n")
118
119
  .map((p) => p.replace(/^\.\//, ""))
119
120
  .filter((p) => p.length > 0);
@@ -135,14 +136,14 @@ function countLines(root: string, files: string[]): Map<string, number> {
135
136
  const BATCH = 200;
136
137
  for (let i = 0; i < files.length; i += BATCH) {
137
138
  const batch = files.slice(i, i + BATCH);
138
- const r = spawnSync("wc", ["-l", "--", ...batch], {
139
+ const r = Bun.spawnSync({
140
+ cmd: ["wc", "-l", "--", ...batch],
139
141
  cwd: root,
140
- encoding: "utf8",
141
- maxBuffer: 32 * 1024 * 1024,
142
- stdio: ["ignore", "pipe", "ignore"],
142
+ stdout: "pipe",
143
+ stderr: "pipe",
143
144
  });
144
145
  if (!r.stdout) continue;
145
- for (const line of r.stdout.split("\n")) {
146
+ for (const line of r.stdout.toString().split("\n")) {
146
147
  const m = line.match(/^\s*(\d+)\s+(.+)$/);
147
148
  // Regex groups are typed `string | undefined` under strict mode even
148
149
  // when the whole match succeeded — guard explicitly.
@@ -26,6 +26,10 @@ import { safeGitStatusS } from "../helpers/git.ts";
26
26
  const MAX_LOOPS = 10;
27
27
  const CONSECUTIVE_CLEAN_THRESHOLD = 2;
28
28
 
29
+ // The orchestrator stage implements the actual code changes and can run for
30
+ // a very long time on large tasks. 24 hours prevents premature timeout.
31
+ const ORCHESTRATOR_TIMEOUT_MS = 24 * 60 * 60 * 1000; // 24 hours
32
+
29
33
  /** Wrap a prompt with a Claude Code @-mention so the named sub-agent runs it. */
30
34
  function asAgentCall(agentName: string, prompt: string): string {
31
35
  return `@"${agentName} (agent)" ${prompt}`;
@@ -78,6 +82,7 @@ export default defineWorkflow<"claude">({
78
82
  "orchestrator",
79
83
  buildOrchestratorPrompt(prompt),
80
84
  ),
85
+ { timeoutMs: ORCHESTRATOR_TIMEOUT_MS },
81
86
  );
82
87
  s.save(s.sessionId);
83
88
  },
@@ -10,6 +10,7 @@ export { defineWorkflow, WorkflowBuilder } from "../define-workflow.ts";
10
10
 
11
11
  export type {
12
12
  AgentType,
13
+ ValidationWarning,
13
14
  Transcript,
14
15
  SavedMessage,
15
16
  SaveTranscript,
@@ -44,15 +45,14 @@ export type { SessionMessage as ClaudeSessionMessage } from "@anthropic-ai/claud
44
45
 
45
46
  // Providers
46
47
  export { createClaudeSession, claudeQuery, clearClaudeSession, validateClaudeWorkflow } from "../providers/claude.ts";
47
- export type { ClaudeSessionOptions, ClaudeQueryOptions, ClaudeQueryResult, ClaudeValidationWarning } from "../providers/claude.ts";
48
+ export type { ClaudeSessionOptions, ClaudeQueryOptions, ClaudeQueryResult } from "../providers/claude.ts";
48
49
 
49
50
  export { validateCopilotWorkflow } from "../providers/copilot.ts";
50
- export type { CopilotValidationWarning } from "../providers/copilot.ts";
51
51
 
52
52
  export { validateOpenCodeWorkflow } from "../providers/opencode.ts";
53
- export type { OpenCodeValidationWarning } from "../providers/opencode.ts";
54
53
 
55
54
  // Runtime — tmux utilities
55
+ export type { TmuxResult } from "../runtime/tmux.ts";
56
56
  export {
57
57
  SOCKET_NAME,
58
58
  isTmuxInstalled,