@agent-compose/sdk 0.7.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.
Files changed (119) 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 +32 -1
  5. package/dist/agent/run-agent.d.ts +4 -0
  6. package/dist/client.d.ts +382 -534
  7. package/dist/directives.d.ts +112 -0
  8. package/dist/display.d.ts +258 -0
  9. package/dist/errors.d.ts +24 -1
  10. package/dist/index.d.ts +26 -14
  11. package/dist/index.js +3774 -1679
  12. package/dist/pause/wrappers.d.ts +31 -9
  13. package/dist/runtimes/_acp-client.d.ts +46 -1
  14. package/dist/runtimes/_cli-agent.d.ts +51 -4
  15. package/dist/runtimes/_jsonl-guard.d.ts +103 -0
  16. package/dist/runtimes/amp.d.ts +2 -2
  17. package/dist/runtimes/claude-code.d.ts +61 -0
  18. package/dist/runtimes/claude-code.test.d.ts +14 -0
  19. package/dist/runtimes/claude.d.ts +16 -0
  20. package/dist/runtimes/claude.test.d.ts +8 -0
  21. package/dist/runtimes/codex.d.ts +12 -3
  22. package/dist/runtimes/cursor.d.ts +2 -2
  23. package/dist/runtimes/droid.d.ts +2 -2
  24. package/dist/runtimes/jsonl-guard.test.d.ts +19 -0
  25. package/dist/runtimes/openai-desktop.js +3718 -1680
  26. package/dist/runtimes/opencode.d.ts +2 -2
  27. package/dist/runtimes/vercel.js +12 -1
  28. package/dist/sandbox/devbox.d.ts +42 -0
  29. package/dist/sandbox/exec-stream.d.ts +14 -0
  30. package/dist/sandbox/network-policy.d.ts +100 -0
  31. package/dist/sandbox/provider-def.d.ts +79 -0
  32. package/dist/sandbox/providers/desktop.d.ts +10 -0
  33. package/dist/sandbox/providers/e2b.d.ts +17 -0
  34. package/dist/sandbox/providers/local.d.ts +11 -0
  35. package/dist/sandbox/providers/vercel.d.ts +18 -0
  36. package/dist/sandbox/registry.d.ts +45 -0
  37. package/dist/sandbox/sizes.d.ts +68 -0
  38. package/dist/sandbox.d.ts +24 -299
  39. package/dist/step-invocation/__tests__/foreground-recovery.test.d.ts +1 -0
  40. package/dist/step-invocation/invoker.d.ts +10 -0
  41. package/dist/step-invocation/protocol.d.ts +5 -0
  42. package/dist/types/api-compliance.d.ts +71 -0
  43. package/dist/types/api-conversations.d.ts +523 -0
  44. package/dist/types/api-factory.d.ts +334 -0
  45. package/dist/types/api-projects.d.ts +131 -0
  46. package/dist/types/api-runs.d.ts +422 -0
  47. package/dist/types/api-scopes.d.ts +102 -0
  48. package/dist/types/conversation-stream.d.ts +191 -0
  49. package/dist/types/execution-context.d.ts +12 -2
  50. package/dist/types/protocol.d.ts +38 -1
  51. package/dist/types/sandbox-environment.d.ts +8 -5
  52. package/dist/types/sandbox.d.ts +74 -4
  53. package/dist/types/workflow-metadata.d.ts +41 -8
  54. package/dist/types/workflow-plan.d.ts +10 -0
  55. package/dist/types/workflow.d.ts +18 -205
  56. package/dist/utils/bundler.d.ts +68 -1
  57. package/dist/workflow-steps/index.d.ts +1 -1
  58. package/dist/workflow-steps/observability.d.ts +8 -1
  59. package/dist/workflow-steps/runner.d.ts +3 -3
  60. package/dist/workflow-steps/step.d.ts +15 -1
  61. package/dist/workflow-steps/types.d.ts +19 -5
  62. package/dist/workflow-steps/workflow.d.ts +29 -1
  63. package/dist/workflows/engine.d.ts +3 -2
  64. package/dist/workflows/invoke-child.d.ts +20 -2
  65. package/dist/workflows/invoke-child.test.d.ts +9 -0
  66. package/package.json +2 -2
  67. package/src/agent/agent-context.ts +186 -3
  68. package/src/agent/agent-loop.ts +40 -2
  69. package/src/agent/run-agent.ts +5 -0
  70. package/src/client.ts +1048 -625
  71. package/src/directives.ts +184 -0
  72. package/src/display.ts +834 -0
  73. package/src/errors.ts +39 -0
  74. package/src/index.ts +114 -12
  75. package/src/pause/wrappers.ts +44 -9
  76. package/src/runtimes/_acp-client.ts +72 -3
  77. package/src/runtimes/_cli-agent.ts +161 -36
  78. package/src/runtimes/_jsonl-guard.ts +219 -0
  79. package/src/runtimes/claude-code.ts +256 -0
  80. package/src/runtimes/claude.ts +32 -2
  81. package/src/runtimes/codex.ts +63 -3
  82. package/src/runtimes/openai-desktop.ts +59 -14
  83. package/src/sandbox/devbox.ts +48 -0
  84. package/src/sandbox/exec-stream.ts +48 -0
  85. package/src/sandbox/network-policy.ts +181 -0
  86. package/src/sandbox/provider-def.ts +94 -0
  87. package/src/sandbox/providers/desktop.ts +57 -0
  88. package/src/sandbox/providers/e2b.ts +354 -0
  89. package/src/sandbox/providers/local.ts +106 -0
  90. package/src/sandbox/providers/vercel.ts +331 -0
  91. package/src/sandbox/registry.ts +198 -0
  92. package/src/sandbox/sizes.ts +95 -0
  93. package/src/sandbox.ts +59 -1275
  94. package/src/step-invocation/invoker.ts +151 -28
  95. package/src/step-invocation/protocol.ts +8 -0
  96. package/src/types/api-compliance.ts +79 -0
  97. package/src/types/api-conversations.ts +547 -0
  98. package/src/types/api-factory.ts +368 -0
  99. package/src/types/api-projects.ts +140 -0
  100. package/src/types/api-runs.ts +459 -0
  101. package/src/types/api-scopes.ts +102 -0
  102. package/src/types/conversation-stream.ts +231 -0
  103. package/src/types/execution-context.ts +10 -2
  104. package/src/types/protocol.ts +41 -0
  105. package/src/types/sandbox-environment.ts +28 -9
  106. package/src/types/sandbox.ts +73 -4
  107. package/src/types/workflow-metadata.ts +44 -8
  108. package/src/types/workflow-plan.ts +11 -0
  109. package/src/types/workflow.ts +25 -292
  110. package/src/utils/bundler.ts +245 -8
  111. package/src/utils/errors.ts +16 -1
  112. package/src/workflow-steps/index.ts +1 -0
  113. package/src/workflow-steps/observability.ts +19 -8
  114. package/src/workflow-steps/runner.ts +4 -4
  115. package/src/workflow-steps/step.ts +49 -1
  116. package/src/workflow-steps/types.ts +20 -5
  117. package/src/workflow-steps/workflow.ts +29 -1
  118. package/src/workflows/engine.ts +3 -2
  119. package/src/workflows/invoke-child.ts +49 -13
package/src/client.ts CHANGED
@@ -12,13 +12,112 @@
12
12
  */
13
13
 
14
14
  import { ofetch } from "ofetch";
15
- import { AgentComposeError } from "./errors.js";
15
+ import { AgentComposeError, parseRetryAfterMs } from "./errors.js";
16
16
  import { parseSseStream } from "./sse.js";
17
- import type { SandboxNetworkPolicy, SandboxSize } from "./sandbox.js";
18
17
  import type { RunEvent } from "./types/events.js";
19
- import type { WorkflowPlan } from "./types/workflow-plan.js";
20
- import type { SnapshotConfig, IOSchema, ConnectorRequirements, ConnectorOperationTag, InvokePolicy, SandboxResources } from "./types/workflow-metadata.js";
21
- import type { WorkflowManifest } from "./utils/bundler.js";
18
+ import type { ConversationStreamEvent } from "./types/conversation-stream.js";
19
+ import { normalizeConversationStreamEvent } from "./types/conversation-stream.js";
20
+ import type {
21
+ InvokeWorkflowOptions, InvokeAndWaitOptions, InvokeResult, FundingChoice, StreamRunLogsOptions,
22
+ InlineWorkflowPayload, InvokeInlineOptions, InvokeInlineAndWaitOptions,
23
+ RunStatus, ResumePauseOptions, ResumePauseResponse, AnswerSteerOptions,
24
+ RequestAgentPauseOptions, RequestAgentPauseResponse,
25
+ SendAgentMessageOptions, SendAgentMessageResponse,
26
+ RunDetail, RunListEntry, ListRunsOptions, TimelineEvent, RunFundingResponse, EventRow, ReportEventInput,
27
+ ListEventsOptions, ListEventsResult, RunArtifactRow, RunLogLine, ListRunLogsOptions,
28
+ CancelRunResponse, ListSnapshotsOptions, SnapshotListResponse, SnapshotListEntry, RunSnapshotEntry,
29
+ } from "./types/api-runs.js";
30
+ import type {
31
+ TeamMember, Mention, CreateMentionsInput,
32
+ ConversationsPage, SessionsPage, ConversationDetail, ConversationThread,
33
+ CreateCloudSessionInput, CloudSessionCreated,
34
+ SessionPreview, PreviewOpened, OpenPreviewInput, SessionForked,
35
+ SessionChangeSet, SessionMergeReport, SessionDiscardReport,
36
+ SendConversationMessageInput, SendConversationMessageResult,
37
+ ChannelSessionRow, ChannelSessionsResponse, SessionChannelMessagePosted,
38
+ ConversationPresenceSnapshot, StreamConversationOptions, AgentListRow,
39
+ } from "./types/api-conversations.js";
40
+ import type {
41
+ ConversationMemberRole, ConversationMember, ArtifactScope,
42
+ SetScopeGrantsInput, SetTemplateScopeInput,
43
+ } from "./types/api-scopes.js";
44
+ import type {
45
+ Project, ProjectsPage, ProjectRole, ProjectMember,
46
+ ProjectObjectsPage, ProjectAddPreview,
47
+ AddProjectObjectInput, AddProjectObjectResult, RefreshProjectObjectResult,
48
+ } from "./types/api-projects.js";
49
+ import type {
50
+ RegisterResult, RegisterWorkflowInput, TemplateRow, TemplateDetail, ListTemplatesOptions,
51
+ SearchFactoryFilesOptions, FactoryFileSearchResult, FactoryFileWriteResult,
52
+ FactoryRow, CreateFactoryInput, UpdateFactoryInput,
53
+ ScheduleRow, CreateScheduleInput,
54
+ SecretOptions, SetSecretResult, SecretListEntry,
55
+ CreateApiKeyInput, ApiKey, ApiKeyCreated, UsageResponse,
56
+ DriveRepoLink, CreateDriveRepoLinkInput,
57
+ DriveMountSession, CreateDriveMountSessionInput,
58
+ } from "./types/api-factory.js";
59
+ import type {
60
+ ComplianceSession, RequestComplianceSessionInput, ListComplianceSessionsOptions,
61
+ ComplianceSessionsPage, ListComplianceAccessesOptions, ComplianceAccessesPage,
62
+ } from "./types/api-compliance.js";
63
+
64
+ // The client's request/response wire types live in
65
+ // `./types/api-{runs,conversations,scopes,factory}.ts`. They are re-exported
66
+ // here so `client.js` stays the single import surface for the client and its
67
+ // shapes (index.ts re-exports from here).
68
+ export type {
69
+ RunState, InvokeWorkflowOptions, InvokeAndWaitOptions, InvokeResult, FundingChoice, StreamRunLogsOptions,
70
+ InlineWorkflowPayload, InvokeInlineOptions, InvokeInlineAndWaitOptions,
71
+ RunStatus, ResumePauseActor, ResumePauseSuccess, ResumePausePending, ResumePauseResponse,
72
+ ResumePauseOptions, RequestAgentPauseOptions, AnswerSteerOptions, RequestAgentPauseResponse,
73
+ SendAgentMessageOptions, SendAgentMessageResponse,
74
+ RunDetail, RunListEntry, ListRunsOptions, TimelineEvent,
75
+ FundingLane, RunFundingStamp, RunFundingUsageRow, RunFundingResponse,
76
+ EventSubjectType, EventRow, RunArtifactRow, ReportEventInput, ListEventsOptions, ListEventsResult,
77
+ RunLogLine, ListRunLogsOptions, CancelRunResponse,
78
+ ListSnapshotsOptions, SnapshotListEntry, SnapshotListResponse, RunSnapshotEntry,
79
+ } from "./types/api-runs.js";
80
+ export type {
81
+ TeamMember, Mention, CreateMentionsInput,
82
+ ConversationMessagePart, ConversationRow, ConversationMessageRow,
83
+ ConversationsPage, SessionsPage, ConversationDetail,
84
+ CreateCloudSessionInput, CloudSessionCreated, ConversationThread,
85
+ SessionPreview, PreviewOpened, OpenPreviewInput, SessionForked,
86
+ SessionFileChange, SessionChangeSet,
87
+ SessionMergeReportDetail, SessionMergeReport, SessionDiscardReport,
88
+ ConversationPageContext, SendConversationMessageInput, ConversationTurnState,
89
+ SendConversationMessageResult, ConversationPresenceSnapshot, AgentListRow, StreamConversationOptions,
90
+ ChannelSessionStatus, ChannelSessionRow, ChannelSessionsResponse, SessionChannelMessagePosted,
91
+ } from "./types/api-conversations.js";
92
+ export type {
93
+ ConversationMemberRole, ConversationMember,
94
+ DocumentCapability, TemplateCapability, ScopeGrant, ArtifactScope,
95
+ RunContext, SetScopeGrantsInput, SetTemplateScopeInput,
96
+ } from "./types/api-scopes.js";
97
+ export type {
98
+ Project, ProjectsPage, ProjectRole, ProjectMember,
99
+ ProjectObject, ProjectObjectsPage, ProjectSkippedFile, ProjectPreviewFile,
100
+ ProjectAddPreview, AddProjectObjectInput, AddProjectObjectResult,
101
+ RefreshProjectObjectResult,
102
+ } from "./types/api-projects.js";
103
+ export type {
104
+ RegisterResult, RegisteredRuntime, RuntimeSourceInput, TemplateSourceRef, RegisterWorkflowInput,
105
+ TemplateRow, TemplateDetail, ListTemplatesOptions,
106
+ FactoryFileSearchRow, FactoryFolderSearchRow, SearchFactoryFilesOptions, FactoryFileSearchResult, FactoryFileWriteResult,
107
+ PublicFileLinkState,
108
+ FactoryRow, CreateFactoryInput, UpdateFactoryInput,
109
+ ScheduleRow, CreateScheduleInput,
110
+ SecretOptions, SetSecretResult, SecretListEntry,
111
+ CreateApiKeyInput, ApiKey, ApiKeyCreated,
112
+ UsageRollupRow, UsageResponse,
113
+ DriveRepoLink, CreateDriveRepoLinkInput,
114
+ DriveMountSession, CreateDriveMountSessionInput,
115
+ } from "./types/api-factory.js";
116
+ export type {
117
+ ComplianceScopeKind, ComplianceStatus, ComplianceSession, ComplianceAccess,
118
+ RequestComplianceSessionInput, ListComplianceSessionsOptions, ComplianceSessionsPage,
119
+ ListComplianceAccessesOptions, ComplianceAccessesPage,
120
+ } from "./types/api-compliance.js";
22
121
 
