@agent-compose/sdk 0.6.0 → 0.8.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 (126) hide show
  1. package/README.md +66 -39
  2. package/dist/agent/__tests__/runtime-json-schema.test.d.ts +10 -0
  3. package/dist/agent/agent-context.d.ts +21 -1
  4. package/dist/agent/agent-loop.d.ts +24 -1
  5. package/dist/client.d.ts +338 -534
  6. package/dist/directives.d.ts +112 -0
  7. package/dist/display.d.ts +242 -0
  8. package/dist/errors.d.ts +24 -1
  9. package/dist/index.d.ts +34 -13
  10. package/dist/index.js +2984 -861
  11. package/dist/pause/wrappers.d.ts +31 -9
  12. package/dist/processors/ask-human.d.ts +30 -0
  13. package/dist/processors/ask-human.test.d.ts +1 -0
  14. package/dist/processors/index.d.ts +1 -0
  15. package/dist/runtimes/_acp-client.d.ts +46 -1
  16. package/dist/runtimes/_cli-agent.d.ts +58 -4
  17. package/dist/runtimes/_jsonl-guard.d.ts +103 -0
  18. package/dist/runtimes/amp.d.ts +2 -2
  19. package/dist/runtimes/claude-code.d.ts +59 -0
  20. package/dist/runtimes/claude-code.test.d.ts +14 -0
  21. package/dist/runtimes/claude.d.ts +16 -0
  22. package/dist/runtimes/claude.test.d.ts +8 -0
  23. package/dist/runtimes/codex.d.ts +9 -3
  24. package/dist/runtimes/cursor.d.ts +9 -0
  25. package/dist/runtimes/droid.d.ts +9 -0
  26. package/dist/runtimes/jsonl-guard.test.d.ts +19 -0
  27. package/dist/runtimes/openai-desktop.js +2922 -861
  28. package/dist/runtimes/opencode.d.ts +25 -0
  29. package/dist/runtimes/vercel.js +22 -1
  30. package/dist/sandbox/devbox.d.ts +42 -0
  31. package/dist/sandbox/exec-stream.d.ts +14 -0
  32. package/dist/sandbox/network-policy.d.ts +100 -0
  33. package/dist/sandbox/provider-def.d.ts +79 -0
  34. package/dist/sandbox/providers/desktop.d.ts +10 -0
  35. package/dist/sandbox/providers/e2b.d.ts +17 -0
  36. package/dist/sandbox/providers/local.d.ts +11 -0
  37. package/dist/sandbox/providers/vercel.d.ts +18 -0
  38. package/dist/sandbox/registry.d.ts +45 -0
  39. package/dist/sandbox/sizes.d.ts +68 -0
  40. package/dist/sandbox.d.ts +24 -299
  41. package/dist/step-invocation/__tests__/foreground-recovery.test.d.ts +1 -0
  42. package/dist/step-invocation/invoker.d.ts +24 -1
  43. package/dist/step-invocation/protocol.d.ts +13 -0
  44. package/dist/types/api-compliance.d.ts +71 -0
  45. package/dist/types/api-conversations.d.ts +492 -0
  46. package/dist/types/api-factory.d.ts +309 -0
  47. package/dist/types/api-projects.d.ts +131 -0
  48. package/dist/types/api-runs.d.ts +377 -0
  49. package/dist/types/api-scopes.d.ts +102 -0
  50. package/dist/types/conversation-stream.d.ts +191 -0
  51. package/dist/types/execution-context.d.ts +12 -2
  52. package/dist/types/protocol.d.ts +30 -1
  53. package/dist/types/sandbox-environment.d.ts +8 -5
  54. package/dist/types/sandbox.d.ts +79 -0
  55. package/dist/types/workflow-metadata.d.ts +33 -8
  56. package/dist/types/workflow-plan.d.ts +10 -0
  57. package/dist/types/workflow.d.ts +18 -193
  58. package/dist/utils/bundler.d.ts +12 -1
  59. package/dist/utils/errors.d.ts +9 -1
  60. package/dist/workflow-steps/index.d.ts +1 -1
  61. package/dist/workflow-steps/observability.d.ts +8 -1
  62. package/dist/workflow-steps/runner.d.ts +3 -3
  63. package/dist/workflow-steps/step.d.ts +15 -1
  64. package/dist/workflow-steps/types.d.ts +19 -5
  65. package/dist/workflow-steps/workflow.d.ts +22 -1
  66. package/dist/workflows/engine.d.ts +3 -2
  67. package/dist/workflows/invoke-child.d.ts +2 -2
  68. package/package.json +1 -1
  69. package/src/agent/agent-context.ts +206 -16
  70. package/src/agent/agent-loop.ts +40 -4
  71. package/src/agent/run-agent.ts +9 -1
  72. package/src/client.ts +909 -621
  73. package/src/directives.ts +184 -0
  74. package/src/display.ts +788 -0
  75. package/src/errors.ts +39 -0
  76. package/src/index.ts +117 -10
  77. package/src/pause/wrappers.ts +44 -9
  78. package/src/processors/ask-human.ts +136 -0
  79. package/src/processors/index.ts +5 -0
  80. package/src/runtimes/_acp-client.ts +72 -3
  81. package/src/runtimes/_cli-agent.ts +171 -38
  82. package/src/runtimes/_jsonl-guard.ts +219 -0
  83. package/src/runtimes/claude-code.ts +246 -0
  84. package/src/runtimes/claude.ts +32 -2
  85. package/src/runtimes/codex.ts +55 -3
  86. package/src/runtimes/cursor.ts +59 -0
  87. package/src/runtimes/droid.ts +63 -0
  88. package/src/runtimes/openai-desktop.ts +59 -14
  89. package/src/runtimes/opencode.ts +61 -0
  90. package/src/sandbox/devbox.ts +48 -0
  91. package/src/sandbox/exec-stream.ts +48 -0
  92. package/src/sandbox/network-policy.ts +181 -0
  93. package/src/sandbox/provider-def.ts +94 -0
  94. package/src/sandbox/providers/desktop.ts +57 -0
  95. package/src/sandbox/providers/e2b.ts +354 -0
  96. package/src/sandbox/providers/local.ts +106 -0
  97. package/src/sandbox/providers/vercel.ts +331 -0
  98. package/src/sandbox/registry.ts +198 -0
  99. package/src/sandbox/sizes.ts +95 -0
  100. package/src/sandbox.ts +59 -1263
  101. package/src/step-invocation/invoker.ts +319 -34
  102. package/src/step-invocation/protocol.ts +19 -0
  103. package/src/types/api-compliance.ts +79 -0
  104. package/src/types/api-conversations.ts +522 -0
  105. package/src/types/api-factory.ts +336 -0
  106. package/src/types/api-projects.ts +140 -0
  107. package/src/types/api-runs.ts +412 -0
  108. package/src/types/api-scopes.ts +102 -0
  109. package/src/types/conversation-stream.ts +231 -0
  110. package/src/types/execution-context.ts +10 -2
  111. package/src/types/protocol.ts +33 -0
  112. package/src/types/sandbox-environment.ts +28 -9
  113. package/src/types/sandbox.ts +78 -0
  114. package/src/types/workflow-metadata.ts +35 -8
  115. package/src/types/workflow-plan.ts +11 -0
  116. package/src/types/workflow.ts +25 -280
  117. package/src/utils/bundler.ts +32 -5
  118. package/src/utils/errors.ts +34 -2
  119. package/src/workflow-steps/index.ts +1 -0
  120. package/src/workflow-steps/observability.ts +19 -8
  121. package/src/workflow-steps/runner.ts +4 -4
  122. package/src/workflow-steps/step.ts +49 -1
  123. package/src/workflow-steps/types.ts +20 -5
  124. package/src/workflow-steps/workflow.ts +22 -1
  125. package/src/workflows/engine.ts +3 -2
  126. package/src/workflows/invoke-child.ts +2 -2
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Conversation SSE stream — typed wire events (ADR-0033 phase 1 shapes,
3
+ * extended by ADR-0037 with the live-only `turn_state` / `presence` frames).
4
+ *
5
+ * The server (`server/src/routes/conversations.ts`, `GET
6
+ * /conversations/:id/stream`) emits two classes of event:
7
+ *
8
+ * - **Durable** events (`part`, `message_done`, `error`, `reaction`,
9
+ * `message_deleted`): each is a persisted `conversation_stream_events`
10
+ * row, carries an SSE `id:` field (the conversation-global row id), and
11
+ * replays on reconnect. Clients advance `Last-Event-ID` from these — and
12
+ * ONLY these.
13
+ * - **Live-only** frames (`part_partial`, `turn_state`, `presence`,
14
+ * `session_status`): no row, no SSE `id:` field, never replayed.
15
+ * Advancing `Last-Event-ID` from one would skip durable rows on
16
+ * reconnect and corrupt replay — this is a pinned invariant on every
17
+ * client (dashboard, SDK, TUI).
18
+ *
19
+ * Two sequences ride the wire — do not conflate them:
20
+ * - the SSE `id:` field is the conversation-global stream-event row id
21
+ * (`Last-Event-ID` resumption is keyed on this);
22
+ * - `seq` is the per-message part index (0, 1, …) — part ordering and
23
+ * replay dedupe within one message key on this.
24
+ *
25
+ * `normalizeConversationStreamEvent` folds both transports' framing (SSE
26
+ * `event:`/`id:` fields + JSON data payload) into one union member with a
27
+ * single rule: `id` is present ⇔ the event is durable.
28
+ */
29
+
30
+ import type { ConversationMessagePart } from "./api-conversations.js";
31
+
32
+ /** Message author metadata — rides every message-scoped event (live and
33
+ * replayed) so a teammate's user message mirrored onto a shared thread
34
+ * renders as a user message, not as the agent. */
35
+ export interface ConversationStreamAuthor {
36
+ authorKind?: "user" | "agent" | "system";
37
+ authorId?: string | null;
38
+ /** Present only on thread replies — absent = a room message. */
39
+ threadRootId?: string;
40
+ }
41
+
42
+ interface DurableEventBase extends ConversationStreamAuthor {
43
+ conversationId: string;
44
+ /** Durable stream-event row id (the SSE `id:` field). Present on every
45
+ * durable event; clients advance `Last-Event-ID` from it. */
46
+ id?: number;
47
+ /** Producer timestamp, ms epoch. */
48
+ at: number;
49
+ }
50
+
51
+ /** One persisted message part (final). Replay-safe: a reconnect that
52
+ * re-delivers seq N is a no-op for a buffer that already holds it. */
53
+ export interface ConversationPartEvent extends DurableEventBase {
54
+ event: "part";
55
+ messageId: string;
56
+ seq: number;
57
+ part: ConversationMessagePart;
58
+ }
59
+
60
+ /** Live-only progressive re-emit of an in-flight text block: the block's
61
+ * FULL text grown so far, at the same `seq` its final `part` event will
62
+ * use. Never persisted, never carries an SSE id. */
63
+ export interface ConversationPartPartialEvent extends ConversationStreamAuthor {
64
+ event: "part_partial";
65
+ conversationId: string;
66
+ messageId: string;
67
+ seq: number;
68
+ at: number;
69
+ part: ConversationMessagePart;
70
+ partial: true;
71
+ }
72
+
73
+ /** The message's turn completed — the persisted row is authoritative from
74
+ * here on. */
75
+ export interface ConversationMessageDoneEvent extends DurableEventBase {
76
+ event: "message_done";
77
+ messageId: string;
78
+ seq: number;
79
+ }
80
+
81
+ /** A turn error (durable, message-scoped: carries `part` and/or `error`) —
82
+ * or a stream-level rejection (`messageId` absent, `error` only: the
83
+ * conversation is gone or access was revoked; the server ends the stream). */
84
+ export interface ConversationErrorEvent extends ConversationStreamAuthor {
85
+ event: "error";
86
+ conversationId?: string;
87
+ id?: number;
88
+ messageId?: string | null;
89
+ seq?: number;
90
+ at?: number;
91
+ part?: ConversationMessagePart;
92
+ error?: string;
93
+ }
94
+
95
+ /** Whole-map replacement of one message's reactions — replays and
96
+ * out-of-order arrivals are harmless (last write wins). */
97
+ export interface ConversationReactionEvent extends DurableEventBase {
98
+ event: "reaction";
99
+ messageId: string;
100
+ seq: number;
101
+ reactions: Record<string, string[]>;
102
+ }
103
+
104
+ /** A message was deleted. `tombstoned: true` = a thread root that kept its
105
+ * shell; false/absent = hard-deleted. Durable either way (hard deletes
106
+ * persist a null-FK marker row), so it replays on reconnect. */
107
+ export interface ConversationMessageDeletedEvent extends DurableEventBase {
108
+ event: "message_deleted";
109
+ messageId: string;
110
+ seq: number;
111
+ tombstoned?: boolean;
112
+ }
113
+
114
+ /** The bounded replay batch filled before reaching the live head — the
115
+ * server ends the stream after this. Reconnect immediately with
116
+ * `Last-Event-ID: nextAfterSeq` (no backoff). */
117
+ export interface ConversationReplayContinueEvent {
118
+ event: "replay.continue";
119
+ nextAfterSeq: number;
120
+ }
121
+
122
+ /** Live-only turn-machine state (ADR-0037 §6): `running` = a claim is held
123
+ * and a turn is executing; `queued` = a send coalesced behind the in-flight
124
+ * turn (owed work — answered by its release loop); `idle` = nothing owed. */
125
+ export interface ConversationTurnStateEvent {
126
+ event: "turn_state";
127
+ conversationId: string;
128
+ messageId: null;
129
+ state: "running" | "queued" | "idle";
130
+ pendingCount: number;
131
+ at: number;
132
+ partial: true;
133
+ }
134
+
135
+ /** One attached surface (a dashboard tab, an `agentc session` TUI process)
136
+ * as carried on a `presence` beat. Clients key the roster on `clientId` —
137
+ * the same user attached from two surfaces is two entries — and expire an
138
+ * entry after the server-declared TTL (`ConversationPresenceSnapshot.ttlMs`). */
139
+ export interface ConversationPresenceClient {
140
+ clientId: string;
141
+ userId: string | null;
142
+ /** The attaching user's display name (null for key callers with no
143
+ * user binding). */
144
+ name: string | null;
145
+ surface: "dashboard" | "terminal";
146
+ }
147
+
148
+ /** The conversation agent's executor substrate + liveness, derived fresh
149
+ * server-side at emit time: `platform` and `cloud` executors are
150
+ * server-resident (always online); `local` is online iff the bridge
151
+ * daemon heartbeated within the server's TTL. Null = no agent (a dm). */
152
+ export interface ConversationAgentPresence {
153
+ executor: "platform" | "local" | "cloud";
154
+ online: boolean;
155
+ /** The daemon's machine label (local executor only). */
156
+ machineName: string | null;
157
+ }
158
+
159
+ /** Live-only presence beat (ADR-0037 §6, Phase 6): ONE attached client's
160
+ * heartbeat + a fresh agent-liveness snapshot. There is deliberately no
161
+ * server-side roster — every subscriber assembles it from beats (keyed by
162
+ * `clientId`, TTL-expired), the same stateless discipline as the bridge
163
+ * daemon's DB heartbeat. Never persisted, never carries an SSE id, never
164
+ * advances `Last-Event-ID`. */
165
+ export interface ConversationPresenceEvent {
166
+ event: "presence";
167
+ conversationId: string;
168
+ at: number;
169
+ partial: true;
170
+ client: ConversationPresenceClient;
171
+ agent: ConversationAgentPresence | null;
172
+ }
173
+
174
+ /** Live-only rail-status frame on a CHANNEL's stream (ADR-0057 Seam 5):
175
+ * one ATTACHED session flipped running/idle. `conversationId` is the
176
+ * channel; `sessionConversationId` is the session whose turn machine
177
+ * moved. Same contract as `turn_state`: never persisted, never carries an
178
+ * SSE id, never advances `Last-Event-ID`. Durable rail fields (unread /
179
+ * awaiting / failed) refresh via the channel-sessions list, not this. */
180
+ export interface ConversationSessionStatusEvent {
181
+ event: "session_status";
182
+ conversationId: string;
183
+ sessionConversationId: string;
184
+ state: "running" | "idle";
185
+ at: number;
186
+ }
187
+
188
+ export type ConversationStreamEvent =
189
+ | ConversationPartEvent
190
+ | ConversationPartPartialEvent
191
+ | ConversationMessageDoneEvent
192
+ | ConversationErrorEvent
193
+ | ConversationReactionEvent
194
+ | ConversationMessageDeletedEvent
195
+ | ConversationReplayContinueEvent
196
+ | ConversationTurnStateEvent
197
+ | ConversationPresenceEvent
198
+ | ConversationSessionStatusEvent;
199
+
200
+ /**
201
+ * Fold one parsed SSE frame into a `ConversationStreamEvent`.
202
+ *
203
+ * Normalizes the two places the wire carries framing metadata:
204
+ * - the event name comes from the SSE `event:` field, falling back to the
205
+ * data payload's own `event` (replayed rows carry both; `replay.continue`
206
+ * carries only the SSE field);
207
+ * - `id` is set from the SSE `id:` field when present and positive —
208
+ * replayed rows carry it ONLY there, live durable notifies carry it in
209
+ * both places (equal), and live-only frames carry a `-1` sentinel inside
210
+ * the data which is stripped here. The result is one invariant: `id`
211
+ * present ⇔ durable ⇔ may advance `Last-Event-ID`.
212
+ *
213
+ * Unknown future event names flow through untyped (cast) — callers using
214
+ * the discriminated union see them via the default branch, mirroring
215
+ * `streamRunLogs`.
216
+ */
217
+ export function normalizeConversationStreamEvent(
218
+ eventName: string,
219
+ data: Record<string, unknown>,
220
+ sseId: number | undefined,
221
+ ): ConversationStreamEvent {
222
+ const name = eventName || (typeof data.event === "string" ? data.event : "message");
223
+ const dataId = typeof data.id === "number" && Number.isFinite(data.id) ? data.id : undefined;
224
+ const durableId = sseId !== undefined && Number.isFinite(sseId) && sseId > 0
225
+ ? sseId
226
+ : dataId !== undefined && dataId > 0 ? dataId : undefined;
227
+ const ev: Record<string, unknown> = { ...data, event: name };
228
+ if (durableId !== undefined) ev.id = durableId;
229
+ else delete ev.id;
230
+ return ev as unknown as ConversationStreamEvent;
231
+ }
@@ -1,9 +1,9 @@
1
1
  /** Shared execution context capabilities for workflow functions and steps. */
