@agent-compose/sdk 0.8.0 → 0.8.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.
package/src/index.ts CHANGED
@@ -113,7 +113,8 @@ export type {
113
113
  export { AgentComposeClient } from "./client.js";
114
114
  export type {
115
115
  RegisterResult, RegisterWorkflowInput, RuntimeSourceInput, TemplateSourceRef,
116
- InvokeWorkflowOptions, InvokeAndWaitOptions, InvokeResult,
116
+ InvokeWorkflowOptions, InvokeAndWaitOptions, InvokeResult, FundingChoice,
117
+ InlineWorkflowPayload, InvokeInlineOptions, InvokeInlineAndWaitOptions,
117
118
  ListSnapshotsOptions, TemplateRow, ListTemplatesOptions,
118
119
  CreateFactoryInput, UpdateFactoryInput,
119
120
  SecretOptions, SetSecretResult, SecretListEntry,
@@ -141,6 +142,8 @@ export type {
141
142
  // Session branch proposals (ADR-0053)
142
143
  SessionFileChange, SessionChangeSet,
143
144
  SessionMergeReportDetail, SessionMergeReport, SessionDiscardReport,
145
+ // User drive mounts (`agentc files mount` on a human's own machine)
146
+ DriveMountSession, CreateDriveMountSessionInput,
144
147
  // Channel-attached sessions (ADR-0057)
145
148
  ChannelSessionStatus, ChannelSessionRow, ChannelSessionsResponse, SessionChannelMessagePosted,
146
149
  FactoryFileSearchRow, FactoryFolderSearchRow, SearchFactoryFilesOptions, FactoryFileSearchResult, PublicFileLinkState,
@@ -206,6 +209,10 @@ export {
206
209
  BUNDLER_VERSION,
207
210
  WorkflowSourceValidationError,
208
211
  assertDefaultExportIsDefineWorkflow,
212
+ SDK_PACKAGE,
213
+ SDK_SPECIFIER_ALIASES,
214
+ resolveSdkAlias,
215
+ explainBundleFailure,
209
216
  } from "./utils/bundler.js";
210
217
  export type { BundledWorkflow, WorkflowManifest } from "./utils/bundler.js";
211
218
 
@@ -232,7 +239,7 @@ export type { GatewayModelId } from "ai";
232
239
  // sandbox and stream-parse its JSONL. No heavy npm deps (the CLI lives in the
233
240
  // sandbox image), so these are root-exported like claudeRuntime.
234
241
  export { createCodexRuntime, codexSpec } from "./runtimes/codex.js";
235
- export type { CodexRuntimeConfig } from "./runtimes/codex.js";
242
+ export type { CodexRuntimeConfig, CodexReasoningEffort } from "./runtimes/codex.js";
236
243
  export { default as codexRuntime } from "./runtimes/codex.js";
237
244
  // Reasoning-effort level a CLI runtime turn may carry (claude-code / codex).
238
245
  export type { CliReasoningEffort } from "./runtimes/_cli-agent.js";
@@ -255,7 +262,7 @@ export { default as droidRuntime } from "./runtimes/droid.js";
255
262
  // adapter) — the cloud-hostable counterpart of `claudeRuntime` (claude.ts,
256
263
  // which drives the Agent SDK in the calling process and so can never run a
257
264
  // server-driven cloud-session turn).
258
- export { createClaudeCodeRuntime, claudeCodeSpec, CLAUDE_CODE_ACP_ADAPTER, CLAUDE_CODE_THINKING_TOKENS } from "./runtimes/claude-code.js";
265
+ export { createClaudeCodeRuntime, claudeCodeSpec, CLAUDE_CODE_ACP_ADAPTER, CLAUDE_CODE_EFFORT_LEVELS } from "./runtimes/claude-code.js";
259
266
  export type { ClaudeCodeRuntimeConfig } from "./runtimes/claude-code.js";
260
267
  export { default as claudeCodeRuntime } from "./runtimes/claude-code.js";
261
268
 
@@ -386,6 +393,7 @@ export {
386
393
  TABLE_MAX_COLUMNS, TABLE_MAX_ROWS, TABLE_CELL_MAX_CHARS,
387
394
  CHART_MAX_SERIES, CHART_MAX_POINTS_PER_SERIES, CHART_LABEL_MAX_CHARS,
388
395
  ASK_PROMPT_MAX_CHARS, ASK_MAX_OPTIONS, ASK_OPTION_ID_MAX_CHARS, ASK_OPTION_LABEL_MAX_CHARS,
396
+ DRIVE_PATH_MAX_CHARS, isPlausibleDrivePath,
389
397
  serializeDisplayMarker, parseDisplayMarker, findDisplayMarker, clampPlanEntries,
390
398
  clampTableData, clampChartSeries, clampChartAxisLabel, clampAskOptions,
391
399
  detectAgentcInvocation, shellWords, createDisplayPromoter,
@@ -119,13 +119,15 @@ async function withHandshakeTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
119
119
  }
120
120
  }
121
121
 
122
- /** Reasoning-effort level a CLI turn may carry (T2 session effort). The
123
- * per-CLI mapping lives in each spec's `buildCommand` — Claude Code takes a
124
- * thinking-token budget via `MAX_THINKING_TOKENS`, codex takes
125
- * `-c model_reasoning_effort=<level>`. Specs without a real knob
126
- * (opencode/droid/cursor) never receive one: the server hides + rejects
127
- * effort for those runtimes. */
128
- export type CliReasoningEffort = "low" | "medium" | "high";
122
+ /** Reasoning-effort level a CLI turn may carry (T2 session effort) — the
123
+ * UNION of what the effort-capable CLIs accept. The per-CLI mapping lives in
124
+ * each spec's `buildCommand` — Claude Code takes its own `--effort` flag
125
+ * (all five levels), codex takes `-c model_reasoning_effort=<level>`
126
+ * (low|medium|high|xhigh — no "max"; the spec clamps it). Specs without a
127
+ * real knob (opencode/droid/cursor) never receive one: the server hides +
128
+ * rejects effort for those runtimes, and rejects levels a runtime lacks
129
+ * (sessionEffortLockError). */
130
+ export type CliReasoningEffort = "low" | "medium" | "high" | "xhigh" | "max";
129
131
 
130
132
  /** Per-CLI behaviour. The base owns the lifecycle (init/done/error) and the
131
133
  * transport (spawn + JSONL parse); a spec owns the CLI-specific bits. */
@@ -72,13 +72,15 @@ function toolResultText(content: unknown): string {
72
72
  return content == null ? "" : JSON.stringify(content) ?? "";
73
73
  }
74
74
 
75
- /** Extended-thinking token budget per effort level — Claude Code's real knob
76
- * is the `MAX_THINKING_TOKENS` env var (its documented settings env), set
77
- * per invocation below. Values mirror the platform turn loop's
78
- * `EFFORT_BUDGET_TOKENS` (server model-client) so "high" means the same
79
- * thing in a channel and in a claude-code session. */
80
- export const CLAUDE_CODE_THINKING_TOKENS: Record<CliReasoningEffort, number> =
81
- { low: 2_048, medium: 8_192, high: 16_384 };
75
+ /** Claude Code's real reasoning knob is its own `--effort <level>` flag
76
+ * (low|medium|high|xhigh|max — verified against `claude -p --help`). The
77
+ * CliReasoningEffort union IS the CLI's vocabulary, so the level rides the
78
+ * flag verbatim; the CLI itself downgrades a level the selected model lacks
79
+ * (its documented behaviour, e.g. xhigh → high off Opus). The old
80
+ * `MAX_THINKING_TOKENS` env mapping is gone: the CLI deprecated it (treated
81
+ * as on/off on current models) and it could never express xhigh/max. */
82
+ export const CLAUDE_CODE_EFFORT_LEVELS: readonly CliReasoningEffort[] =
83
+ ["low", "medium", "high", "xhigh", "max"];
82
84
 
83
85
  export const claudeCodeSpec: CliAgentSpec = {
84
86
  kind: "claude-code",
@@ -115,17 +117,25 @@ export const claudeCodeSpec: CliAgentSpec = {
115
117
  // root-user refusal (the E2B agent-env user is root).
116
118
  "--dangerously-skip-permissions",
117
119
  ...(model ? [`--model ${shellQuote(model)}`] : []),
120
+ // Reasoning effort is the CLI's own flag; the value comes from the
121
+ // closed CliReasoningEffort set, so it is shell-safe unquoted.
122
+ ...(effort ? [`--effort ${effort}`] : []),
118
123
  ...(sessionId ? [`--resume ${shellQuote(sessionId)}`] : []),
119
124
  ].join(" ");
120
- // Reasoning effort rides as the CLI's own thinking-budget env, same
121
- // per-invocation idiom as IS_SANDBOX (never persisted into settings).
122
- const thinking = effort ? `MAX_THINKING_TOKENS=${CLAUDE_CODE_THINKING_TOKENS[effort]} ` : "";
123
- return `${cwd ? `cd ${shellQuote(cwd)} && ` : ""}${thinking}IS_SANDBOX=1 claude ${flags} < ${shellQuote(promptPath)}`;
125
+ return `${cwd ? `cd ${shellQuote(cwd)} && ` : ""}IS_SANDBOX=1 claude ${flags} < ${shellQuote(promptPath)}`;
124
126
  },
125
127
  // Every stream-json event carries the session id; the init event is first.
126
128
  extractSessionId: (p) => (typeof p.session_id === "string" ? p.session_id : undefined),
127
129
  mapEvent: (p): AgentMessage[] => {
128
130
  const ts = now();
131
+ // Sidechain attribution: stream-json stamps `parent_tool_use_id` on
132
+ // every event emitted INSIDE a subagent (the spawning Agent/Task call's
133
+ // tool_use id; null at top level). Forwarded on tool_use/tool_result so
134
+ // renderers can nest child activity under the spawning call instead of
135
+ // flattening it into the parent transcript unattributed.
136
+ const parent = typeof p.parent_tool_use_id === "string" && p.parent_tool_use_id.length > 0
137
+ ? { parentToolUseId: p.parent_tool_use_id }
138
+ : {};
129
139
  switch (p.type) {
130
140
  // Assistant API message: content blocks → text / thinking / tool_use.
131
141
  case "assistant": {
@@ -141,7 +151,7 @@ export const claudeCodeSpec: CliAgentSpec = {
141
151
  if (b.type === "tool_use") {
142
152
  return [{
143
153
  type: "tool_use", toolName: String(b.name ?? "tool"),
144
- toolInput: b.input ?? {}, toolUseId: String(b.id ?? ""), timestamp: ts,
154
+ toolInput: b.input ?? {}, toolUseId: String(b.id ?? ""), ...parent, timestamp: ts,
145
155
  }];
146
156
  }
147
157
  return [];
@@ -155,7 +165,7 @@ export const claudeCodeSpec: CliAgentSpec = {
155
165
  b.type === "tool_result"
156
166
  ? [{
157
167
  type: "tool_result", toolUseId: String(b.tool_use_id ?? ""),
158
- output: toolResultText(b.content), isError: b.is_error === true, timestamp: ts,
168
+ output: toolResultText(b.content), isError: b.is_error === true, ...parent, timestamp: ts,
159
169
  }]
160
170
  : []);
161
171
  }
@@ -234,8 +244,8 @@ export const claudeCodeSpec: CliAgentSpec = {
234
244
  export interface ClaudeCodeRuntimeConfig {
235
245
  /** Claude model id (`--model`). Omit to use the CLI's configured default. */
236
246
  model?: string;
237
- /** Extended-thinking effort (`MAX_THINKING_TOKENS`). Omit for the CLI's
238
- * default behaviour (no forced budget). */
247
+ /** Reasoning effort (`--effort <level>`; the CLI accepts all five levels).
248
+ * Omit for the CLI's default behaviour. */
239
249
  effort?: CliReasoningEffort;
240
250
  }
241
251
 
@@ -88,9 +88,13 @@ export const codexSpec: CliAgentSpec = {
88
88
  "--dangerously-bypass-approvals-and-sandbox",
89
89
  ...(model ? ["-m", shellQuote(model)] : []),
90
90
  // codex's own reasoning knob — a config override, valid values
91
- // low|medium|high (plus "minimal", unused here). The value comes from
91
+ // none|minimal|low|medium|high|xhigh (codex docs; xhigh is the
92
+ // codex-max-tier deep-reasoning level). There is NO "max" in codex's
93
+ // vocabulary: the server rejects it for codex sessions
94
+ // (sessionEffortLockError), and this clamp to xhigh is the type-level
95
+ // backstop for a caller that bypasses that gate. The value comes from
92
96
  // the closed CliReasoningEffort set, so it is shell-safe unquoted.
93
- ...(effort ? ["-c", `model_reasoning_effort=${effort}`] : []),
97
+ ...(effort ? ["-c", `model_reasoning_effort=${effort === "max" ? "xhigh" : effort}`] : []),
94
98
  ...(cwd ? ["-C", shellQuote(cwd)] : []),
95
99
  ].join(" ");
96
100
  // Fresh turn: `codex exec <flags> - < prompt`. Continue a thread:
@@ -171,12 +175,16 @@ export const codexSpec: CliAgentSpec = {
171
175
  },
172
176
  };
173
177
 
178
+ /** The effort levels codex actually has (`model_reasoning_effort`):
179
+ * low|medium|high|xhigh — no "max" (that level is Claude Code's alone). */
180
+ export type CodexReasoningEffort = Exclude<CliReasoningEffort, "max">;
181
+
174
182
  export interface CodexRuntimeConfig {
175
183
  /** Codex model id (`-m`). Omit to use the codex CLI's configured default. */
176
184
  model?: string;
177
185
  /** Reasoning effort (`-c model_reasoning_effort=<level>`). Omit to use the
178
186
  * codex CLI's configured default. */
179
- effort?: CliReasoningEffort;
187
+ effort?: CodexReasoningEffort;
180
188
  }
181
189
 
182
190
  export function createCodexRuntime(config: CodexRuntimeConfig = {}) {
@@ -155,6 +155,13 @@ export interface ConversationDetail {
155
155
  * in drivers. Null/absent when never reported (platform agents, older
156
156
  * daemons/servers). */
157
157
  sessionCommands?: Array<{ name: string; description: string }> | null;
158
+ /** Where this conversation's UNMERGED work lives: a cloud session writes
159
+ * its own private drive branch, and every drive read a client makes for
160
+ * THAT factory must carry `?branch=` or it resolves against `main`,
161
+ * where session-born files do not exist. Non-null only for a session
162
+ * with a drive branch and a resolvable factory; null/absent = main
163
+ * only (read loosely — older servers omit it). */
164
+ sessionDrive?: { factorySlug: string; branch: string } | null;
158
165
  viewerLastReadAt: string | null;
159
166
  /** The caller's role in this conversation (ADR-0045) — drives client
160
167
  * affordances only; the server remains the authority on every action.
@@ -226,6 +233,24 @@ export interface CreateCloudSessionInput {
226
233
  * profile ids, or "none" for a bare session. Omitted = the owner's
227
234
  * default profile. */
228
235
  connectorProfileId?: string;
236
+ // ── Session import (session-import spec) ────────────────────────────────
237
+ /** Idempotent dependency-setup command run by every fresh sandbox acquire
238
+ * after the drive mounts (`bun install`, `npm ci`, …). Strict charset —
239
+ * no shell metacharacters (400). Mutually exclusive with `templateId`
240
+ * (a snapshot carries its own installed state). */
241
+ setupCommand?: string;
242
+ /** Provenance of a session born by `agentc session import`: where the
243
+ * imported local session lived and which harness thread seeded it. */
244
+ handoffOrigin?: {
245
+ cwd: string;
246
+ sourceAcpSessionId: string | null;
247
+ agentKind: string;
248
+ mirrorConversationId: string | null;
249
+ };
250
+ /** Imported claude memories (session-import spec §memories): guest-home
251
+ * seeds re-applied on every fresh acquire. Paths under .claude/ only;
252
+ * strict base64; 256KB decoded total (server-capped). */
253
+ seedFiles?: Array<{ path: string; contentB64: string; mode: "write" | "append" }>;
229
254
  }
230
255
 
231
256
  export interface CloudSessionCreated {
@@ -95,6 +95,11 @@ export interface RegisterWorkflowInput {
95
95
  * server skips the /factory mount for its runs (#13). See
96
96
  * `WorkflowMetadata.environmentBuild`. */
97
97
  environmentBuild?: boolean;
98
+ /** Drive requirement declared via `defineWorkflow({ factoryDrive })`.
99
+ * Omitted ⇒ `"required"` on the server (a failed /factory mount fails
100
+ * the run); `"none"` = explicit no-drive opt-out. See
101
+ * `WorkflowMetadata.factoryDrive`. */
102
+ factoryDrive?: "required" | "none";
98
103
  /** Factory slug. Defaults to `"default"`. */
99
104
  factorySlug?: string;
100
105
  }
@@ -194,6 +199,33 @@ export interface FactoryFileWriteResult {
194
199
  created: boolean;
195
200
  }
196
201
 
202
+ // ── User drive mounts (`agentc files mount` on a human's own machine) ────────
203
+
204
+ /** A minted local-mount grant: the user branch, the signed gateway token, and
205
+ * where to dial. The backing `conversationId` is the mount's review surface
206
+ * (its branch changes list + merge ride the session-changes routes). */
207
+ export interface DriveMountSession {
208
+ conversationId: string;
209
+ branch: string;
210
+ /** Signed gateway mount token (user principal, exclusive mode). Treat as a
211
+ * secret: write it to a 0600 token file, never argv/env of children. */
212
+ token: string;
213
+ gatewayWsUrl: string;
214
+ /** Token expiry, unix seconds — re-mint (same `conversationId`) before it. */
215
+ expiresAtS: number;
216
+ diskId: string;
217
+ }
218
+
219
+ export interface CreateDriveMountSessionInput {
220
+ /** Re-mint for an existing mount session (remount / token refresh). */
221
+ conversationId?: string;
222
+ /** The mounting machine's hostname — carried in the mount's title. */
223
+ host?: string;
224
+ }
225
+
226
+ // A mount's change set + merge ride the existing session-branch proposal
227
+ // types in `api-conversations.ts` (`SessionChangeSet`, `SessionMergeReport`).
228
+
197
229
  /** A factory: a project-level grouping of workflows inside a team. */
198
230
  export interface FactoryRow {
199
231
  id: string;
@@ -10,9 +10,19 @@
10
10
  import type { SandboxNetworkPolicy, SandboxSize } from "../sandbox.js";
11
11
  import type { SnapshotConfig } from "./workflow-metadata.js";
12
12
  import type { RunContext } from "./api-scopes.js";
13
+ import type { BundledWorkflow } from "../utils/bundler.js";
13
14
 
14
15
  export type RunState = "running" | "success" | "failed" | "abandoned" | "canceled";
15
16
 
17
+ /** INLINE invoke payload (`POST /factories/:slug/invoke`): everything
18
+ * `bundleWorkflow` produced — source, the REQUIRED manifest binding the
19
+ * bytes, the workflow plan, and the build fields — plus the name the run
20
+ * reports as its workflow. The server runs it through the same
21
+ * validate/build core as registration but writes NO registry row: the run
22
+ * snapshots the validated source (auditable, replayable) and the name
23
+ * stays free for real registrations. */
24
+ export type InlineWorkflowPayload = BundledWorkflow & { name: string };
25
+
16
26
  export interface InvokeWorkflowOptions {
17
27
  /** Per-invocation snapshot config override. `snapshots.bootFrom`
18
28
  * replaces the template's boot source; `snapshots.saveLatest` and
@@ -41,13 +51,50 @@ export interface InvokeWorkflowOptions {
41
51
  * with the same key inside the server's dedup window returns the original
42
52
  * run instead of starting a new one (matches `resumePause`'s pattern). */
43
53
  idempotencyKey?: string;
44
- }
54
+ /** Who pays for this run's model calls:
55
+ *
56
+ * - `"default"` (Auto) — the initiating human's connected subscription
57
+ * when one is enabled for workflows, else platform credits;
58
+ * - `"platform"` — always platform credits (metered);
59
+ * - `"subscription"` — REQUIRE the initiating human's plan. If it
60
+ * cannot be honored the invoke FAILS (412) rather than quietly
61
+ * spending credits;
62
+ * - `"byok"` — a factory secret named by `fundingSecret`, injected as
63
+ * the runtime's raw provider key. Never metered.
64
+ *
65
+ * Omitted → the workflow's own default (its `settings.funding`), else
66
+ * Auto. */
67
+ funding?: FundingChoice;
68
+ /** `funding: "byok"` only — the FACTORY SECRET NAME whose value funds the
69
+ * run. Must be a provider key variable (ANTHROPIC_API_KEY,
70
+ * OPENAI_API_KEY, CODEX_API_KEY, OPENROUTER_API_KEY) — that is where the
71
+ * sandbox's runtimes read it. The name only; the value never leaves the
72
+ * server's secret store. */
73
+ fundingSecret?: string;
74
+ }
75
+
76
+ /** Inference-funding choice for a run — a lane the caller PINS, or
77
+ * `"default"` (Auto, the automatic ladder). Mirrors the same vocabulary
78
+ * cloud sessions use. */
79
+ export type FundingChoice = "default" | "platform" | "subscription" | "byok";
45
80
 
46
81
  export interface InvokeAndWaitOptions extends InvokeWorkflowOptions {
47
82
  timeoutMs?: number;
48
83
  pollIntervalMs?: number;
49
84
  }
50
85
 
86
+ /** Options for `invokeInline` — the named-invoke options plus the run-title
87
+ * override (an inline run has no registered template to inherit one from). */
88
+ export interface InvokeInlineOptions extends InvokeWorkflowOptions {
89
+ /** Run title override — defaults to the workflow name. */
90
+ title?: string;
91
+ }
92
+
93
+ export interface InvokeInlineAndWaitOptions extends InvokeInlineOptions {
94
+ timeoutMs?: number;
95
+ pollIntervalMs?: number;
96
+ }
97
+
51
98
  export interface InvokeResult {
52
99
  id: string;
53
100
  }
@@ -37,6 +37,12 @@ export interface AgentMessageToolUse extends AgentMessageBase {
37
37
  toolName: string;
38
38
  toolInput: Record<string, unknown>;
39
39
  toolUseId: string;
40
+ /** The spawning subagent call's tool_use id when this call ran INSIDE a
41
+ * subagent (claude stream-json stamps `parent_tool_use_id` on every
42
+ * sidechain event) — lets renderers nest child activity under the
43
+ * Agent/Task call instead of flattening it into the parent transcript.
44
+ * Optional and additive: producers without sidechains omit it. */
45
+ parentToolUseId?: string;
40
46
  }
41
47
 
42
48
  export interface AgentMessageToolResult extends AgentMessageBase {
@@ -54,6 +60,8 @@ export interface AgentMessageToolResult extends AgentMessageBase {
54
60
  /** File locations touched by the tool call (ACP `locations` field), enabling
55
61
  * "follow-along" UI. Optional and additive (WS-C / ADR-0020 Q3). */
56
62
  locations?: { path: string; line?: number }[];
63
+ /** Sidechain attribution, mirroring AgentMessageToolUse.parentToolUseId. */
64
+ parentToolUseId?: string;
57
65
  }
58
66
 
59
67
  export interface AgentMessageDone extends AgentMessageBase {
@@ -241,6 +241,14 @@ export interface WorkflowMetadata {
241
241
  * canonical metadata hash (frozen-metadata rule), so existing workflows are
242
242
  * not forced to re-register. */
243
243
  environmentBuild?: boolean;
244
+ /** Whether this workflow's runs need the factory drive. ABSENT ⇒
245
+ * `"required"`: on a drive-backed factory the server treats the /factory
246
+ * mount as load-bearing — a mount failure FAILS the run instead of
247
+ * silently proceeding drive-less. Declare `"none"` for a workflow that
248
+ * genuinely never touches /factory: the server skips the mount entirely
249
+ * for its runs (the explicit no-drive mode; there is no silent degrade).
250
+ * Optional + additive (frozen-metadata rule). */
251
+ factoryDrive?: "required" | "none";
244
252
  }
245
253
 
246
254
  /**
@@ -274,6 +282,7 @@ export function extractMetadata(source: Partial<WorkflowMetadata>): WorkflowMeta
274
282
  if (source.connectorOperation !== undefined) out.connectorOperation = Object.freeze({ ...source.connectorOperation });
275
283
  if (source.invokePolicy !== undefined) out.invokePolicy = freezeMetadataValue(source.invokePolicy);
276
284
  if (source.environmentBuild !== undefined) out.environmentBuild = source.environmentBuild;
285
+ if (source.factoryDrive !== undefined) out.factoryDrive = source.factoryDrive;
277
286
  return Object.freeze(out);
278
287
  }
279
288
 
@@ -18,9 +18,23 @@
18
18
  * parses or imports user source — it only validates the structured manifest
19
19
  * this function returns alongside the bundled bytes, and cross-checks the
20
20
  * manifest's `sourceHash` against the source it received.
21
+ *
22
+ * Module resolution carries two more layers, because most workflow source is
23
+ * now written by an AGENT and the bundler's error is the only feedback it
24
+ * gets (the Workflow Studio's agent authored `import … from "agentc/sdk"`
25
+ * and prod answered with bun's `Maybe you need to "bun install"?` — advice
26
+ * nobody could act on inside a sandbox with no package.json):
27
+ *
28
+ * - TOLERATE (`SDK_SPECIFIER_ALIASES` + `sdkAliasPlugin`) — near-miss
29
+ * spellings of `@agent-compose/sdk` resolve to the real package, so a
30
+ * draft already written with the wrong one builds unedited.
31
+ * - SELF-CORRECT (`explainBundleFailure`) — anything that still fails to
32
+ * resolve produces an error NAMING the real package, which an agent
33
+ * reading its own tool error can fix on the next turn.
21
34
  */
22
35
 
23
36
  import { createHash } from "node:crypto";
37
+ import { dirname } from "node:path";
24
38
  import { parse as babelParse } from "@babel/parser";
25
39
  import type { File, ExportDefaultDeclaration, CallExpression, Expression, Statement } from "@babel/types";
26
40
  import { importSourceModule } from "./source-loader.js";
@@ -121,11 +135,191 @@ export interface BundledWorkflow {
121
135
  /** Set by `defineSandboxEnvironment` — marks an environment build so the
122
136
  * server skips the /factory mount for its runs (#13). */
123
137
  environmentBuild?: boolean;
138
+ /** Drive requirement declared via `defineWorkflow({ factoryDrive })`.
139
+ * Absent ⇒ `"required"` (a failed /factory mount fails the run);
140
+ * `"none"` = explicit no-drive opt-out. */
141
+ factoryDrive?: "required" | "none";
142
+ }
143
+
144
+ /** The one true import specifier for the platform SDK. */
145
+ export const SDK_PACKAGE = "@agent-compose/sdk";
146
+
147
+ /**
148
+ * Import specifiers that can only have MEANT `@agent-compose/sdk`, rewritten
149
+ * to it at bundle time so a draft written with the wrong spelling builds
150
+ * unedited.
151
+ *
152
+ * Every entry carries an `sdk` segment or suffix, so none of them can be a
153
+ * real third-party package a workflow might legitimately depend on: `a/b`
154
+ * forms are subpaths of packages that don't exist, and the `*-sdk` forms
155
+ * name this platform explicitly. Bare `agentc` / `agent-compose` are
156
+ * deliberately NOT aliased — those are plausible npm package names, and
157
+ * silently redirecting a real dependency is worse than a clear error.
158
+ *
159
+ * Exported for the unit test that pins the table.
160
+ */
161
+ export const SDK_SPECIFIER_ALIASES: readonly string[] = [
162
+ "agentc/sdk",
163
+ "@agentc/sdk",
164
+ "agentc-sdk",
165
+ "agent-compose/sdk",
166
+ "agentcompose/sdk",
167
+ "@agentcompose/sdk",
168
+ "agent-compose-sdk",
169
+ ];
170
+
171
+ const SDK_ALIAS_SET = new Set(SDK_SPECIFIER_ALIASES);
172
+
173
+ /** `@agent-compose/sdk` when `specifier` is a known near-miss for it, else
174
+ * null. The single authority: the plugin's regex filter is only a fast
175
+ * pre-filter, and this decides. */
176
+ export function resolveSdkAlias(specifier: string): string | null {
177
+ return SDK_ALIAS_SET.has(specifier) ? SDK_PACKAGE : null;
178
+ }
179
+
180
+ /** Minimal shape of the Bun surface this module drives. Accessed via
181
+ * globalThis so the SDK keeps no compile-time dependency on @types/bun. */
182
+ interface BunBuildLog { message: string; name?: string; specifier?: string }
183
+ interface BunSurface {
184
+ build(opts: {
185
+ entrypoints: string[]; format: string; target: string;
186
+ plugins?: { name: string; setup(build: BunPluginBuild): void }[];
187
+ }): Promise<{ success: boolean; outputs: { text(): Promise<string> }[]; logs: BunBuildLog[] }>;
188
+ resolveSync(specifier: string, parent: string): string;
189
+ }
190
+ interface BunPluginBuild {
191
+ onResolve(
192
+ constraints: { filter: RegExp; namespace?: string },
193
+ callback: (args: { path: string; importer?: string; resolveDir?: string }) => { path: string } | undefined,
194
+ ): void;
195
+ }
196
+
197
+ /** Anchored regex over the alias table — the plugin filter. Specifiers are
198
+ * literal package names, but escape anyway so a future entry with a `.`
199
+ * or `+` can't widen the filter. */
200
+ function aliasFilter(): RegExp {
201
+ const alternation = SDK_SPECIFIER_ALIASES
202
+ .map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
203
+ .join("|");
204
+ return new RegExp(`^(?:${alternation})$`);
205
+ }
206
+
207
+ /**
208
+ * The TOLERATE layer: a Bun resolve plugin that maps every known near-miss
209
+ * specifier onto the real SDK. Resolution goes through Bun's own resolver
210
+ * from the importing file's directory, so the alias lands on exactly the
211
+ * `@agent-compose/sdk` the build would have used had the source spelled it
212
+ * correctly. When the real SDK can't be resolved either, the plugin declines
213
+ * and Bun's failure flows into `explainBundleFailure` below.
214
+ */
215
+ function sdkAliasPlugin(bun: BunSurface): { name: string; setup(build: BunPluginBuild): void } {
216
+ return {
217
+ name: "agent-compose-sdk-alias",
218
+ setup(build) {
219
+ build.onResolve({ filter: aliasFilter() }, (args) => {
220
+ const target = resolveSdkAlias(args.path);
221
+ if (!target) return undefined;
222
+ // `importer` is the importing FILE, `resolveDir` already a directory.
223
+ const from = args.importer ? dirname(args.importer) : args.resolveDir;
224
+ try {
225
+ return { path: bun.resolveSync(target, from && from.length > 0 ? from : process.cwd()) };
226
+ } catch {
227
+ return undefined;
228
+ }
229
+ });
230
+ },
231
+ };
232
+ }
233
+
234
+ /** The unresolved specifier a Bun diagnostic is about, or null when it isn't
235
+ * a resolve failure. `ResolveMessage` carries it as a field; parsing the
236
+ * message text is the fallback. */
237
+ function readUnresolvedSpecifier(diag: unknown): string | null {
238
+ if (!diag || typeof diag !== "object") return null;
239
+ const d = diag as { specifier?: unknown; message?: unknown };
240
+ if (typeof d.specifier === "string" && d.specifier.length > 0) return d.specifier;
241
+ const message = typeof d.message === "string" ? d.message : "";
242
+ const m = /Could not resolve:?\s*"([^"]+)"/.exec(message);
243
+ return m ? m[1] : null;
244
+ }
245
+
246
+ /** Flatten a bundler failure into the diagnostics it actually carries.
247
+ * Bun reports through two channels and both land here: a THROWN
248
+ * `AggregateError("Bundle failed")` whose real messages hide in `.errors`,
249
+ * and a returned `logs` array on `success: false`. */
250
+ function flattenDiagnostics(failure: unknown): unknown[] {
251
+ const out: unknown[] = [];
252
+ const seen = new Set<unknown>();
253
+ const walk = (node: unknown, depth: number): void => {
254
+ if (!node || depth > 4 || seen.has(node)) return;
255
+ seen.add(node);
256
+ if (Array.isArray(node)) {
257
+ for (const sub of node) walk(sub, depth + 1);
258
+ return;
259
+ }
260
+ out.push(node);
261
+ if (typeof node === "object") {
262
+ const n = node as { errors?: unknown; cause?: unknown };
263
+ if (Array.isArray(n.errors)) for (const sub of n.errors) walk(sub, depth + 1);
264
+ if (n.cause) walk(n.cause, depth + 1);
265
+ }
266
+ };
267
+ walk(failure, 0);
268
+ return out;
269
+ }
270
+
271
+ function diagnosticText(diag: unknown): string {
272
+ if (diag instanceof Error) return diag.message || String(diag);
273
+ if (diag && typeof diag === "object" && typeof (diag as { message?: unknown }).message === "string") {
274
+ return (diag as { message: string }).message;
275
+ }
276
+ return String(diag);
277
+ }
278
+
279
+ /**
280
+ * The SELF-CORRECTING layer. Turns a Bun bundle failure into a message that
281
+ * names the real fix instead of leaking Bun's internals.
282
+ *
283
+ * An unresolved import is almost always one thing: workflow source naming
284
+ * the platform SDK by a specifier that isn't its package name. Bun answers
285
+ * that with `Could not resolve: "agentc/sdk". Maybe you need to "bun
286
+ * install"?` — advice the author cannot act on (there is no package.json to
287
+ * install into; the drive holds one file). The rewritten message says the
288
+ * package name, so an agent reading its own tool error can fix the import on
289
+ * the next turn.
290
+ *
291
+ * Non-resolve failures (syntax errors, transform failures) keep their
292
+ * verbatim diagnostics — those are already actionable.
293
+ *
294
+ * Exported for the unit test; `bundleWorkflow` is the supported entrypoint.
295
+ */
296
+ export function explainBundleFailure(err: unknown, label: string): string {
297
+ const diagnostics = flattenDiagnostics(err);
298
+ const unresolved: string[] = [];
299
+ for (const diag of diagnostics) {
300
+ const specifier = readUnresolvedSpecifier(diag);
301
+ if (specifier && !unresolved.includes(specifier)) unresolved.push(specifier);
302
+ }
303
+
304
+ if (unresolved.length > 0) {
305
+ const quoted = unresolved.map((s) => `"${s}"`).join(", ");
306
+ return (
307
+ `Could not resolve ${quoted} — workflow source imports the platform SDK as "${SDK_PACKAGE}". ` +
308
+ `Fix the import specifier in ${label}; anything that is genuinely a third-party ` +
309
+ `package must be installed where the source is bundled.`
310
+ );
311
+ }
312
+
313
+ const detail = diagnostics
314
+ .map(diagnosticText)
315
+ .filter((t) => t.length > 0 && t !== "Bundle failed")
316
+ .join("\n");
317
+ return `Failed to bundle ${label}:\n${detail || "the bundler reported no diagnostics"}`;
124
318
  }
125
319
 
126
320
  async function bundle(path: string, label: string): Promise<string> {
127
321
  // Use globalThis to access Bun without a compile-time dependency on @types/bun.
128
- const bun = globalThis as unknown as { Bun?: { build(opts: { entrypoints: string[]; format: string; target: string }): Promise<{ success: boolean; outputs: { text(): Promise<string> }[]; logs: { message: string }[] }> } };
322
+ const bun = globalThis as unknown as { Bun?: BunSurface };
129
323
  if (!bun.Bun?.build) throw new Error("bundleWorkflow requires the Bun runtime (Bun.build)");
130
324
  // target: "node" — the bundle runs inside the Vercel sandbox under Node 22+.
131
325
  //
@@ -141,9 +335,24 @@ async function bundle(path: string, label: string): Promise<string> {
141
335
  // Pure-ESM workflows (e.g. only importing `@agent-compose/sdk`) bundled
142
336
  // identically under either target — that's why this bug stayed hidden
143
337
  // until the first workflow that pulled CJS deps got dispatched.
144
- const result = await bun.Bun.build({ entrypoints: [path], format: "esm", target: "node" });
338
+ //
339
+ // The alias plugin rewrites known near-miss SDK specifiers before Bun's
340
+ // resolver sees them; whatever still fails to resolve comes back through
341
+ // `explainBundleFailure` naming the real package instead of Bun's
342
+ // "Maybe you need to `bun install`?".
343
+ let result: Awaited<ReturnType<BunSurface["build"]>>;
344
+ try {
345
+ result = await bun.Bun.build({
346
+ entrypoints: [path], format: "esm", target: "node",
347
+ plugins: [sdkAliasPlugin(bun.Bun)],
348
+ });
349
+ } catch (err) {
350
+ // Modern Bun.build THROWS AggregateError("Bundle failed") on a resolve
351
+ // or transform error rather than returning `success: false`.
352
+ throw new WorkflowSourceValidationError(explainBundleFailure(err, label));
353
+ }
145
354
  if (!result.success) {
146
- throw new Error(`Failed to bundle ${label}:\n${result.logs.map((l) => l.message).join("\n")}`);
355
+ throw new WorkflowSourceValidationError(explainBundleFailure(result.logs, label));
147
356
  }
148
357
  return result.outputs[0].text();
149
358
  }
@@ -371,6 +580,7 @@ export async function bundleWorkflow(
371
580
  ...(metadata.connectorOperation !== undefined ? { connectorOperation: metadata.connectorOperation } : {}),
372
581
  ...(metadata.invokePolicy !== undefined ? { invokePolicy: metadata.invokePolicy } : {}),
373
582
  ...(metadata.environmentBuild !== undefined ? { environmentBuild: metadata.environmentBuild } : {}),
583
+ ...(metadata.factoryDrive !== undefined ? { factoryDrive: metadata.factoryDrive } : {}),
374
584
  };
375
585
  }
376
586