23
122
  /** UUID-v4-ish — matches the server-side predicate. Used to auto-detect
24
123
  * that `process.env.RUN_ID` was injected by the runner sandbox (rather
@@ -47,436 +146,9 @@ function templatePath(factorySlug: string, ...rest: string[]): string {
47
146
  return `/api/v1/factories/${encodeURIComponent(factorySlug)}/templates${tail}`;
48
147
  }
49
148
 
50
- export interface RegisterResult {
51
- id: string;
52
- name: string;
53
- version: string;
54
- runtimes?: RegisteredRuntime[];
55
- }
56
-
57
- export interface RegisteredRuntime {
58
- id: string;
59
- name: string;
60
- version: string;
61
- }
62
-
63
- export interface RuntimeSourceInput {
64
- name: string;
65
- source: string;
66
- }
67
-
68
- /** GitHub provenance for a registered template's source file — stored as
69
- * `metadata.source` on the registration. `cloud-build` stamps the built
70
- * commit's sha; the dashboard's manual link path writes `sha: "manual"`. */
71
- export interface TemplateSourceRef {
72
- owner: string;
73
- repo: string;
74
- branch: string;
75
- /** Repo-relative file path, e.g. `.agentc/workflows/workflow-deploy.ts`. */
76
- path: string;
77
- /** Commit sha the version was built from, or `"manual"` for hand-links. */
78
- sha: string;
79
- }
80
-
81
- export interface RegisterWorkflowInput {
82
- name: string;
83
- source: string;
84
- /** Structured attestation produced by `bundleWorkflow`. The server
85
- * requires this on every registration; it proves the source was
86
- * bundled by a `defineWorkflow`-aware toolchain. The server validates
87
- * the manifest's shape and verifies `manifest.sourceHash` matches
88
- * sha256(source) — the source bytes themselves are never parsed or
89
- * executed on the server. */
90
- manifest: WorkflowManifest;
91
- version?: string;
92
- /** Where the source file lives on GitHub — stored as `metadata.source`.
93
- * Named `sourceRef` because `source` is the bundled code itself. */
94
- sourceRef?: TemplateSourceRef;
95
- schedule?: string;
96
- runtimes?: RuntimeSourceInput[];
97
- /** Human-readable description declared via
98
- * `defineWorkflow({ description })`. Stored in template metadata and
99
- * surfaced on the dashboard template card. */
100
- description?: string;
101
- networkPolicy?: unknown;
102
- placeholders?: Record<string, string>;
103
- /** All snapshot config — `bootFrom` (where to restore at run start),
104
- * `save`, `retain`. See `WorkflowMetadata.snapshots`. */
105
- snapshots?: SnapshotConfig;
106
- /** Sandbox machine resources — size + provider (template defaults).
107
- * See `WorkflowMetadata.resources`. */
108
- resources?: SandboxResources;
109
- /** Provider-neutral execution plan detected by the CLI bundler. */
110
- workflowPlan?: WorkflowPlan;
111
- /** Connector requirements declared via `defineWorkflow({ connectors })`
112
- * (ADR-0007). Validated against the server's provider registry at
113
- * registration; tokens are injected at the network layer at dispatch. */
114
- connectors?: ConnectorRequirements;
115
- /** Connector-catalogue operation tag — see `ConnectorOperationTag`. */
116
- connectorOperation?: ConnectorOperationTag;
117
- /** Tier-1 invoke ACL declared via `defineWorkflow({ invokePolicy })`.
118
- * Only meaningful when the workflow also declares `connectors` — the
119
- * server gates dispatch on it before binding any grant. */
120
- invokePolicy?: InvokePolicy;
121
- /** Input schema extracted from the workflow's `input` zod schema. */
122
- inputSchema?: IOSchema;
123
- /** Output schema extracted from the workflow's `output` zod schema. */
124
- outputSchema?: IOSchema;
125
- /** Set by `defineSandboxEnvironment` — marks an environment build so the
126
- * server skips the /factory mount for its runs (#13). See
127
- * `WorkflowMetadata.environmentBuild`. */
128
- environmentBuild?: boolean;
129
- /** Factory slug. Defaults to `"default"`. */
130
- factorySlug?: string;
131
- }
132
-
133
- export type RunState = "running" | "success" | "failed" | "abandoned" | "canceled";
134
-
135
- export interface InvokeWorkflowOptions {
136
- /** Per-invocation snapshot config override. `snapshots.bootFrom`
137
- * replaces the template's boot source; `snapshots.saveLatest` and
138
- * `snapshots.retainSteps` override capture mode. Anything omitted
139
- * falls back to the template's registered default. */
140
- snapshots?: SnapshotConfig;
141
- /** Per-invocation network policy override. Replaces the template-level
142
- * policy for this run only — registered metadata is not mutated. */
143
- networkPolicy?: SandboxNetworkPolicy;
144
- /** Per-invocation placeholder override. Maps secret names referenced in
145
- * `networkPolicy` ($VAR) to the values the runner should see for env
146
- * vars after brokering. Replaces the template-level placeholders for
147
- * this run only — registered metadata is not mutated. */
148
- placeholders?: Record<string, string>;
149
- /** Per-invocation machine-size override of the template's `resources.size`.
150
- * `small` (default) | `medium` | `large`; omit → the template default,
151
- * else `small`. Honoured on Vercel (→ vCPUs); E2B ignores it. */
152
- size?: SandboxSize;
153
- /** Explicit parent run id. Pass `null` to suppress ambient RUN_ID auto-detection. */
154
- parentRunId?: string | null;
155
- /** Agent loop inside the parent run that caused this invoke, when applicable. */
156
- agentId?: string | null;
157
- /** Factory slug. Defaults to `"default"`. */
158
- factorySlug?: string;
159
- /** Idempotency key — sent as the `Idempotency-Key` header. A repeat invoke
160
- * with the same key inside the server's dedup window returns the original
161
- * run instead of starting a new one (matches `resumePause`'s pattern). */
162
- idempotencyKey?: string;
163
- }
164
-
165
- export interface InvokeAndWaitOptions extends InvokeWorkflowOptions {
166
- timeoutMs?: number;
167
- pollIntervalMs?: number;
168
- }
169
-
170
- export interface InvokeResult {
171
- id: string;
172
- }
173
-
174
- export interface ListSnapshotsOptions {
175
- /** Factory slug. Defaults to `"default"`. */
176
- factorySlug?: string;
177
- workflow?: string;
178
- limit?: number;
179
- /** Cursor returned as `next_cursor` from `listSnapshotsPage`. */
180
- before?: string;
181
- }
182
-
183
- export interface TemplateRow {
184
- name: string;
185
- version: string;
186
- factorySlug: string;
187
- }
188
-
189
- export interface ListTemplatesOptions {
190
- factorySlug?: string;
191
- }
192
-
193
- /** A human member of your team — the people an agent (or you) can @-flag. */
194
- export interface TeamMember {
195
- /** Membership row id. */
196
- id: string;
197
- /** The user id — what you pass to `createMentions({ mentionedUserIds })`. */
198
- userId: string;
199
- role: string;
200
- email: string;
201
- name: string;
202
- joinedAt: string;
203
- }
204
-
205
- /** A "you were flagged" ping, persisted server-side so it reaches the
206
- * mentioned teammate in their Workbench. */
207
- export interface Mention {
208
- id: string;
209
- factoryId: string;
210
- mentionedUserId: string;
211
- /** Who flagged: 'user' | 'api_key' | 'run' | 'system'. */
212
- actorKind: string;
213
- actorId: string | null;
214
- actorLabel: string | null;
215
- /** Where it lives: 'doc' | 'comment' | 'plan' | 'run'. */
216
- contextKind: string;
217
- contextPath: string | null;
218
- /** Ready-made relative dashboard URL the Workbench card links to. */
219
- contextUrl: string | null;
220
- text: string;
221
- runId: string | null;
222
- seenAt: string | null;
223
- resolvedAt: string | null;
224
- createdAt: string;
225
- }
226
-
227
- export interface CreateMentionsInput {
228
- /** Team-member user ids to flag (1–20). Discover them via `listMembers()`.
229
- * Non-members are dropped server-side. */
230
- mentionedUserIds: string[];
231
- /** The flag message shown in the teammate's Workbench. */
232
- text: string;
233
- contextKind: "doc" | "comment" | "plan" | "run";
234
- /** Factory-relative file path or comment thread id, when applicable. */
235
- contextPath?: string;
236
- /** Ready-made relative dashboard URL the Workbench card links to (e.g.
237
- * `/factories/<slug>/files/view?path=<plan>`). */
238
- contextUrl?: string;
239
- runId?: string;
240
- factorySlug?: string;
241
- }
242
-
243
- export interface CreateFactoryInput {
244
- slug: string;
245
- name: string;
246
- description?: string;
247
- }
248
-
249
- export interface UpdateFactoryInput {
250
- name?: string;
251
- description?: string;
252
- }
253
-
254
- export interface ScheduleRow {
255
- id: string;
256
- name: string;
257
- workflowName: string;
258
- cron: string;
259
- createdAt: string;
260
- updatedAt: string;
261
- nextFireAt: string | null;
262
- lastFireAt: string | null;
263
- }
264
-
265
- export interface CreateScheduleInput {
266
- /** Human-friendly schedule name. Unique within the factory. */
267
- name: string;
268
- /** The workflow this schedule should fire. Must already be registered
269
- * in the same factory. */
270
- workflow: string;
271
- /** Cron expression (UTC). */
272
- cron: string;
273
- /** Factory to attach the schedule to. Defaults to `"default"`. */
274
- factorySlug?: string;
275
- }
276
-
277
- export interface SecretOptions {
278
- factorySlug?: string;
279
- }
280
-
281
- export interface SetSecretResult {
282
- key: string;
283
- }
284
-
285
- export interface SecretListEntry {
286
- key: string;
287
- createdAt: string;
288
- updatedAt: string;
289
- }
290
-
291
- export interface CreateApiKeyInput {
292
- name?: string;
293
- scopes?: string[];
294
- expiresAt?: string;
295
- factorySlug?: string;
296
- }
297
-
298
- export interface StreamRunLogsOptions {
299
- lastEventId?: number;
300
- signal?: AbortSignal;
301
- }
302
-
303
- export interface RunStatus<TOutput = unknown> {
304
- id: string;
305
- status: RunState;
306
- output?: TOutput;
307
- /** The run's latest (`saveLatest`) snapshot id, populated once the run has
308
- * succeeded — the boot source to fork this run's evolved filesystem from
309
- * (pass as `snapshots.bootFrom` on a follow-up invoke). `null` while the run
310
- * is still in flight or when it captured no snapshot. Lets an orchestrator
311
- * fork a child straight off the `invokeChild` result without a separate
312
- * `listRunSnapshots` call. */
313
- latestSnapshotId?: string | null;
314
- }
315
-
316
- /** ADR-0006 step 10 — actor record returned on a successful resume.
317
- * Shape mirrors the server's `PauseResumeActor` type after the row
318
- * has been stamped. Audit FKs (`userId` / `keyId` / `runId`) may be
319
- * null when the referenced row was deleted between resume and the
320
- * response render — the immutable `label` survives. */
321
- export interface ResumePauseActor {
322
- kind: "session_user" | "api_key" | "agent";
323
- /** Better Auth user id when kind='session_user'. */
324
- userId?: string | null;
325
- /** api_keys.id when kind='api_key'. */
326
- keyId?: string | null;
327
- /** Caller-run id when kind='agent' (NOT the run being resumed). */
328
- runId?: string | null;
329
- /** Agent-instance within `runId` when kind='agent'. */
330
- agentId?: string | null;
331
- label: string;
332
- }
333
-
334
- /** Success branch of the resume HTTP response. The pause has reached
335
- * a terminal state — `resolved` (workflow signal arrived), `expired`
336
- * (TTL fired first), or `cancelled` (workflow terminated mid-pause).
337
- * Only `resolved` carries the resume payload the user supplied. */
338
- export interface ResumePauseSuccess {
339
- status: "resolved" | "expired" | "cancelled";
340
- pauseId: string;
341
- resolvedAt: string | null;
342
- resumePayload: unknown;
343
- actor: ResumePauseActor | null;
344
- }
345
-
346
- /** Pending branch — the workflow accepted the signal but the row
347
- * flip didn't observe within the route's 5s wait window. The
348
- * operation is in-flight; retry with the same `Idempotency-Key`
349
- * and the cache collapses the duplicate to a single canonical
350
- * response. */
351
- export interface ResumePausePending {
352
- status: "pending";
353
- pauseId: string;
354
- timedOut: true;
355
- }
356
-
357
- export type ResumePauseResponse = ResumePauseSuccess | ResumePausePending;
358
-
359
- export interface ResumePauseOptions {
360
- /** Stripe-style retry-dedup key — same syntax as the workflow-invoke
361
- * route: 1..255 chars of `[A-Za-z0-9_\-:.]`, no embedded CR/LF.
362
- * Sent as the `Idempotency-Key` request header. (The server reads the
363
- * header first, falling back to a body field for callers behind a
364
- * header-stripping proxy; this client only sends the header.) */
365
- idempotencyKey?: string;
366
- /** Abort the HTTP request mid-wait (e.g. from a UI cancel button).
367
- * The server's LISTEN tears down via the request's AbortSignal. */
368
- signal?: AbortSignal;
369
- }
370
-
371
- export interface RequestAgentPauseOptions {
372
- /** Human-readable note surfaced to the agent as the pause reason. */
373
- reason?: string;
374
- /** Your handle for answering this pause without the minted pauseId:
375
- * pass the same value to `resumePauseByKey`. */
376
- correlationKey?: string;
377
- /** Abort the HTTP request. */
378
- signal?: AbortSignal;
379
- }
380
-
381
- export interface AnswerSteerOptions extends ResumePauseOptions {
382
- /** Who answered — surfaced to the agent as `[human steer from <actor>]`. */
383
- actor?: string;
384
- }
385
-
386
- /** 202 envelope from a steer-pause request. The request is best-effort and
387
- * fire-and-forget: it publishes a transient control message and returns
388
- * immediately — the agent parks at its next iteration boundary (if it is
389
- * `mode: "hitl"` and still running). Answer it via `answerSteerByKey`
390
- * (pass the `correlationKey` you set here) or `answerSteer` (by pauseId). */
391
- export interface RequestAgentPauseResponse {
392
- status: "steer_requested";
393
- runId: string;
394
- agentId: string;
395
- }
396
-
397
- export interface SendAgentMessageOptions {
398
- /** Transcript attribution. For non-session callers (API key, orchestrator)
399
- * this names the sender; defaults to the caller's identity server-side. */
400
- senderName?: string;
401
- /** Abort the HTTP request. */
402
- signal?: AbortSignal;
403
- }
404
-
405
- /** 202 envelope from a message-to-running-agent request. The message is queued
406
- * durably and delivered mid-stream as the agent's next user turn — the workflow
407
- * is NOT paused. `seq` is the message's per-run ordinal. */
408
- export interface SendAgentMessageResponse {
409
- status: "message_enqueued";
410
- runId: string;
411
- agentId: string;
412
- seq: number;
413
- }
414
-
415
- export interface RunDetail<TOutput = unknown> {
416
- runId: string;
417
- title: string;
418
- metadata: Record<string, unknown>;
419
- outcome: string;
420
- startedAt: string;
421
- endedAt: string | null;
422
- durationMs: number | null;
423
- failureReason: string | null;
424
- input: unknown;
425
- output: TOutput | null;
426
- lifecycleEvents: Array<{ at: string; type: string; payload: unknown }>;
427
- children?: RunDetail[];
428
- }
429
-
430
- export interface TimelineEvent {
431
- kind: string;
432
- at: string;
433
- seq: number;
434
- type: string;
435
- payload: Record<string, unknown>;
436
- }
437
-
438
- export type EventSubjectType = "run" | "agent" | "workflow" | "factory";
439
-
440
- export interface EventRow {
441
- id: string;
442
- teamId: string;
443
- subjectType: EventSubjectType | string;
444
- subjectId: string;
445
- runId: string | null;
446
- agentId: string | null;
447
- workflowId: string | null;
448
- factoryId: string | null;
449
- name: string;
450
- body: unknown;
451
- summary: string | null;
452
- confidence: string | null;
453
- timestamp: string;
454
- attributes: Record<string, unknown>;
455
- propagate: boolean;
456
- idempotencyKey: string | null;
457
- createdAt: string;
458
- }
459
-
460
- // The server emits these rows in snake_case; the client maps them to
461
- // camelCase at the fetch boundary so the SDK surface stays uniform
462
- // (`EventRow.createdAt`, `RunStatus.latestSnapshotId`, …).
463
- export interface RunArtifactRow {
464
- path: string;
465
- factorySlug: string | null;
466
- sizeBytes: number | null;
467
- lastWriteAt: string;
468
- /** Opening text of the file (≤320 chars) — null for binary/empty. */
469
- preview: string | null;
470
- }
471
-
472
- export interface FactoryFileWriteResult {
473
- path: string;
474
- contentHash: string;
475
- sizeBytes: number;
476
- created: boolean;
477
- }
478
-
479
- /** Wire shapes — what the server actually emits (snake_case). */
149
+ /** Wire shapes — what the server actually emits (snake_case); the client
150
+ * maps them to camelCase at the fetch boundary so the SDK surface stays
151
+ * uniform (`RunArtifactRow.sizeBytes`, `FactoryFileWriteResult.contentHash`). */
480
152
  interface RunArtifactWire {
481
153
  path: string;
482
154
  factory_slug: string | null;
@@ -492,141 +164,6 @@ interface FactoryFileWriteWire {
492
164
  created: boolean;
493
165
  }
494
166
 
495
- export interface ReportEventInput {
496
- name: string;
497
- body: unknown;
498
- summary?: string;
499
- confidence?: number;
500
- timestamp?: string | Date;
501
- attributes?: Record<string, unknown>;
502
- propagate?: boolean;
503
- idempotencyKey?: string;
504
- }
505
-
506
- export interface ListEventsOptions {
507
- factorySlug?: string;
508
- limit?: number;
509
- /** Case-insensitive substring match. Server uses `ILIKE %name%`, so
510
- * `"site"` matches `site.created`, `site.failed`, etc. Pass the
511
- * full event name for an effectively-exact filter (any string is a
512
- * substring of itself). */
513
- name?: string;
514
- /** Date-range lower bound: only include events at or after this
515
- * timestamp. Used by the dashboard's range toggle (1h / 24h / 7d
516
- * / 30d). Same semantics as `from` on the runs list endpoint. */
517
- from?: string;
518
- /** Timestamp cursor for "load older" pagination. Pass the
519
- * `timestamp` of the last row from the previous page; the server
520
- * returns rows strictly older than that. Distinct from `from`:
521
- * `from` filters a date range, `before` walks the page boundary.
522
- * Both can be supplied together for a paginated range-query. */
523
- before?: string;
524
- }
525
-
526
- export interface ListEventsResult {
527
- events: EventRow[];
528
- has_more: boolean;
529
- }
530
-
531
- /** One captured stdout or stderr line from a run's sandbox subprocess.
532
- * `id` is per-run monotonically increasing — pass the highest `id`
533
- * you've seen as `afterId` to paginate forward. */
534
- export interface RunLogLine {
535
- id: number;
536
- at: string;
537
- stream: "stdout" | "stderr";
538
- line: string;
539
- }
540
-
541
- export interface ListRunLogsOptions {
542
- /** Return only lines with `id > afterId` (forward pagination). */
543
- afterId?: number;
544
- /** Max lines (server clamps to [1, 1000], defaults to 200). */
545
- limit?: number;
546
- /** Stream direction. `"asc"` (default) returns the oldest lines first
547
- * — use with `afterId` to paginate forward. `"desc"` returns the newest
548
- * lines first — use to fetch the last N lines of a finished run. */
549
- direction?: "asc" | "desc";
550
- }
551
-
552
- /** Row shape returned by `GET /api-keys`. */
553
- export interface ApiKey {
554
- object: "api_key";
555
- id: string;
556
- name: string | null;
557
- last4: string | null;
558
- scopes: string[];
559
- teamId: string;
560
- createdByUserId: string | null;
561
- /** Non-null when the key is restricted to a single factory. */
562
- factoryId: string | null;
563
- createdAt: string;
564
- expiresAt: string | null;
565
- lastUsedAt: string | null;
566
- revokedAt: string | null;
567
- }
568
-
569
- /** Response from `POST /api-keys`. The `key` field is the plaintext token —
570
- * shown once at creation, never retrievable again. */
571
- export interface ApiKeyCreated extends ApiKey { key: string }
572
-
573
- /** Single rollup row from `GET /api/v1/usage`. */
574
- export interface UsageRollupRow {
575
- eventType: string;
576
- unit: string;
577
- total: number;
578
- tags: Record<string, unknown>;
579
- }
580
-
581
- /** Response from `GET /api/v1/usage`. */
582
- export interface UsageResponse {
583
- object: "list";
584
- data: UsageRollupRow[];
585
- has_more: boolean;
586
- from: string | null;
587
- to: string | null;
588
- }
589
-
590
- /** Response from `POST /api/v1/workflows/:id/cancel`. The endpoint is
591
- * idempotent: cancelling a run that's already terminal returns its current
592
- * outcome verbatim (rather than throwing or pretending it just canceled),
593
- * so `status` widens to every terminal value the server might surface. */
594
- export interface CancelRunResponse {
595
- runId: string;
596
- status: "canceled" | "success" | "failed" | "abandoned";
597
- canceledAt: string;
598
- }
599
-
600
- export interface SnapshotListEntry {
601
- snapshotId: string;
602
- kind: "latest" | "step";
603
- stepIndex: number | null;
604
- runId: string;
605
- workflow: string | null;
606
- version: string | null;
607
- createdAt: string | null;
608
- /** Provider-reported on-disk size, or null when unavailable. */
609
- sizeBytes: number | null;
610
- }
611
-
612
- export interface SnapshotListResponse {
613
- object: "list";
614
- data: SnapshotListEntry[];
615
- has_more: boolean;
616
- next_cursor: string | null;
617
- }
618
-
619
- export interface RunSnapshotEntry {
620
- snapshotId: string;
621
- /** `"latest"` = current/last pointer on the run.
622
- * `"step"` = retained per-step snapshot. */
623
- kind: "latest" | "step";
624
- stepIndex: number | null;
625
- createdAt: string | null;
626
- /** Provider-reported on-disk size, or null when unavailable. */
627
- sizeBytes: number | null;
628
- }
629
-
630
167
  export interface AgentComposeClientOptions {
631
168
  /** Your team's API key — minted from the dashboard or `agentc keys create`.
632
169
  * Required. Resolved from `process.env.AGENT_COMPOSE_API_KEY` when omitted. */
@@ -675,12 +212,50 @@ export class AgentComposeClient {
675
212
  baseURL: this.baseUrl,
676
213
  headers: { Authorization: `Bearer ${apiKey}` },
677
214
  async onResponseError({ response }) {
678
- const body = response._data as { error?: string } | undefined;
679
- throw new AgentComposeError(response.status, body?.error ?? response.statusText);
215
+ const body = response._data as { error?: string; code?: string; capability?: string } | undefined;
216
+ throw new AgentComposeError(response.status, body?.error ?? response.statusText, {
217
+ retryAfterMs: parseRetryAfterMs(response.headers.get("retry-after")),
218
+ ...(body?.code ? { code: body.code } : {}),
219
+ ...(body?.capability ? { capability: body.capability } : {}),
220
+ });
680
221
  },
681
222
  });
682
223
  }