2
2
 
3
- import type { InvokeAndWaitOptions, RunStatus } from "../client.js";
3
+ import type { InvokeAndWaitOptions, RunStatus } from "./api-runs.js";
4
4
  import type { RequestContext } from "../request-context/request-context.js";
5
5
  import type { PauseRequest } from "../pause/pause-core.js";
6
- import type { WaitForEventRequest } from "../pause/wrappers.js";
6
+ import type { DecisionRequest, WaitForEventRequest } from "../pause/wrappers.js";
7
7
  import type { SandboxProvider } from "./sandbox.js";
8
8
 
9
9
  /** The identity of this workflow run. */
@@ -42,4 +42,12 @@ export interface BaseExecutionContext {
42
42
  sleep(durationMs: number): Promise<void>;
43
43
  /** Pause until an event resumes by `correlationKey`. Wrapper over `pause`. */
44
44
  waitForEvent<T = unknown>(req: WaitForEventRequest<T>): Promise<T>;
45
+ /**
46
+ * Pause for a typed human/agent decision (ADR-0042). The prompt, options,
47
+ * draft, and approver refs surface in the dashboard's decision UI (run
48
+ * page AND the spawning conversation's run card); resolves with the frozen
49
+ * `{ decision: string }` payload. With `approvers` named, only those team
50
+ * members can resolve it — server-enforced. Wrapper over `pause`.
51
+ */
52
+ requestDecision(req: DecisionRequest): Promise<{ decision: string }>;
45
53
  }