683
224
 
225
+ /** Open an authenticated SSE stream and yield its parsed frames. Uses raw
226
+ * `fetch` (not ofetch) because SSE requires access to the response's
227
+ * `ReadableStream`, which ofetch consumes when parsing. Auth + base URL
228
+ * come from the same constructor inputs, and non-2xx responses throw the
229
+ * same `AgentComposeError`. Shared by `streamRunLogs` / `streamConversation`
230
+ * — per-event mapping stays at each call site. */
231
+ private async *openSseStream(
232
+ path: string,
233
+ opts?: { lastEventId?: number; signal?: AbortSignal },
234
+ ): AsyncGenerator<{ id: number; event: string; data: Record<string, unknown> }> {
235
+ const headers: Record<string, string> = {
236
+ Authorization: `Bearer ${this.apiKey}`,
237
+ Accept: "text/event-stream",
238
+ };
239
+ if (opts?.lastEventId && opts.lastEventId > 0) {
240
+ headers["Last-Event-ID"] = String(opts.lastEventId);
241
+ }
242
+ const res = await fetch(`${this.baseUrl}${path}`, {
243
+ headers,
244
+ ...(opts?.signal ? { signal: opts.signal } : {}),
245
+ });
246
+ if (!res.ok || !res.body) {
247
+ let message = res.statusText;
248
+ try {
249
+ const body = await res.json() as { error?: string };
250
+ if (body.error) message = body.error;
251
+ } catch { /* non-JSON error body — fall back to statusText */ }
252
+ throw new AgentComposeError(res.status, message, {
253
+ retryAfterMs: parseRetryAfterMs(res.headers.get("retry-after")),
254
+ });
255
+ }
256
+ yield* parseSseStream(res.body);
257
+ }
258
+
684
259
  /** Register (or update) a workflow template inside a factory. Defaults to
685
260
  * the team's `default` factory when `factorySlug` is omitted. */
686
261
  register(payload: RegisterWorkflowInput): Promise<RegisterResult> {
@@ -725,6 +300,45 @@ export class AgentComposeClient {
725
300
  ...(opts?.size !== undefined ? { size: opts.size } : {}),
726
301
  ...(parentRunId ? { parentRunId } : {}),
727
302
  ...(opts?.agentId ? { agentId: opts.agentId } : {}),
303
+ ...(opts?.funding !== undefined ? { funding: opts.funding } : {}),
304
+ ...(opts?.fundingSecret !== undefined ? { fundingSecret: opts.fundingSecret } : {}),
305
+ },
306
+ });
307
+ }
308
+
309
+ /** Invoke an INLINE workflow — the exact payload `bundleWorkflow` produced
310
+ * plus a `name` — WITHOUT registering it. The server validates it through
311
+ * the same core as registration (manifest required + bound to the source
312
+ * bytes) but writes no registry row: the run snapshots the source it
313
+ * executes, and registration stays the door for named/versioned/scheduled
314
+ * workflows. Same parent-child auto-detection and `Idempotency-Key`
315
+ * semantics as `invoke()`. Requires the `invoke` scope. */
316
+ invokeInline(
317
+ workflow: InlineWorkflowPayload,
318
+ input?: Record<string, unknown>,
319
+ opts?: InvokeInlineOptions,
320
+ ): Promise<InvokeResult> {
321
+ const parentRunId = opts?.parentRunId === undefined
322
+ ? detectAmbientParentRunId()
323
+ : opts.parentRunId;
324
+ const factorySlug = opts?.factorySlug ?? DEFAULT_FACTORY;
325
+ const headers: Record<string, string> = {};
326
+ if (opts?.idempotencyKey) headers["Idempotency-Key"] = opts.idempotencyKey;
327
+ return this.fetch(`/api/v1/factories/${encodeURIComponent(factorySlug)}/invoke`, {
328
+ method: "POST",
329
+ ...(opts?.idempotencyKey ? { headers } : {}),
330
+ body: {
331
+ workflow,
332
+ input,
333
+ ...(opts?.title !== undefined ? { title: opts.title } : {}),
334
+ ...(opts?.snapshots !== undefined ? { snapshots: opts.snapshots } : {}),
335
+ ...(opts?.networkPolicy !== undefined ? { networkPolicy: opts.networkPolicy } : {}),
336
+ ...(opts?.placeholders !== undefined ? { placeholders: opts.placeholders } : {}),
337
+ ...(opts?.size !== undefined ? { size: opts.size } : {}),
338
+ ...(parentRunId ? { parentRunId } : {}),
339
+ ...(opts?.agentId ? { agentId: opts.agentId } : {}),
340
+ ...(opts?.funding !== undefined ? { funding: opts.funding } : {}),
341
+ ...(opts?.fundingSecret !== undefined ? { fundingSecret: opts.fundingSecret } : {}),
728
342
  },
729
343
  });
730
344
  }
@@ -741,12 +355,33 @@ export class AgentComposeClient {
741
355
  input?: Record<string, unknown>,
742
356
  opts?: InvokeAndWaitOptions,
743
357
  ): Promise<RunStatus<TOutput>> {
744
- const timeoutMs = opts?.timeoutMs ?? 30 * 60 * 1000;
745
- const pollMs = opts?.pollIntervalMs ?? 1000;
746
358
  // InvokeAndWaitOptions extends InvokeWorkflowOptions, so we can forward
747
359
  // `opts` directly — invoke() picks only the fields it sends, so the
748
360
  // extra `timeoutMs` / `pollIntervalMs` never leak into the request body.
749
361
  const { id: runId } = await this.invoke(name, input, opts);
362
+ return this.waitForRun<TOutput>(runId, opts);
363
+ }
364
+
365
+ /** `invokeInline` + block until the run settles — the "call blocks →
366
+ * result returns on the same turn" contract for inline sub-workflows. */
367
+ async invokeInlineAndWait<TOutput = unknown>(
368
+ workflow: InlineWorkflowPayload,
369
+ input?: Record<string, unknown>,
370
+ opts?: InvokeInlineAndWaitOptions,
371
+ ): Promise<RunStatus<TOutput>> {
372
+ const { id: runId } = await this.invokeInline(workflow, input, opts);
373
+ return this.waitForRun<TOutput>(runId, opts);
374
+ }
375
+
376
+ /** Poll one run until it settles (success / failed / abandoned) and return
377
+ * its final status. Shared tail of `invokeAndWait` / `invokeInlineAndWait`;
378
+ * also useful to re-attach to a run you dispatched fire-and-forget. */
379
+ async waitForRun<TOutput = unknown>(
380
+ runId: string,
381
+ opts?: { timeoutMs?: number; pollIntervalMs?: number },
382
+ ): Promise<RunStatus<TOutput>> {
383
+ const timeoutMs = opts?.timeoutMs ?? 30 * 60 * 1000;
384
+ const pollMs = opts?.pollIntervalMs ?? 1000;
750
385
  const deadline = Date.now() + timeoutMs;
751
386
  while (Date.now() < deadline) {
752
387
  const status = await this.getStatus<TOutput>(runId);
@@ -758,7 +393,7 @@ export class AgentComposeClient {
758
393
  // Use AgentComposeError (not plain Error) so catch-blocks handling SDK
759
394
  // transport failures also handle timeouts uniformly. HTTP 504 is the
760
395
  // closest idiomatic status for "upstream didn't answer in time."
761
- throw new AgentComposeError(504, `invokeAndWait: run ${runId} did not settle within ${timeoutMs}ms`);
396
+ throw new AgentComposeError(504, `waitForRun: run ${runId} did not settle within ${timeoutMs}ms`);
762
397
  }
763
398
 
764
399
  /** List captured snapshots in a factory. */
@@ -979,6 +614,23 @@ export class AgentComposeClient {
979
614
  return this.fetch(`/api/v1/workflows/${encodeURIComponent(runId)}`);
980
615
  }
981
616
 
617
+ /** Recent runs in one factory, newest first by default. Bounded at the
618
+ * server (limit ≤ 200). `workflow` filters by the registered workflow
619
+ * name (substring) — `listRuns({ workflow: "report", limit: 1 })` is
620
+ * "the latest report run". */
621
+ async listRuns(opts: ListRunsOptions = {}): Promise<RunListEntry[]> {
622
+ const q = new URLSearchParams();
623
+ if (opts.workflow) q.set("workflow", opts.workflow);
624
+ if (opts.search) q.set("search", opts.search);
625
+ if (opts.outcome) q.set("outcome", opts.outcome);
626
+ if (opts.sort) q.set("sort", opts.sort);
627
+ if (opts.limit !== undefined) q.set("limit", String(opts.limit));
628
+ const slug = encodeURIComponent(opts.factorySlug ?? "default");
629
+ const body = await this.fetch<{ workflows: RunListEntry[] }>(
630
+ `/api/v1/factories/${slug}/runs${q.toString() ? `?${q}` : ""}`);
631
+ return body.workflows;
632
+ }
633
+
982
634
  /** Ordered lifecycle timeline for one run. */
983
635
  async getRunTimeline(runId: string, opts?: { limit?: number; offset?: number }): Promise<TimelineEvent[]> {
984
636
  const q = new URLSearchParams();
@@ -990,6 +642,16 @@ export class AgentComposeClient {
990
642
  return body.events;
991
643
  }
992
644
 
645
+ /** One run's funding-lane surface (ADR-0048): per-step credential stamps
646
+ * recorded at resolution, plus the gateway-attested platform dollars —
647
+ * the only usage the ledger records (non-platform lanes are attribution,
648
+ * never metered). */
649
+ async getRunFunding(runId: string): Promise<RunFundingResponse> {
650
+ return this.fetch<RunFundingResponse>(
651
+ `/api/v1/workflows/${encodeURIComponent(runId)}/funding`,
652
+ );
653
+ }
654
+
993
655
  /** Report a durable event against a run. Events are late-binding facts
994
656
  * like quality.accepted, defect.regression, or intervention.override. */
995
657
  async reportEvent(runId: string, input: ReportEventInput): Promise<EventRow> {
@@ -1024,6 +686,21 @@ export class AgentComposeClient {
1024
686
  }));
1025
687
  }
1026
688
 
689
+ /** One run artifact's bytes, resolved server-side through the DRIVE INDEX
690
+ * (never the run's gone sandbox or branch) — a listed artifact with an
691
+ * indexed path is always readable here, including after the run's branch
692
+ * is merged/retired. The path is the listing's `path`, sent as a single
693
+ * query parameter so slashes / spaces / unicode in agent-derived
694
+ * filenames survive verbatim. */
695
+ async getRunArtifactBytes(runId: string, path: string): Promise<Uint8Array> {
696
+ const q = new URLSearchParams({ path });
697
+ const buf = await this.fetch<ArrayBuffer, "arrayBuffer">(
698
+ `/api/v1/workflows/${encodeURIComponent(runId)}/artifacts/content?${q}`,
699
+ { responseType: "arrayBuffer" },
700
+ );
701
+ return new Uint8Array(buf);
702
+ }
703
+
1027
704
  // ── Factory files ──────────────────────────────────────────────────────────
1028
705
  // The factory drive: documents surfaced in the dashboard's Files tab.
1029
706
  // Writes from inside a sandbox automatically carry the run-callback token
@@ -1060,19 +737,471 @@ export class AgentComposeClient {
1060
737
  };
1061
738
  }
1062
739
 