@@ -16,6 +16,17 @@ export interface AgentMessageText extends AgentMessageBase {
16
16
  text: string;
17
17
  }
18
18
 
19
+ /** LIVE-ONLY incremental chunk of the in-progress assistant text block
20
+ * (claude stream-json `content_block_delta`/`text_delta` under
21
+ * `--include-partial-messages`). Consumers that stream progressively
22
+ * accumulate deltas; everyone else ignores them — the terminating `text`
23
+ * message always carries the COMPLETE block and is the only durable form.
24
+ * Additive kind: existing producers never emit it. */
25
+ export interface AgentMessageTextDelta extends AgentMessageBase {
26
+ type: "text_delta";
27
+ text: string;
28
+ }
29
+
19
30
  export interface AgentMessageThinking extends AgentMessageBase {
20
31
  type: "thinking";
21
32
  text: string;
@@ -65,6 +76,26 @@ export interface AgentMessageUsage extends AgentMessageBase {
65
76
  numTurns: number;
66
77
  }
67
78
 
79
+ /** LIVE-ONLY incremental usage off the harness's raw provider stream — the
80
+ * `text_delta` of token counts. claude-code's `--include-partial-messages`
81
+ * stream events carry per-model-call usage (`message_start` /
82
+ * `message_delta`) that the terminal `usage` report only totals at turn
83
+ * end; this forwards them so a streaming consumer (the cloud executor's
84
+ * live token counter) can tick in real time. Semantics per model call
85
+ * within the turn: `boundary: "call_start"` carries the call's input-side
86
+ * finals (input + cache tokens, known at call start); `"call_delta"`
87
+ * carries the call's CUMULATIVE output tokens so far. Never durable and
88
+ * never a substitute for `usage` — the agent loop drops it exactly like
89
+ * `text_delta`. Additive kind: existing producers never emit it. */
90
+ export interface AgentMessageUsageDelta extends AgentMessageBase {
91
+ type: "usage_delta";
92
+ boundary: "call_start" | "call_delta";
93
+ inputTokens: number;
94
+ outputTokens: number;
95
+ cacheReadTokens: number;
96
+ cacheCreationTokens: number;
97
+ }
98
+
68
99
  /** Structured execution plan emitted by an agent (ACP `plan` session update,
69
100
  * WS-C / ADR-0020 Q2). Each `plan` notification REPLACES the whole plan — the
70
101
  * normaliser emits one `AgentMessagePlan` per notification carrying the entire
@@ -83,12 +114,14 @@ export interface AgentMessagePlan extends AgentMessageBase {
83
114
  export type AgentMessage =
84
115
  | AgentMessageInit
85
116
  | AgentMessageText
117
+ | AgentMessageTextDelta
86
118
  | AgentMessageThinking
87
119
  | AgentMessageToolUse
88
120
  | AgentMessageToolResult
89
121
  | AgentMessageDone
90
122
  | AgentMessageError
91
123
  | AgentMessageUsage
124
+ | AgentMessageUsageDelta
92
125
  | AgentMessagePlan;
93
126
 
94
127
  /** Status block the agent emits to signal iteration completion or blockers. */
@@ -10,9 +10,10 @@
10
10
  *
11
11
  * snapshots: { bootFrom: { snapshotId: "snap_..." } }
12
12
  *
13
- * `defineSandboxEnvironment` is sugar over `defineWorkflow` — it makes the
14
- * setup recipe read like an imperative script by supplying the local
15
- * sandbox provider (the runner's own VM) to the callback.
13
+ * `defineSandboxEnvironment` is sugar over the step builder — it wraps the
14
+ * `setup` callback as a single-step workflow, supplying the local sandbox
15
+ * provider (the runner's own VM) so the recipe reads like an imperative
16
+ * script.
16
17
  *
17
18
  * Typical flow:
18
19
  * 1. Author a setup file with `defineSandboxEnvironment`.
@@ -34,8 +35,9 @@
34
35
  * ```
35
36
  */
36
37
 
38
+ import { z } from "zod";
37
39
  import type { SandboxProvider } from "./sandbox.js";
38
- import { defineWorkflow } from "./workflow.js";
40
+ import { createStepWorkflow } from "../workflow-steps/workflow.js";
39
41
  import type { Workflow } from "../workflow-steps/types.js";
40
42
  import type { SandboxResources, SnapshotConfig } from "./workflow-metadata.js";
41
43
 
@@ -55,24 +57,41 @@ export interface SandboxEnvironmentDefinition {
55
57
  resources?: SandboxResources;
56
58
  }
57
59
 
58
- /** Sugar over `defineWorkflow` for setup-only workflows that exist to
60
+ /** Sugar over the step builder for setup-only workflows that exist to
59
61
  * capture a snapshot. The workflow takes no meaningful input and returns
60
- * nothing — its value is the side effect on the sandbox VM. */
62
+ * nothing — its value is the side effect on the sandbox VM. Both schemas
63
+ * are deliberately `z.unknown()` so no input/output contract is captured
64
+ * into template metadata (there is nothing to render). */
61
65
  export function defineSandboxEnvironment(
62
66
  env: SandboxEnvironmentDefinition,
63
67
  ): Workflow<Record<string, unknown>, void> {
64
68
  if (typeof env.setup !== "function") {
65
69
  throw new Error(`defineSandboxEnvironment(${env.name}): 'setup' must be a function`);
66
70
  }
67
- return defineWorkflow<void, Record<string, unknown>>({
71
+ const input = z.unknown() as z.ZodType<Record<string, unknown>>;
72
+ const output = z.unknown() as z.ZodType<void>;
73
+ return createStepWorkflow<Record<string, unknown>, void>({
74
+ id: env.name,
68
75
  ...(env.description !== undefined ? { description: env.description } : {}),
69
76
  ...(env.resources !== undefined ? { resources: env.resources } : {}),
77
+ input,
78
+ output,
70
79
  snapshots: env.snapshots ?? { saveLatest: true },
71
80
  // Mark this as an environment build so the server skips the /factory mount
72
81
  // for its runs — an env build builds a platform image and never touches the
73
82
  // shared drive; baking a live Archil mount into its snapshot breaks the
74
83
  // re-mount of every workflow that later boots from it (#13).
75
84
  environmentBuild: true,
76
- run: async (_ctx, sandbox) => env.setup(sandbox),
77
- });
85
+ })
86
+ .step({
87
+ name: "setup",
88
+ input,
89
+ output,
90
+ run: async (ctx) => {
91
+ const sandbox = ctx.sandbox;
92
+ if (!sandbox) throw new Error(`defineSandboxEnvironment(${env.name}): setup requires a sandbox in StepContext`);
93
+ await env.setup(sandbox);
94
+ },
95
+ })
96
+ .build();
78
97
  }
@@ -78,6 +78,37 @@ export interface SandboxSpawnDuplexOptions {
78
78
  envs?: Record<string, string>;
79
79
  }
80
80
 
81
+ /** Options for opening a brokered interactive PTY in the sandbox
82
+ * (ADR-0055 — the session Terminal surface). */
83
+ export interface SandboxPtyOpts {
84
+ cols: number;
85
+ rows: number;
86
+ /** Working directory the shell opens in. Omitted → the guest user's home. */
87
+ cwd?: string;
88
+ /** Extra env for the shell. */
89
+ envs?: Record<string, string>;
90
+ /** Raw PTY output. */
91
+ onData: (data: Uint8Array) => void;
92
+ /** Fired once when the PTY process ends — shell exit, kill, or sandbox
93
+ * fault. The server's broker closes the WebSocket off it. */
94
+ onExit?: () => void;
95
+ }
96
+
97
+ /** A live PTY inside the guest, brokered over the server's attach WebSocket. */
98
+ export interface SandboxPtyHandle {
99
+ /** OS pid inside the VM. */
100
+ pid: number;
101
+ sendInput(data: Uint8Array): Promise<void>;
102
+ resize(size: { cols: number; rows: number }): Promise<void>;
103
+ kill(): Promise<void>;
104
+ /** Detach this handle's event stream WITHOUT killing the guest process —
105
+ * the PTY keeps running (and survives a VM pause) and is re-attachable
106
+ * later via `connectPty(pid)`. The detach half of the ADR-0055 §7
107
+ * persistent-terminal rework: the server's broker disconnects on socket
108
+ * close; killing is a separate, explicit route decision. */
109
+ disconnect(): Promise<void>;
110
+ }
111
+
81
112
  /**
82
113
  * A sandbox provider implements the RAW provider operations only. It does NOT
83
114
  * implement transient-failure retry/backoff: reconnecting and snapshotting both
@@ -122,6 +153,17 @@ export interface SandboxProvider {
122
153
  };
123
154
  files: {
124
155
  write(path: string, content: string): Promise<void>;
156
+ /** Read a file's text content over the provider's FILE transport. On E2B this
157
+ * is the envd HTTP API (`Sandbox.files.read`), a DIFFERENT transport from
158
+ * `commands` — so a large readback is immune to the connect-web gRPC
159
+ * message-compression that can abort `commands.run` output on a big frame
160
+ * ("received unsupported compressed output"). On Vercel it is the HTTP file
161
+ * API (`readFileToBuffer`), independent of a faulted runCommand log stream.
162
+ * This is what lets the step recovery paths (`launchStep`'s wait,
163
+ * `invokeStep`'s foreground stream-fault recovery) backfill the full logs +
164
+ * result after a live-stream fault. OPTIONAL — implemented where durable
165
+ * file-readback is needed (E2B, Vercel, local). */
166
+ read?(path: string): Promise<string>;
125
167
  };
126
168
  kill(): Promise<void>;
127
169
  /** Capture the running sandbox's state as a reusable snapshot. Vercel and E2B
@@ -133,6 +175,15 @@ export interface SandboxProvider {
133
175
  * be omitted when the provider doesn't expose it; the server stores
134
176
  * `null` for missing values rather than estimating. */
135
177
  snapshot?(): Promise<{ snapshotId: string; sizeBytes?: number }>;
178
+ /** Resolve a public forwarding host for a port bound inside the sandbox, or
179
+ * null when the provider cannot expose ports. E2B returns its native
180
+ * `*.e2b.app` host (`sb.getHost(port)`); Vercel/local leave it undefined.
181
+ * Pure string op — no retry wrapper (unlike reconnect/snapshot). The
182
+ * returned host is the RAW capability and MUST NOT reach a browser
183
+ * (ADR-0052 §2.1); the server's member-gated preview proxy is the only
184
+ * caller. The presence of this method IS the port-exposure capability
185
+ * flag; providers without it leave it undefined (the no-shim rule). */
186
+ getHost?(port: number): string | null;
136
187
  /** Suspend the live VM in place and return a handle to resume it (ADR-0027).
137
188
  * Present ONLY on process-resume-capable providers (E2B via `sandbox.pause()`,
138
189
  * returning the sandbox id; resume is `Sandbox.connect(handle)`, which
@@ -143,6 +194,33 @@ export interface SandboxProvider {
143
194
  * flag: providers without native VM-suspend leave it undefined and fall back
144
195
  * to `snapshot()` + re-run. */
145
196
  pauseProcess?(): Promise<{ resumeHandle: string }>;
197
+ /** Reset the PROVIDER-side kill-clock: the sandbox lives at least `ms`
198
+ * more from now (shorter values SHORTEN the remaining lifetime — E2B's
199
+ * `setTimeout` replaces the deadline rather than extending it). The
200
+ * server's session lifecycle calls this on every attach-heartbeat tick
201
+ * and when it arms the deferred hot-window suspend, so its own suspend
202
+ * timer always beats the provider deadline — without it a long-attached
203
+ * terminal outlives the create-time timeout and E2B kills the VM before
204
+ * the pause can run (persistence silently lost; user-hit 2026-07-25).
205
+ * Presence-is-capability: only providers with a live timeout primitive
206
+ * (E2B `sandbox.setTimeout`) implement it; others leave it undefined and
207
+ * ride their create-time lifetime. */
208
+ extendLifetime?(ms: number): Promise<void>;
209
+ /** Open an interactive PTY in the guest (ADR-0055 — the brokered session
210
+ * terminal). Presence-is-capability, like `runBackground`: only E2B
211
+ * implements it (native `sb.pty`); Vercel/local leave it undefined and
212
+ * the server's terminal route answers 400 instead of shimming. */
213
+ createPty?(opts: SandboxPtyOpts): Promise<SandboxPtyHandle>;
214
+ /** Re-attach to a PTY that an earlier handle `disconnect()`ed from, by
215
+ * pid (ADR-0055 §7 rework — the silent-reattach half of `disconnect`).
216
+ * The process kept running (it survives socket close AND a VM
217
+ * pause/resume); connecting resumes its output stream on `opts.onData`.
218
+ * `opts.cols`/`rows`/`cwd`/`envs` describe the CALLER's viewport intent
219
+ * only — the guest process already exists, so providers apply what their
220
+ * transport accepts (E2B: onData only; the server jiggles a resize after
221
+ * connect to repaint). Presence-is-capability, E2B-only; throws when no
222
+ * PTY with that pid is running. */
223
+ connectPty?(pid: number, opts: SandboxPtyOpts): Promise<SandboxPtyHandle>;
146
224
  /** Replace the live sandbox's egress policy in place — so the server can
147
225
  * push a freshly resolved policy (with re-minted connector access tokens)
148
226
  * before each step instead of relying on the policy baked at create.
@@ -169,6 +169,24 @@ export interface SandboxResources {
169
169
  provider?: WorkflowSandboxProvider;
170
170
  }
171
171
 
172
+ /**
173
+ * What happens to a drive branch at its producer's TERMINUS — the one
174
+ * vocabulary shared by both branch-producing surfaces (workflow runs and
175
+ * agent sessions).
176
+ *
177
+ * - `"auto"` — the branch folds into `main` at the terminus.
178
+ * - `"manual"` — at the SAME terminus a merge APPROVAL is proposed instead.
179
+ * The branch is durable, so `manual` never means "silently strand the
180
+ * work": it means one click instead of zero.
181
+ *
182
+ * **Default for a workflow is `"auto"`** — absent (`mergePolicy` unset) is
183
+ * exactly what every already-registered workflow does today, so no existing
184
+ * template needs re-registering. (Sessions default the other way, `manual`,
185
+ * for the same reason: it is what they do today. The default belongs to the
186
+ * SURFACE, not to this type.)
187
+ */
188
+ export type DriveMergePolicy = "auto" | "manual";
189
+
172
190
  export interface WorkflowMetadata {
173
191
  /** One-line, human-readable description of what the workflow does.
174
192
  * Surfaced on the dashboard template tile + run page header. Authors
@@ -178,20 +196,28 @@ export interface WorkflowMetadata {
178
196
  networkPolicy?: SandboxNetworkPolicy;
179
197
  placeholders?: Record<string, string>;
180
198
  /** Input schema captured at bundle time from the workflow's
181
- * declared `input` zod schema. Step-form workflows populate this
182
- * automatically; run-form workflows (no explicit input schema —
183
- * defaults to `z.unknown()`) leave it undefined. */
199
+ * declared `input` zod schema. Undefined when the schema is
200
+ * effectively `z.unknown()` — no contract to render. */
184
201
  inputSchema?: IOSchema;
185
202
  /** Output schema captured at bundle time from the workflow's
186
- * declared `output` zod schema. Step-form workflows populate this
187
- * automatically; run-form workflows whose `run()` returns
188
- * arbitrarily-typed values leave it undefined. */
203
+ * declared `output` zod schema. Undefined when the schema is
204
+ * effectively `z.unknown()`. */
189
205
  outputSchema?: IOSchema;
190
206
  /** All snapshot config — boot source + capture mode. */
191
207
  snapshots?: SnapshotConfig;
192
208
  /** Sandbox machine resources — size + provider. Optional; omit → smallest
193
209
  * SKU on the platform-default provider (`vercel`). */
194
210
  resources?: SandboxResources;
211
+ /** What happens to this workflow's run branch at the run's terminus:
212
+ * `"auto"` folds it into `main` (success AND failure — a failed run still
213
+ * produced real outputs); `"manual"` proposes a merge approval at the same
214
+ * terminus instead. A CANCELLED run does neither under either policy.
215
+ *
216
+ * Optional + additive: an ABSENT policy means `"auto"` — today's hard-coded
217
+ * behaviour — and contributes nothing to the canonical metadata hash
218
+ * (frozen-metadata rule), so existing workflows are not forced to
219
+ * re-register. */
220
+ mergePolicy?: DriveMergePolicy;
195
221
  processors?: readonly Processor[];
196
222
  /** Connector requirements — providers whose APIs this workflow calls.
197
223
  * Dispatch resolves an authorized grant per provider and injects a
@@ -218,8 +244,8 @@ export interface WorkflowMetadata {
218
244
  }
219
245
 
220
246
  /**
221
- * Pull the server-readable declarations off a source object (run-form
222
- * `WorkflowDefinition` or step-form `StepWorkflowDefinition`) into a
247
+ * Pull the server-readable declarations off a `StepWorkflowDefinition`
248
+ * (or any source overlapping `WorkflowMetadata` in field shape) into a
223
249
  * single `WorkflowMetadata` bag. Undefined fields are omitted so the
224
250
  * canonical metadata hash (server-side) is stable across re-registers
225
251
  * that left a field unspecified.
@@ -242,6 +268,7 @@ export function extractMetadata(source: Partial<WorkflowMetadata>): WorkflowMeta
242
268
  if (source.outputSchema !== undefined) out.outputSchema = freezeMetadataValue(source.outputSchema);
243
269
  if (source.snapshots !== undefined) out.snapshots = Object.freeze({ ...source.snapshots });
244
270
  if (source.resources !== undefined) out.resources = Object.freeze({ ...source.resources });
271
+ if (source.mergePolicy !== undefined) out.mergePolicy = source.mergePolicy;
245
272
  if (source.processors !== undefined) out.processors = Object.freeze([...source.processors]);
246
273
  if (source.connectors !== undefined) out.connectors = freezeMetadataValue(source.connectors);
247
274
  if (source.connectorOperation !== undefined) out.connectorOperation = Object.freeze({ ...source.connectorOperation });
@@ -10,9 +10,20 @@
10
10
  * workflow (step name = "run"); the bundler sees the same shape regardless.
11
11
  */
12
12
 
13
+ /** One artifact a step promises to produce — mirrors `StepDeliverable`,
14
+ * restated here so the plan stays a self-contained wire shape. */
15
+ export interface WorkflowStepDeliverable {
16
+ path: string;
17
+ description?: string;
18
+ }
19
+
13
20
  export interface WorkflowStepPlan {
14
21
  index: number;
15
22
  name: string;
23
+ /** Author-declared narrative (defineStep `summary`) — optional, additive. */
24
+ summary?: string;
25
+ /** Author-declared artifacts (defineStep `deliverables`) — optional, additive. */
26
+ deliverables?: WorkflowStepDeliverable[];
16
27
  }
17
28
 
18
29
  export interface WorkflowPlan {