1063
- /** Read one file's current content (or a specific revision) as text. */
1064
- async getFactoryFile(
740
+ /** Read one file's current content (or a specific revision) as RAW BYTES —
741
+ * byte-exact for any content type. This is the wire truth; `getFactoryFile`
742
+ * is a UTF-8 decode over it. Binaries (images, archives) MUST come through
743
+ * here: a text decode is lossy (every non-UTF-8 sequence collapses to
744
+ * U+FFFD and the original bytes are unrecoverable). */
745
+ async getFactoryFileBytes(
1065
746
  path: string,
1066
- opts?: { factorySlug?: string; revision?: number },
1067
- ): Promise<string> {
747
+ opts?: { factorySlug?: string; revision?: number; branch?: string },
748
+ ): Promise<Uint8Array> {
1068
749
  const factorySlug = opts?.factorySlug
1069
750
  ?? (typeof process !== "undefined" ? process.env?.AGENT_COMPOSE_FACTORY : undefined)
1070
751
  ?? DEFAULT_FACTORY;
1071
752
  const q = new URLSearchParams({ path });
1072
753
  if (opts?.revision !== undefined) q.set("revision", String(opts.revision));
1073
- return this.fetch(
754
+ // Branch view (read-only): the file AS OF a drive branch — e.g. a cloud
755
+ // session's own `session-<uuid>` branch, where its unmerged work lives.
756
+ // Mutually exclusive with `revision` (the server rejects the combination).
757
+ if (opts?.branch !== undefined) q.set("branch", opts.branch);
758
+ const buf = await this.fetch<ArrayBuffer, "arrayBuffer">(
1074
759
  `/api/v1/factories/${encodeURIComponent(factorySlug)}/files/content?${q}`,
1075
- { responseType: "text" },
760
+ { responseType: "arrayBuffer" },
761
+ );
762
+ return new Uint8Array(buf);
763
+ }
764
+
765
+ /** Read one file's current content (or a specific revision) as text
766
+ * (UTF-8). For binary files use `getFactoryFileBytes` — decoding them to
767
+ * text corrupts the bytes irreversibly. */
768
+ async getFactoryFile(
769
+ path: string,
770
+ opts?: { factorySlug?: string; revision?: number; branch?: string },
771
+ ): Promise<string> {
772
+ return new TextDecoder().decode(await this.getFactoryFileBytes(path, opts));
773
+ }
774
+
775
+ // ── User drive mounts ──────────────────────────────────────────────────────
776
+ // `agentc files mount` on a human's own machine: the server forks a user
777
+ // branch, backs it with a mount session (the review surface), and mints a
778
+ // user-principal gateway token. Human-held credentials only (sign-in
779
+ // cookie or a device-flow bridge key) — sandbox session keys are refused.
780
+
781
+ /** Create a local drive mount (or re-mint an existing one's token by
782
+ * passing its `conversationId`). */
783
+ async createDriveMountSession(
784
+ opts?: CreateDriveMountSessionInput & { factorySlug?: string },
785
+ ): Promise<DriveMountSession> {
786
+ const factorySlug = opts?.factorySlug
787
+ ?? (typeof process !== "undefined" ? process.env?.AGENT_COMPOSE_FACTORY : undefined)
788
+ ?? DEFAULT_FACTORY;
789
+ return this.fetch<DriveMountSession>(
790
+ `/api/v1/factories/${encodeURIComponent(factorySlug)}/files/mount-sessions`,
791
+ {
792
+ method: "POST",
793
+ body: {
794
+ ...(opts?.conversationId ? { conversationId: opts.conversationId } : {}),
795
+ ...(opts?.host ? { host: opts.host } : {}),
796
+ },
797
+ },
798
+ );
799
+ }
800
+
801
+ /** Release a local mount's gateway branch mount (clean unmount). The
802
+ * branch and its review card survive — this only drops the gateway's
803
+ * in-memory mount so the exclusive branch is not pinned. */
804
+ async releaseDriveMountSession(
805
+ conversationId: string,
806
+ opts?: { factorySlug?: string },
807
+ ): Promise<{ released: boolean }> {
808
+ const factorySlug = opts?.factorySlug
809
+ ?? (typeof process !== "undefined" ? process.env?.AGENT_COMPOSE_FACTORY : undefined)
810
+ ?? DEFAULT_FACTORY;
811
+ return this.fetch<{ released: boolean }>(
812
+ `/api/v1/factories/${encodeURIComponent(factorySlug)}/files/mount-sessions/${encodeURIComponent(conversationId)}/release`,
813
+ { method: "POST", body: {} },
814
+ );
815
+ }
816
+
817
+ // A mount's change set + merge/discard ride the EXISTING session-branch
818
+ // proposal surface below (`getSessionChanges` / `mergeSessionChanges` /
819
+ // `discardSessionChanges`) — the mount's `conversationId` is a session.
820
+
821
+ // ── Conversations ──────────────────────────────────────────────────────────
822
+ // The chat surfaces (channels / solo chats / sessions) the caller can
823
+ // access. Key callers see what their sender scope allows; the server is the
824
+ // sole authority — these are thin typed wrappers.
825
+
826
+ /** List conversations the caller can access, newest-activity first.
827
+ * Cursor-paginated: pass the previous page's `next_cursor`. */
828
+ listConversations(opts?: { cursor?: string }): Promise<ConversationsPage> {
829
+ const q = new URLSearchParams();
830
+ if (opts?.cursor) q.set("cursor", opts.cursor);
831
+ return this.fetch<ConversationsPage>(`/api/v1/conversations${q.toString() ? `?${q}` : ""}`);
832
+ }
833
+
834
+ /** List the sessions the caller can ACCESS in one factory (their own +
835
+ * shared/project-reachable ones), newest first (kind='session' rows are
836
+ * excluded from listConversations by design). Session or user-bound driver credential only. */
837
+ listSessions(factoryId: string, opts?: { cursor?: string }): Promise<SessionsPage> {
838
+ const q = new URLSearchParams({ factoryId });
839
+ if (opts?.cursor) q.set("cursor", opts.cursor);
840
+ return this.fetch<SessionsPage>(`/api/v1/sessions?${q}`);
841
+ }
842
+
843
+ /** One conversation with its latest page of messages. `limit` bounds the
844
+ * page (newest N) — metadata-only consumers (e.g. resolving the
845
+ * session's drive branch) pass 1 instead of pulling the full hydrate. */
846
+ getConversation(id: string, opts?: { limit?: number }): Promise<ConversationDetail> {
847
+ const q = opts?.limit !== undefined ? `?limit=${opts.limit}` : "";
848
+ return this.fetch<ConversationDetail>(`/api/v1/conversations/${encodeURIComponent(id)}${q}`);
849
+ }
850
+
851
+ /** Provision a CLOUD-native session (ADR-0037 Phase 3b / ADR-0055 §9): a
852
+ * persistent server-side sandbox on the factory drive that keeps working
853
+ * after the caller's terminal (and laptop) closes. The session's TYPE is
854
+ * fixed at creation: `chat` hosts a coding runtime driven via ACP (open →
855
+ * the transcript, drive it like any conversation); `terminal`/`custom`
856
+ * are raw machine surfaces with no chat pane (open → the PTY). Omitting
857
+ * `type` lets the server derive it from `runtime` (the older-caller
858
+ * grandfather); send it explicitly. Requires the `invoke` scope and a
859
+ * user-bound credential. */
860
+ createCloudSession(input: CreateCloudSessionInput): Promise<CloudSessionCreated> {
861
+ return this.fetch<CloudSessionCreated>(`/api/v1/sessions/cloud`, {
862
+ method: "POST",
863
+ body: {
864
+ ...(input.type ? { type: input.type } : {}),
865
+ ...(input.runtime ? { runtime: input.runtime } : {}),
866
+ factoryId: input.factoryId,
867
+ ...(input.presetId ? { presetId: input.presetId } : {}),
868
+ ...(input.preStart ? { preStart: input.preStart } : {}),
869
+ ...(input.title ? { title: input.title } : {}),
870
+ ...(input.repoUrl ? { repoUrl: input.repoUrl } : {}),
871
+ ...(input.repoBranch ? { repoBranch: input.repoBranch } : {}),
872
+ ...(input.channelId ? { channelId: input.channelId } : {}),
873
+ ...(input.templateId ? { templateId: input.templateId } : {}),
874
+ ...(input.sandboxSize ? { sandboxSize: input.sandboxSize } : {}),
875
+ ...(input.networkPolicy !== undefined ? { networkPolicy: input.networkPolicy } : {}),
876
+ ...(input.connectorProfileId !== undefined ? { connectorProfileId: input.connectorProfileId } : {}),
877
+ ...(input.setupCommand !== undefined ? { setupCommand: input.setupCommand } : {}),
878
+ ...(input.handoffOrigin !== undefined ? { handoffOrigin: input.handoffOrigin } : {}),
879
+ ...(input.seedFiles !== undefined ? { seedFiles: input.seedFiles } : {}),
880
+ },
881
+ });
882
+ }
883
+
884
+ /** One thread (root + replies, oldest→newest) inside a channel. */
885
+ getConversationThread(conversationId: string, rootId: string): Promise<ConversationThread> {
886
+ return this.fetch<ConversationThread>(
887
+ `/api/v1/conversations/${encodeURIComponent(conversationId)}/threads/${encodeURIComponent(rootId)}`,
888
+ );
889
+ }
890
+
891
+ // ── Channel-attached sessions (ADR-0057 — sessions VISIT channels) ────────
892
+ // A session is brought into a channel for as long as a piece of work
893
+ // lasts: attaching shares it with the channel's members (derived access,
894
+ // revoked on detach); the session posts back attributed as itself.
895
+
896
+ /** The sessions attached to a channel, newest attach first, each with its
897
+ * durable rail status (channel read gate). 404 on non-channels. */
898
+ async listChannelSessions(channelId: string): Promise<ChannelSessionRow[]> {
899
+ const body = await this.fetch<ChannelSessionsResponse>(
900
+ `/api/v1/conversations/${encodeURIComponent(channelId)}/sessions`,
901
+ );
902
+ return body.sessions;
903
+ }
904
+
905
+ /** Attach an existing session to a channel — an explicit share act
906
+ * (ADR-0052 consent): only the session's CREATOR may attach it, with
907
+ * channel role ≥ write and a human credential. Idempotent (re-attach is
908
+ * a no-op). Failures: 403 `human_credential_required` | `role_read_only`
909
+ * | `owner_required`; 400 `not_a_session`; 409 `attach_limit_reached`. */
910
+ async attachChannelSession(channelId: string, sessionConversationId: string): Promise<void> {
911
+ await this.fetch(
912
+ `/api/v1/conversations/${encodeURIComponent(channelId)}/sessions`,
913
+ { method: "POST", body: { sessionConversationId } },
914
+ );
915
+ }
916
+
917
+ /** Detach a session from a channel — either side ends the visit (channel
918
+ * role ≥ write, OR the session's creator). Forward-only: the channel's
919
+ * members lose the derived access; relay rows already in the session
920
+ * transcript stay; explicit shares survive. 404 when not attached. */
921
+ async detachChannelSession(channelId: string, sessionConversationId: string): Promise<void> {
922
+ await this.fetch(
923
+ `/api/v1/conversations/${encodeURIComponent(channelId)}/sessions/${encodeURIComponent(sessionConversationId)}`,
924
+ { method: "DELETE" },
925
+ );
926
+ }
927
+
928
+ /** Post one message into a channel AS the calling session (ADR-0057 Seam
929
+ * 4) — progress, results, questions, attributed to the session (title +
930
+ * runtime mark). Session toolbelt credential ONLY (the calling session
931
+ * resolves from the key server-side — 403 `session_credential_required`
932
+ * otherwise); the session must be attached (409 `not_attached`). By
933
+ * default the post lands in the thread of the message that last
934
+ * addressed the session from that channel (the room when none);
935
+ * `threadRootId` overrides it — must be a root message in the channel
936
+ * (400 `invalid_thread_root`). The post never triggers any turn. */
937
+ postSessionChannelMessage(
938
+ channelId: string,
939
+ input: { text: string; threadRootId?: string },
940
+ ): Promise<SessionChannelMessagePosted> {
941
+ return this.fetch<SessionChannelMessagePosted>(
942
+ `/api/v1/conversations/${encodeURIComponent(channelId)}/session-messages`,
943
+ { method: "POST", body: { text: input.text, ...(input.threadRootId ? { threadRootId: input.threadRootId } : {}) } },
944
+ );
945
+ }
946
+
947
+ // ── Cloud-session developer surface (ADR-0052) ────────────────────────────
948
+
949
+ /** Open a dev preview on a cloud session: expose a port a dev server is
950
+ * LISTENING on inside the session sandbox at a member-gated proxy URL, and
951
+ * post an "Open preview" card to the transcript. Resumes a suspended VM
952
+ * (write-tier act — read members get 403). Returns the row + the proxy URL
953
+ * (never the raw sandbox host). */
954
+ openPreview(conversationId: string, input: OpenPreviewInput): Promise<PreviewOpened> {
955
+ return this.fetch<PreviewOpened>(
956
+ `/api/v1/conversations/${encodeURIComponent(conversationId)}/preview`,
957
+ {
958
+ method: "POST",
959
+ body: {
960
+ port: input.port,
961
+ ...(input.name ? { name: input.name } : {}),
962
+ ...(input.path ? { path: input.path } : {}),
963
+ },
964
+ },
965
+ );
966
+ }
967
+
968
+ /** The live dev previews on a cloud session (read-tier allowed). Rows carry
969
+ * `url: null` — a URL is minted on OPEN (short-lived, member-bound). */
970
+ async listPreviews(conversationId: string): Promise<SessionPreview[]> {
971
+ const body = await this.fetch<{ previews: SessionPreview[] }>(
972
+ `/api/v1/conversations/${encodeURIComponent(conversationId)}/previews`,
973
+ );
974
+ return body.previews;
975
+ }
976
+
977
+ /** Take a dev preview down (write-tier). Idempotent — `closed` is false when
978
+ * no active row matched. */
979
+ async closePreview(conversationId: string, port: number): Promise<boolean> {
980
+ const body = await this.fetch<{ closed: boolean }>(
981
+ `/api/v1/conversations/${encodeURIComponent(conversationId)}/preview/${port}`,
982
+ { method: "DELETE" },
983
+ );
984
+ return body.closed;
985
+ }
986
+
987
+ /** Fork a cloud session from HEAD: snapshot the VM + branch the drive + seed
988
+ * a new conversation from the transcript so far, booting from both. Returns
989
+ * the child conversation id to switch to. Write-tier; "Branch from here." */
990
+ forkSession(conversationId: string, input?: { title?: string }): Promise<SessionForked> {
991
+ return this.fetch<SessionForked>(
992
+ `/api/v1/conversations/${encodeURIComponent(conversationId)}/fork`,
993
+ { method: "POST", body: input?.title ? { title: input.title } : {} },
994
+ );
995
+ }
996
+
997
+ // ── Session branch proposals (ADR-0053) ───────────────────────────────────
998
+ // Every cloud session works on its own factory-drive branch; the whole
999
+ // branch is the unit of review, like a PR. Any member can VIEW the
1000
+ // proposal; deciding it (merge/discard) requires role ≥ write and a human
1001
+ // credential — the resident agent cannot self-approve.
1002
+
1003
+ /** The session's proposed change set: files changed on its drive branch
1004
+ * since it forked from `main` (read-tier — any conversation member).
1005
+ * `state: "unbranched"` means the session predates branching and still
1006
+ * writes `main` directly. The list reflects the branch's last flush and
1007
+ * is advisory; a merge re-verifies against fresh state. 404 = not found
1008
+ * or not a member (uniform). */
1009
+ getSessionChanges(conversationId: string): Promise<SessionChangeSet> {
1010
+ return this.fetch<SessionChangeSet>(
1011
+ `/api/v1/conversations/${encodeURIComponent(conversationId)}/changes`,
1012
+ );
1013
+ }
1014
+
1015
+ /** Approve the session's proposed changes: 3-way merge its branch into
1016
+ * `main`, then continue the session on a fresh branch off post-merge
1017
+ * `main`. Write-tier + human caller. Pass `opts.branch` (the branch you
1018
+ * reviewed) to fail 409 `branch_changed` if it moved since.
1019
+ *
1020
+ * Failures: 403 `role_read_only` (read-only member) or a plain 403 for
1021
+ * session toolbelt keys (agents cannot self-approve); 409 `turn_active`
1022
+ * (a turn is running — retry when idle) | `branch_changed`; 400
1023
+ * `no_branch` (session predates branching); 502 `quiesce_failed`
1024
+ * (nothing changed — safe to retry) | `merge_failed` (branch retained,
1025
+ * named in the body) | `rebranch_failed` (merge landed; retry safe). */
1026
+ mergeSessionChanges(
1027
+ conversationId: string,
1028
+ opts: { branch?: string } = {},
1029
+ ): Promise<SessionMergeReport> {
1030
+ return this.fetch<SessionMergeReport>(
1031
+ `/api/v1/conversations/${encodeURIComponent(conversationId)}/changes/merge`,
1032
+ { method: "POST", body: opts.branch ? { branch: opts.branch } : {} },
1033
+ );
1034
+ }
1035
+
1036
+ /** Reject the session's proposed changes: abandon its branch (retained
1037
+ * dormant, never deleted — an admin re-merge can recover a mistaken
1038
+ * discard) and continue the session on a fresh branch off `main`.
1039
+ * Write-tier + human caller; same 4xx/5xx contract as
1040
+ * `mergeSessionChanges` minus the merge-step failures. */
1041
+ discardSessionChanges(
1042
+ conversationId: string,
1043
+ opts: { branch?: string } = {},
1044
+ ): Promise<SessionDiscardReport> {
1045
+ return this.fetch<SessionDiscardReport>(
1046
+ `/api/v1/conversations/${encodeURIComponent(conversationId)}/changes/discard`,
1047
+ { method: "POST", body: opts.branch ? { branch: opts.branch } : {} },
1048
+ );
1049
+ }
1050
+
1051
+ // ── Conversation membership (ADR-0045) ────────────────────────────────────
1052
+ // Channel rosters carry roles (`owner > write > read`). Inviting is a
1053
+ // `write` action; removing and re-roling are `owner` (or team admin)
1054
+ // actions. Every mutation returns the full refreshed roster.
1055
+
1056
+ /** The conversation's member roster with roles. Solo conversations
1057
+ * return an empty roster; a dm returns the pair. */
1058
+ async getConversationMembers(conversationId: string): Promise<ConversationMember[]> {
1059
+ const body = await this.fetch<{ members: ConversationMember[] }>(
1060
+ `/api/v1/conversations/${encodeURIComponent(conversationId)}/members`,
1061
+ );
1062
+ return body.members;
1063
+ }
1064
+
1065
+ /** Invite members to a channel (requires `write` role). Per-invitee
1066
+ * `role` is `'write'` (default) or `'read'` — `'owner'` is rejected
1067
+ * with 400. Non-team userIds are dropped server-side. */
1068
+ async addConversationMembers(
1069
+ conversationId: string,
1070
+ members: Array<{ userId: string; role?: "write" | "read" }>,
1071
+ ): Promise<ConversationMember[]> {
1072
+ const body = await this.fetch<{ members: ConversationMember[] }>(
1073
+ `/api/v1/conversations/${encodeURIComponent(conversationId)}/members`,
1074
+ { method: "POST", body: { members } },
1075
+ );
1076
+ return body.members;
1077
+ }
1078
+
1079
+ /** Remove a member (conversation `owner` or team admin only).
1080
+ * Throws 400 `last_owner` when the target is the roster's last owner. */
1081
+ async removeConversationMember(conversationId: string, userId: string): Promise<ConversationMember[]> {
1082
+ const body = await this.fetch<{ members: ConversationMember[] }>(
1083
+ `/api/v1/conversations/${encodeURIComponent(conversationId)}/members/${encodeURIComponent(userId)}`,
1084
+ { method: "DELETE" },
1085
+ );
1086
+ return body.members;
1087
+ }
1088
+
1089
+ /** Change a member's role (conversation `owner` or team admin only).
1090
+ * Throws 400 `last_owner` when demoting the roster's last owner. */
1091
+ async setConversationMemberRole(
1092
+ conversationId: string,
1093
+ userId: string,
1094
+ role: ConversationMemberRole,
1095
+ ): Promise<ConversationMember[]> {
1096
+ const body = await this.fetch<{ members: ConversationMember[] }>(
1097
+ `/api/v1/conversations/${encodeURIComponent(conversationId)}/members/${encodeURIComponent(userId)}`,
1098
+ { method: "PATCH", body: { role } },
1099
+ );
1100
+ return body.members;
1101
+ }
1102
+
1103
+ /** Send one message into a conversation. The message persists and fans
1104
+ * out before this resolves; the response carries the server's
1105
+ * authoritative turn verdict (`turnState`, ADR-0037 §4) — `queued` means
1106
+ * a turn is already in flight and a coalesced follow-up will answer, not
1107
+ * an error. Requires the `invoke` scope. */
1108
+ sendConversationMessage(
1109
+ conversationId: string,
1110
+ input: SendConversationMessageInput,
1111
+ ): Promise<SendConversationMessageResult> {
1112
+ return this.fetch<SendConversationMessageResult>(
1113
+ `/api/v1/conversations/${encodeURIComponent(conversationId)}/messages`,
1114
+ {
1115
+ method: "POST",
1116
+ body: {
1117
+ text: input.text,
1118
+ ...(input.threadRootId ? { threadRootId: input.threadRootId } : {}),
1119
+ ...(input.pageContext ? { pageContext: input.pageContext } : {}),
1120
+ },
1121
+ },
1122
+ );
1123
+ }
1124
+
1125
+ /** Pre-warm a CLOUD session's sandbox (ADR-0040 §6): resumes the
1126
+ * suspended VM without running a turn, so the resume latency rides the
1127
+ * attach/focus gap instead of the first reply. Plain no-op
1128
+ * (`warming: false`) for local/platform sessions; rate-limited
1129
+ * server-side per session. Fire-and-forget by design — never block a
1130
+ * surface on it. Requires the `invoke` scope. */
1131
+ prewarmConversation(conversationId: string): Promise<{ warming: boolean; rateLimited?: boolean }> {
1132
+ return this.fetch<{ warming: boolean; rateLimited?: boolean }>(
1133
+ `/api/v1/conversations/${encodeURIComponent(conversationId)}/prewarm`,
1134
+ { method: "POST", body: {} },
1135
+ );
1136
+ }
1137
+
1138
+ /** Stop the conversation's LIVE agent turn (ADR-0037 §5). Conversation-
1139
+ * scoped, not surface-scoped: any member can cancel, whichever surface
1140
+ * started the turn. The canceled turn closes with a terminal error part
1141
+ * (it is never re-run); a message sent after it stays queued and is
1142
+ * answered next. `canceled: false` = nothing was in flight. Requires
1143
+ * the `invoke` scope. */
1144
+ cancelConversationTurn(conversationId: string): Promise<{ canceled: boolean }> {
1145
+ return this.fetch<{ canceled: boolean }>(
1146
+ `/api/v1/conversations/${encodeURIComponent(conversationId)}/cancel-turn`,
1147
+ { method: "POST", body: {} },
1148
+ );
1149
+ }
1150
+
1151
+ /** Announce this client as ATTACHED to a conversation (ADR-0037 §6) —
1152
+ * beat every ~30s while a surface shows the transcript. Each beat fans
1153
+ * out one live-only `presence` event to every stream subscriber (the
1154
+ * roster assembles client-side, keyed by `clientId`) and returns a
1155
+ * fresh agent-liveness snapshot. Member-gated like the stream; no
1156
+ * scope beyond conversation access — presence is a read-side beacon. */
1157
+ heartbeatConversationPresence(
1158
+ conversationId: string,
1159
+ input: { clientId: string; surface: "dashboard" | "terminal" },
1160
+ ): Promise<ConversationPresenceSnapshot> {
1161
+ return this.fetch<ConversationPresenceSnapshot>(
1162
+ `/api/v1/conversations/${encodeURIComponent(conversationId)}/presence`,
1163
+ { method: "POST", body: input },
1164
+ );
1165
+ }
1166
+
1167
+ /** Subscribe to a conversation's SSE stream as an async iterable of
1168
+ * typed events, with two conversation-specific rules the consumer must
1169
+ * uphold:
1170
+ *
1171
+ * - advance `lastEventId` ONLY from events that carry `id` (durable
1172
+ * rows) — never from `part_partial` / `turn_state` / `presence`,
1173
+ * which are live-only and would corrupt reconnect replay;
1174
+ * - a conversation never "completes": the generator ends on the
1175
+ * server's bounded-replay batch boundary (`replay.continue` — pass
1176
+ * its `nextAfterSeq` back as `lastEventId` and reconnect at once) or
1177
+ * its 2h stream lifetime. Callers loop with their tracked id. */
1178
+ async *streamConversation(
1179
+ conversationId: string,
1180
+ opts?: StreamConversationOptions,
1181
+ ): AsyncGenerator<ConversationStreamEvent> {
1182
+ const path = `/api/v1/conversations/${encodeURIComponent(conversationId)}/stream`;
1183
+ for await (const ev of this.openSseStream(path, opts)) {
1184
+ yield normalizeConversationStreamEvent(ev.event, ev.data, ev.id > 0 ? ev.id : undefined);
1185
+ }
1186
+ }
1187
+
1188
+ /** List the team's agents (platform operators + local bridge agents).
1189
+ * Bridge rows carry live presence (`online`) and the hosting machine's
1190
+ * label — what the terminal/dashboard presence dots render from. */
1191
+ async listAgents(): Promise<AgentListRow[]> {
1192
+ const body = await this.fetch<{ agents: AgentListRow[] }>("/api/v1/agents");
1193
+ return body.agents;
1194
+ }
1195
+
1196
+ /** Search a factory drive's live files — path substring OR full-text match
1197
+ * on extracted content. Cursor-paginated by path. */
1198
+ searchFactoryFiles(query: string, opts?: SearchFactoryFilesOptions): Promise<FactoryFileSearchResult> {
1199
+ const factorySlug = opts?.factorySlug ?? DEFAULT_FACTORY;
1200
+ const q = new URLSearchParams({ q: query });
1201
+ if (opts?.limit != null) q.set("limit", String(opts.limit));
1202
+ if (opts?.cursor) q.set("cursor", opts.cursor);
1203
+ return this.fetch<FactoryFileSearchResult>(
1204
+ `/api/v1/factories/${encodeURIComponent(factorySlug)}/files/search?${q}`,
1076
1205
  );
1077
1206
  }
1078
1207
 
@@ -1152,6 +1281,226 @@ export class AgentComposeClient {
1152
1281
  return body.templates;
1153
1282
  }
1154
1283
 
1284
+ /** One template's detail (registration metadata + share scope). Falls
1285
+ * back to the published registry for platform templates. */
1286
+ getTemplate(name: string, opts?: { factorySlug?: string }): Promise<TemplateDetail> {
1287
+ return this.fetch<TemplateDetail>(templatePath(opts?.factorySlug ?? DEFAULT_FACTORY, name));
1288
+ }
1289
+
1290
+ // ── Artifact sharing (ADR-0045) ────────────────────────────────────────────
1291
+ // One scope model for templates and documents: owner + per-principal
1292
+ // capability grants. Mutations are full-replace and owner/team-admin only
1293
+ // (403 `capability_required` with capability "share" otherwise).
1294
+
1295
+ /** Full-replace a template's share grants; optionally bind/unbind its
1296
+ * owning conversation. Capabilities: `read|write|invoke|see_runs`. */
1297
+ setTemplateScope(
1298
+ name: string,
1299
+ input: SetTemplateScopeInput,
1300
+ opts?: { factorySlug?: string },
1301
+ ): Promise<{ scope: ArtifactScope }> {
1302
+ return this.fetch<{ scope: ArtifactScope }>(
1303
+ templatePath(opts?.factorySlug ?? DEFAULT_FACTORY, name, "scope"),
1304
+ {
1305
+ method: "PUT",
1306
+ body: {
1307
+ grants: input.grants,
1308
+ ...(input.conversationId !== undefined ? { conversationId: input.conversationId } : {}),
1309
+ },
1310
+ },
1311
+ );
1312
+ }
1313
+
1314
+ /** Full-replace a drive file's share grants. Capabilities: `read|write`. */
1315
+ setFileScope(
1316
+ path: string,
1317
+ input: SetScopeGrantsInput,
1318
+ opts?: { factorySlug?: string },
1319
+ ): Promise<{ scope: ArtifactScope }> {
1320
+ const factorySlug = opts?.factorySlug ?? DEFAULT_FACTORY;
1321
+ return this.fetch<{ scope: ArtifactScope }>(
1322
+ `/api/v1/factories/${encodeURIComponent(factorySlug)}/files/scope`,
1323
+ { method: "PUT", body: { path, grants: input.grants } },
1324
+ );
1325
+ }
1326
+
1327
+ /** Share a drive file WITH a cloud session — the session becomes a grantee
1328
+ * and the file appears in its fsgw mount. Fixed capabilities: the mount is
1329
+ * concealment-only, so the session can read AND edit the file. Requires the
1330
+ * `share` capability on the file (its owner or a team admin). The target
1331
+ * must be a CLOUD session (local sessions never mount fsgw → 400
1332
+ * `cloud_session_required`). No capabilities argument — semantics are fixed
1333
+ * "open and edit". */
1334
+ async shareFileWithSession(
1335
+ factorySlug: string,
1336
+ input: { path: string; conversationId: string },
1337
+ ): Promise<void> {
1338
+ await this.fetch<{ ok: boolean }>(
1339
+ `/api/v1/factories/${encodeURIComponent(factorySlug)}/files/session-grants`,
1340
+ { method: "POST", body: { path: input.path, conversationId: input.conversationId } },
1341
+ );
1342
+ }
1343
+
1344
+ /** Revoke a file's share to a session (revocation-critical — re-conceals on
1345
+ * the session's live mount within push latency). Idempotent. */
1346
+ async unshareFileWithSession(
1347
+ factorySlug: string,
1348
+ input: { path: string; conversationId: string },
1349
+ ): Promise<void> {
1350
+ await this.fetch<{ ok: boolean }>(
1351
+ `/api/v1/factories/${encodeURIComponent(factorySlug)}/files/session-grants`,
1352
+ { method: "DELETE", body: { path: input.path, conversationId: input.conversationId } },
1353
+ );
1354
+ }
1355
+
1356
+ // ── Projects (ADR-0045) ────────────────────────────────────────────────────
1357
+ // A project is a member-gated group (roles owner > write > read) that holds
1358
+ // objects (files, conversations, sessions). membership ⊆ visibility: every
1359
+ // member sees every object. Access is DERIVED (never copied into personal
1360
+ // scopes) and revokes the instant a member or object leaves. The server is
1361
+ // the sole authority — these are thin typed wrappers.
1362
+
1363
+ /** List the projects the caller is a member of, newest-activity first.
1364
+ * Cursor-paginated. A creator who has left sees nothing (no implicit
1365
+ * creator authority) — membership is the sole gate. */
1366
+ listProjects(opts?: { cursor?: string }): Promise<ProjectsPage> {
1367
+ const q = new URLSearchParams();
1368
+ if (opts?.cursor) q.set("cursor", opts.cursor);
1369
+ return this.fetch<ProjectsPage>(`/api/v1/projects${q.toString() ? `?${q}` : ""}`);
1370
+ }
1371
+
1372
+ /** Create a project. The caller is seeded as its first `owner` member. 409
1373
+ * on a duplicate name within the team. */
1374
+ async createProject(name: string): Promise<Project> {
1375
+ const body = await this.fetch<{ project: Project }>("/api/v1/projects", {
1376
+ method: "POST",
1377
+ body: { name },
1378
+ });
1379
+ return body.project;
1380
+ }
1381
+
1382
+ /** One project (with the caller's role + member/object counts). 404 when
1383
+ * the caller is not a member. */
1384
+ async getProject(id: string): Promise<Project> {
1385
+ const body = await this.fetch<{ project: Project }>(
1386
+ `/api/v1/projects/${encodeURIComponent(id)}`,
1387
+ );
1388
+ return body.project;
1389
+ }
1390
+
1391
+ /** Rename a project (requires `write` role). */
1392
+ async renameProject(id: string, name: string): Promise<Project> {
1393
+ const body = await this.fetch<{ project: Project }>(
1394
+ `/api/v1/projects/${encodeURIComponent(id)}`,
1395
+ { method: "PATCH", body: { name } },
1396
+ );
1397
+ return body.project;
1398
+ }
1399
+
1400
+ /** Delete a project (owner or team admin). Revokes every derived grant. */
1401
+ async deleteProject(id: string): Promise<void> {
1402
+ await this.fetch<{ ok: boolean }>(`/api/v1/projects/${encodeURIComponent(id)}`, {
1403
+ method: "DELETE",
1404
+ });
1405
+ }
1406
+
1407
+ /** The project's member roster with roles (member only). */
1408
+ async getProjectMembers(id: string): Promise<ProjectMember[]> {
1409
+ const body = await this.fetch<{ members: ProjectMember[] }>(
1410
+ `/api/v1/projects/${encodeURIComponent(id)}/members`,
1411
+ );
1412
+ return body.members;
1413
+ }
1414
+
1415
+ /** Invite members (requires `write` role). Per-invitee `role` is `'write'`
1416
+ * (default) or `'read'` — `'owner'` is never grantable at invite. Non-team
1417
+ * userIds are dropped server-side; the full refreshed roster is returned. */
1418
+ async addProjectMembers(
1419
+ id: string,
1420
+ members: Array<{ userId: string; role?: "write" | "read" }>,
1421
+ ): Promise<ProjectMember[]> {
1422
+ const body = await this.fetch<{ members: ProjectMember[] }>(
1423
+ `/api/v1/projects/${encodeURIComponent(id)}/members`,
1424
+ { method: "POST", body: { members } },
1425
+ );
1426
+ return body.members;
1427
+ }
1428
+
1429
+ /** Remove a member — project owner/team admin, or self-leave. Throws 400
1430
+ * `last_owner` when the target is the roster's last owner. */
1431
+ async removeProjectMember(id: string, userId: string): Promise<ProjectMember[]> {
1432
+ const body = await this.fetch<{ members: ProjectMember[] }>(
1433
+ `/api/v1/projects/${encodeURIComponent(id)}/members/${encodeURIComponent(userId)}`,
1434
+ { method: "DELETE" },
1435
+ );
1436
+ return body.members;
1437
+ }
1438
+
1439
+ /** Change a member's role (project owner or team admin). Throws 400
1440
+ * `last_owner` when demoting the roster's last owner. */
1441
+ async setProjectMemberRole(
1442
+ id: string,
1443
+ userId: string,
1444
+ role: ProjectRole,
1445
+ ): Promise<ProjectMember[]> {
1446
+ const body = await this.fetch<{ members: ProjectMember[] }>(
1447
+ `/api/v1/projects/${encodeURIComponent(id)}/members/${encodeURIComponent(userId)}`,
1448
+ { method: "PATCH", body: { role } },
1449
+ );
1450
+ return body.members;
1451
+ }
1452
+
1453
+ /** List a project's objects (member only), newest first. Cursor-paginated. */
1454
+ listProjectObjects(id: string, opts?: { cursor?: string }): Promise<ProjectObjectsPage> {
1455
+ const q = new URLSearchParams();
1456
+ if (opts?.cursor) q.set("cursor", opts.cursor);
1457
+ return this.fetch<ProjectObjectsPage>(
1458
+ `/api/v1/projects/${encodeURIComponent(id)}/objects${q.toString() ? `?${q}` : ""}`,
1459
+ );
1460
+ }
1461
+
1462
+ /** DRY-RUN preview of a session add: the files that WOULD be shared (from
1463
+ * the caller's owned scopes) + any owned-but-skipped entries (paths
1464
+ * concealed from the caller are omitted). Powers the consent dialog. Same
1465
+ * gates as the add, but mutates nothing. */
1466
+ previewProjectObject(id: string, input: { conversationId: string }): Promise<ProjectAddPreview> {
1467
+ return this.fetch<ProjectAddPreview>(
1468
+ `/api/v1/projects/${encodeURIComponent(id)}/objects/preview`,
1469
+ { method: "POST", body: { type: "session", conversationId: input.conversationId } },
1470
+ );
1471
+ }
1472
+
1473
+ /** Add an object to a project (requires `write` role AND authority on the
1474
+ * object itself). A session add also materializes the caller-owned files in
1475
+ * the session's context, returning them alongside the session object. */
1476
+ addProjectObject(id: string, input: AddProjectObjectInput): Promise<AddProjectObjectResult> {
1477
+ return this.fetch<AddProjectObjectResult>(
1478
+ `/api/v1/projects/${encodeURIComponent(id)}/objects`,
1479
+ { method: "POST", body: input },
1480
+ );
1481
+ }
1482
+
1483
+ /** Re-materialize a session object's file context (add-only — never removes;
1484
+ * picks up files scoped/created after the original add). Session objects
1485
+ * only; caller must be the session owner and a project writer. */
1486
+ refreshProjectObject(id: string, objectId: string): Promise<RefreshProjectObjectResult> {
1487
+ return this.fetch<RefreshProjectObjectResult>(
1488
+ `/api/v1/projects/${encodeURIComponent(id)}/objects/${encodeURIComponent(objectId)}/refresh`,
1489
+ { method: "POST", body: {} },
1490
+ );
1491
+ }
1492
+
1493
+ /** Remove an object from a project (revocation-critical). Authorized for a
1494
+ * project writer, the member who added it, OR — for a file object — the
1495
+ * file's owner even without project membership (share implies unshare).
1496
+ * Unauthorized → 404. */
1497
+ async removeProjectObject(id: string, objectId: string): Promise<void> {
1498
+ await this.fetch<{ ok: boolean }>(
1499
+ `/api/v1/projects/${encodeURIComponent(id)}/objects/${encodeURIComponent(objectId)}`,
1500
+ { method: "DELETE" },
1501
+ );
1502
+ }
1503
+
1155
1504
  // ── Factories ──────────────────────────────────────────────────────────────
1156
1505
  // Projects within a team. Workflows + secrets belong to exactly one factory.
1157
1506
 
@@ -1230,13 +1579,17 @@ export class AgentComposeClient {
1230
1579
  });
1231
1580
  }
1232
1581
 
1582
+ /** Both secret tiers list the same `{ secrets }` envelope — fetch + map the
1583
+ * wire's `secretKey` to `SecretListEntry.key`. */
1584
+ private async listSecretEntries(path: string): Promise<SecretListEntry[]> {
1585
+ const body = await this.fetch<{ secrets: Array<{ secretKey: string; createdAt: string; updatedAt: string }> }>(path);
1586
+ return body.secrets.map(s => ({ key: s.secretKey, createdAt: s.createdAt, updatedAt: s.updatedAt }));
1587
+ }
1588
+
1233
1589
  /** List secret keys registered for a workflow (metadata only — values are never returned). */
1234
- async listSecrets(workflowName: string, opts?: SecretOptions): Promise<SecretListEntry[]> {
1590
+ listSecrets(workflowName: string, opts?: SecretOptions): Promise<SecretListEntry[]> {
1235
1591
  const factorySlug = opts?.factorySlug ?? DEFAULT_FACTORY;
1236
- const body = await this.fetch<{ secrets: Array<{ secretKey: string; createdAt: string; updatedAt: string }> }>(
1237
- templatePath(factorySlug, workflowName, "secrets"),
1238
- );
1239
- return body.secrets.map(s => ({ key: s.secretKey, createdAt: s.createdAt, updatedAt: s.updatedAt }));
1592
+ return this.listSecretEntries(templatePath(factorySlug, workflowName, "secrets"));
1240
1593
  }
1241
1594
 
1242
1595
  /** Delete a workflow secret. */
@@ -1245,6 +1598,37 @@ export class AgentComposeClient {
1245
1598
  return this.fetch(templatePath(factorySlug, workflowName, "secrets", key), { method: "DELETE" });
1246
1599
  }
1247
1600
 
1601
+ // ── GitHub-linked drive directories (ADR-0030 P1) ───────────────────────────
1602
+
1603
+ /** List a factory's drive⇄repo links. */
1604
+ async listRepoLinks(opts?: { factorySlug?: string }): Promise<DriveRepoLink[]> {
1605
+ const slug = opts?.factorySlug ?? DEFAULT_FACTORY;
1606
+ const body = await this.fetch<{ links: DriveRepoLink[] }>(
1607
+ `/api/v1/factories/${encodeURIComponent(slug)}/repo-links`);
1608
+ return body.links;
1609
+ }
1610
+
1611
+ /** Link a drive directory to a GitHub repo + tracked branch (`manage`
1612
+ * scope). Requires the drive to be graph-authoritative — 409 names the
1613
+ * promotion prerequisite otherwise. */
1614
+ async createRepoLink(
1615
+ input: CreateDriveRepoLinkInput, opts?: { factorySlug?: string },
1616
+ ): Promise<DriveRepoLink> {
1617
+ const slug = opts?.factorySlug ?? DEFAULT_FACTORY;
1618
+ const body = await this.fetch<{ link: DriveRepoLink }>(
1619
+ `/api/v1/factories/${encodeURIComponent(slug)}/repo-links`,
1620
+ { method: "POST", body: input });
1621
+ return body.link;
1622
+ }
1623
+
1624
+ /** Unlink (§6.3: the prefix's files and history stay on the drive). */
1625
+ deleteRepoLink(linkId: string, opts?: { factorySlug?: string }): Promise<void> {
1626
+ const slug = opts?.factorySlug ?? DEFAULT_FACTORY;
1627
+ return this.fetch(
1628
+ `/api/v1/factories/${encodeURIComponent(slug)}/repo-links/${encodeURIComponent(linkId)}`,
1629
+ { method: "DELETE" });
1630
+ }
1631
+
1248
1632
  // ── Factory-level secrets (ADR-0014) ────────────────────────────────────────
1249
1633
  // The inherited tier: a factory secret is visible to EVERY workflow in the
1250
1634
  // factory; a workflow secret of the same key overrides it. Values are stored
@@ -1260,12 +1644,9 @@ export class AgentComposeClient {
1260
1644
  }
1261
1645
 
1262
1646
  /** List factory-level secret keys (metadata only — values are never returned). */
1263
- async listFactorySecrets(opts?: SecretOptions): Promise<SecretListEntry[]> {
1647
+ listFactorySecrets(opts?: SecretOptions): Promise<SecretListEntry[]> {
1264
1648
  const factorySlug = opts?.factorySlug ?? DEFAULT_FACTORY;
1265
- const body = await this.fetch<{ secrets: Array<{ secretKey: string; createdAt: string; updatedAt: string }> }>(
1266
- `/api/v1/factories/${encodeURIComponent(factorySlug)}/secrets`,
1267
- );
1268
- return body.secrets.map(s => ({ key: s.secretKey, createdAt: s.createdAt, updatedAt: s.updatedAt }));
1649
+ return this.listSecretEntries(`/api/v1/factories/${encodeURIComponent(factorySlug)}/secrets`);
1269
1650
  }
1270
1651
 
1271
1652
  /** Delete a factory-level secret. */
@@ -1313,6 +1694,79 @@ export class AgentComposeClient {
1313
1694
  );
1314
1695
  }
1315
1696
 
1697
+ // ── Break-glass compliance sessions (ADR-0051) ───────────────────────────────
1698
+ // A team admin has no default access to a scoped document. To view one they
1699
+ // don't hold a grant on, they request a READ-ONLY, owner-approved, time-boxed
1700
+ // compliance session over a scope (a factory, or the whole team). Every verb
1701
+ // here requires an `admin`-scoped key; approval additionally requires the
1702
+ // caller be a team owner (or a fallback admin ≠ requester). Every access under
1703
+ // an active session is individually audited and the doc's owner is notified.
1704
+
1705
+ /** Request a break-glass compliance session — returns it `pending` until a
1706
+ * team owner (≠ requester) approves. `scopeRef` is the factory id for
1707
+ * factory scope; omit it for team scope. `ttlSeconds` is the session's
1708
+ * lifetime once approved (15 min floor, 7 day ceiling). */
1709
+ async requestComplianceSession(input: RequestComplianceSessionInput): Promise<ComplianceSession> {
1710
+ const body = await this.fetch<{ session: ComplianceSession }>("/api/v1/compliance/sessions", {
1711
+ method: "POST",
1712
+ body: {
1713
+ scopeKind: input.scopeKind,
1714
+ reason: input.reason,
1715
+ ttlSeconds: input.ttlSeconds,
1716
+ ...(input.scopeRef != null ? { scopeRef: input.scopeRef } : {}),
1717
+ },
1718
+ });
1719
+ return body.session;
1720
+ }
1721
+
1722
+ /** Approve a pending compliance session → active with its TTL. The caller
1723
+ * must be a team owner ≠ the requester (or, when the requester is the sole
1724
+ * owner, any admin ≠ requester). Throws 403 on self-approval or wrong role,
1725
+ * 409 when the session is no longer pending. */
1726
+ async approveComplianceSession(sessionId: string): Promise<ComplianceSession> {
1727
+ const body = await this.fetch<{ session: ComplianceSession }>(
1728
+ `/api/v1/compliance/sessions/${encodeURIComponent(sessionId)}/approve`,
1729
+ { method: "POST" },
1730
+ );
1731
+ return body.session;
1732
+ }
1733
+
1734
+ /** Revoke a pending or active compliance session — revocation always
1735
+ * tightens, so any team owner/admin may do it. */
1736
+ async revokeComplianceSession(sessionId: string): Promise<ComplianceSession> {
1737
+ const body = await this.fetch<{ session: ComplianceSession }>(
1738
+ `/api/v1/compliance/sessions/${encodeURIComponent(sessionId)}/revoke`,
1739
+ { method: "POST" },
1740
+ );
1741
+ return body.session;
1742
+ }
1743
+
1744
+ /** List the team's compliance sessions, newest first. Optional `status`
1745
+ * filter; opaque `cursor` pagination. */
1746
+ listComplianceSessions(opts?: ListComplianceSessionsOptions): Promise<ComplianceSessionsPage> {
1747
+ const q = new URLSearchParams();
1748
+ if (opts?.status) q.set("status", opts.status);
1749
+ if (opts?.limit != null) q.set("limit", String(opts.limit));
1750
+ if (opts?.cursor) q.set("cursor", opts.cursor);
1751
+ return this.fetch<ComplianceSessionsPage>(
1752
+ `/api/v1/compliance/sessions${q.toString() ? `?${q}` : ""}`,
1753
+ );
1754
+ }
1755
+
1756
+ /** The documents touched under one compliance session — the individual
1757
+ * per-document audit trail its reasoned entry authorized. */
1758
+ listComplianceAccesses(
1759
+ sessionId: string,
1760
+ opts?: ListComplianceAccessesOptions,
1761
+ ): Promise<ComplianceAccessesPage> {
1762
+ const q = new URLSearchParams();
1763
+ if (opts?.limit != null) q.set("limit", String(opts.limit));
1764
+ if (opts?.cursor) q.set("cursor", opts.cursor);
1765
+ return this.fetch<ComplianceAccessesPage>(
1766
+ `/api/v1/compliance/sessions/${encodeURIComponent(sessionId)}/accesses${q.toString() ? `?${q}` : ""}`,
1767
+ );
1768
+ }
1769
+
1316
1770
  /** Stream lifecycle events for a run as an async iterable. Yields parsed
1317
1771
  * `RunEvent` payloads in order; caller breaks on terminal events
1318
1772
  * (`run_complete`, `run_failed`, `run_canceled`).
@@ -1320,33 +1774,13 @@ export class AgentComposeClient {
1320
1774
  * `lastEventId` enables resume — pass the highest `seq` you've already
1321
1775
  * processed to receive only events you missed.
1322
1776
  *
1323
- * `event` can be used to abort the stream from the caller side.
1324
- *
1325
- * Uses raw `fetch` (not ofetch) because SSE requires access to the
1326
- * response's `ReadableStream`, which ofetch consumes when parsing. Auth
1327
- * + base URL are still sourced from the same constructor inputs, and
1328
- * non-2xx responses throw the same `AgentComposeError`. */
1777
+ * `event` can be used to abort the stream from the caller side. */
1329
1778
  async *streamRunLogs(
1330
1779
  runId: string,
1331
1780
  opts?: StreamRunLogsOptions,
1332
1781
  ): AsyncGenerator<RunEvent> {
1333
- const headers: Record<string, string> = { Authorization: `Bearer ${this.apiKey}` };
1334
- if (opts?.lastEventId && opts.lastEventId > 0) {
1335
- headers["Last-Event-ID"] = String(opts.lastEventId);
1336
- }
1337
- const res = await fetch(`${this.baseUrl}/api/v1/workflows/${encodeURIComponent(runId)}/stream`, {
1338
- headers,
1339
- ...(opts?.signal ? { signal: opts.signal } : {}),
1340
- });
1341
- if (!res.ok || !res.body) {
1342
- let message = res.statusText;
1343
- try {
1344
- const body = await res.json() as { error?: string };
1345
- if (body.error) message = body.error;
1346
- } catch { /* non-JSON error body — fall back to statusText */ }
1347
- throw new AgentComposeError(res.status, message);
1348
- }
1349
- for await (const ev of parseSseStream(res.body)) {
1782
+ const path = `/api/v1/workflows/${encodeURIComponent(runId)}/stream`;
1783
+ for await (const ev of this.openSseStream(path, opts)) {
1350
1784
  // The server's `data` payload already includes `event`, `runId`, `seq`,
1351
1785
  // `at`, and the per-event payload — so `ev.data` IS the `RunEvent`.
1352
1786
  // Cast directly; unknown future event names flow through untyped, and
@@ -1355,14 +1789,3 @@ export class AgentComposeClient {
1355
1789
  }
1356
1790
  }
1357
1791
  }
1358
-
1359
- /** A factory: a project-level grouping of workflows inside a team. */
1360
- export interface FactoryRow {
1361
- id: string;
1362
- teamId: string;
1363
- slug: string;
1364
- name: string;
1365
- description: string | null;
1366
- createdAt: string;
1367
- updatedAt: string;
1368
- }