@agent-compose/sdk 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -31,6 +31,12 @@ export interface AgentMessageToolUse extends AgentMessageBase {
31
31
  toolName: string;
32
32
  toolInput: Record<string, unknown>;
33
33
  toolUseId: string;
34
+ /** The spawning subagent call's tool_use id when this call ran INSIDE a
35
+ * subagent (claude stream-json stamps `parent_tool_use_id` on every
36
+ * sidechain event) — lets renderers nest child activity under the
37
+ * Agent/Task call instead of flattening it into the parent transcript.
38
+ * Optional and additive: producers without sidechains omit it. */
39
+ parentToolUseId?: string;
34
40
  }
35
41
  export interface AgentMessageToolResult extends AgentMessageBase {
36
42
  type: "tool_result";
@@ -54,6 +60,8 @@ export interface AgentMessageToolResult extends AgentMessageBase {
54
60
  path: string;
55
61
  line?: number;
56
62
  }[];
63
+ /** Sidechain attribution, mirroring AgentMessageToolUse.parentToolUseId. */
64
+ parentToolUseId?: string;
57
65
  }
58
66
  export interface AgentMessageDone extends AgentMessageBase {
59
67
  type: "done";
@@ -231,6 +231,14 @@ export interface WorkflowMetadata {
231
231
  * canonical metadata hash (frozen-metadata rule), so existing workflows are
232
232
  * not forced to re-register. */
233
233
  environmentBuild?: boolean;
234
+ /** Whether this workflow's runs need the factory drive. ABSENT ⇒
235
+ * `"required"`: on a drive-backed factory the server treats the /factory
236
+ * mount as load-bearing — a mount failure FAILS the run instead of
237
+ * silently proceeding drive-less. Declare `"none"` for a workflow that
238
+ * genuinely never touches /factory: the server skips the mount entirely
239
+ * for its runs (the explicit no-drive mode; there is no silent degrade).
240
+ * Optional + additive (frozen-metadata rule). */
241
+ factoryDrive?: "required" | "none";
234
242
  }
235
243
  /**
236
244
  * Pull the server-readable declarations off a `StepWorkflowDefinition`
@@ -18,6 +18,19 @@
18
18
  * parses or imports user source — it only validates the structured manifest
19
19
  * this function returns alongside the bundled bytes, and cross-checks the
20
20
  * manifest's `sourceHash` against the source it received.
21
+ *
22
+ * Module resolution carries two more layers, because most workflow source is
23
+ * now written by an AGENT and the bundler's error is the only feedback it
24
+ * gets (the Workflow Studio's agent authored `import … from "agentc/sdk"`
25
+ * and prod answered with bun's `Maybe you need to "bun install"?` — advice
26
+ * nobody could act on inside a sandbox with no package.json):
27
+ *
28
+ * - TOLERATE (`SDK_SPECIFIER_ALIASES` + `sdkAliasPlugin`) — near-miss
29
+ * spellings of `@agent-compose/sdk` resolve to the real package, so a
30
+ * draft already written with the wrong one builds unedited.
31
+ * - SELF-CORRECT (`explainBundleFailure`) — anything that still fails to
32
+ * resolve produces an error NAMING the real package, which an agent
33
+ * reading its own tool error can fix on the next turn.
21
34
  */
22
35
  import type { SnapshotConfig } from "../types/workflow.js";
23
36
  import type { IOSchema, ConnectorRequirements, ConnectorOperationTag, InvokePolicy, SandboxResources, DriveMergePolicy } from "../types/workflow-metadata.js";
@@ -108,7 +121,50 @@ export interface BundledWorkflow {
108
121
  /** Set by `defineSandboxEnvironment` — marks an environment build so the
109
122
  * server skips the /factory mount for its runs (#13). */
110
123
  environmentBuild?: boolean;
124
+ /** Drive requirement declared via `defineWorkflow({ factoryDrive })`.
125
+ * Absent ⇒ `"required"` (a failed /factory mount fails the run);
126
+ * `"none"` = explicit no-drive opt-out. */
127
+ factoryDrive?: "required" | "none";
111
128
  }
129
+ /** The one true import specifier for the platform SDK. */
130
+ export declare const SDK_PACKAGE = "@agent-compose/sdk";
131
+ /**
132
+ * Import specifiers that can only have MEANT `@agent-compose/sdk`, rewritten
133
+ * to it at bundle time so a draft written with the wrong spelling builds
134
+ * unedited.
135
+ *
136
+ * Every entry carries an `sdk` segment or suffix, so none of them can be a
137
+ * real third-party package a workflow might legitimately depend on: `a/b`
138
+ * forms are subpaths of packages that don't exist, and the `*-sdk` forms
139
+ * name this platform explicitly. Bare `agentc` / `agent-compose` are
140
+ * deliberately NOT aliased — those are plausible npm package names, and
141
+ * silently redirecting a real dependency is worse than a clear error.
142
+ *
143
+ * Exported for the unit test that pins the table.
144
+ */
145
+ export declare const SDK_SPECIFIER_ALIASES: readonly string[];
146
+ /** `@agent-compose/sdk` when `specifier` is a known near-miss for it, else
147
+ * null. The single authority: the plugin's regex filter is only a fast
148
+ * pre-filter, and this decides. */
149
+ export declare function resolveSdkAlias(specifier: string): string | null;
150
+ /**
151
+ * The SELF-CORRECTING layer. Turns a Bun bundle failure into a message that
152
+ * names the real fix instead of leaking Bun's internals.
153
+ *
154
+ * An unresolved import is almost always one thing: workflow source naming
155
+ * the platform SDK by a specifier that isn't its package name. Bun answers
156
+ * that with `Could not resolve: "agentc/sdk". Maybe you need to "bun
157
+ * install"?` — advice the author cannot act on (there is no package.json to
158
+ * install into; the drive holds one file). The rewritten message says the
159
+ * package name, so an agent reading its own tool error can fix the import on
160
+ * the next turn.
161
+ *
162
+ * Non-resolve failures (syntax errors, transform failures) keep their
163
+ * verbatim diagnostics — those are already actionable.
164
+ *
165
+ * Exported for the unit test; `bundleWorkflow` is the supported entrypoint.
166
+ */
167
+ export declare function explainBundleFailure(err: unknown, label: string): string;
112
168
  /**
113
169
  * Parse the bundled source and assert the default export is a CallExpression
114
170
  * to an identifier named `defineWorkflow` (or to a `.step(...).build()` chain
@@ -72,6 +72,13 @@ export interface StepWorkflowDefinition<TInput, TOutput> {
72
72
  * server skips mounting the shared factory drive for its runs (#13). See
73
73
  * `WorkflowMetadata.environmentBuild`. */
74
74
  environmentBuild?: boolean;
75
+ /** Whether this workflow's runs need the factory drive. Omit (⇒
76
+ * `"required"`) for any workflow that reads or writes /factory: a failed
77
+ * mount then FAILS the run instead of silently proceeding drive-less.
78
+ * Declare `"none"` for a drive-agnostic workflow — the server skips the
79
+ * /factory mount entirely for its runs. See
80
+ * `WorkflowMetadata.factoryDrive`. */
81
+ factoryDrive?: "required" | "none";
75
82
  }
76
83
  export declare function createStepWorkflow<TInput, TOutput>(opts: StepWorkflowDefinition<TInput, TOutput>): WorkflowBuilder<TInput, TInput>;
77
84
  /** Type guard — true when `value` is a `Workflow`. */
@@ -1,4 +1,22 @@
1
1
  import type { InvokeChild } from "../types/execution-context.js";
2
+ /**
3
+ * Derive the deterministic per-call `Idempotency-Key` for a `ctx.invokeChild`
4
+ * dispatch: `invoke-child:<parentRunId>:step<stepIndex>:<childName>:<ordinal>`.
5
+ *
6
+ * Replay safety: a replayed step re-runs its body from the top, so the k-th
7
+ * `invokeChild(name)` call in a step re-derives the SAME key (the per-scope
8
+ * ordinal counter lives on the active-step state, which resets identically on
9
+ * every (re-)entry — the `nextPauseOrdinalInActiveStep` pattern). The server's
10
+ * idempotency window then returns the original child run instead of
11
+ * double-dispatching. Outside step execution (local dev, tests) there is no
12
+ * stable coordinate to key on — returns null and the dispatch is unkeyed,
13
+ * exactly the old behavior.
14
+ *
15
+ * Key syntax matches the server's `Idempotency-Key` grammar
16
+ * (`[A-Za-z0-9_\-:.]{1,255}`): runId is a UUID, step index a number, and
17
+ * workflow names are kebab-case.
18
+ */
19
+ export declare function deriveInvokeChildIdempotencyKey(parentRunId: string, childName: string): string | null;
2
20
  /**
3
21
  * Build the public-API child workflow invoker used by legacy and sandboxed
4
22
  * workflow execution. Provider-backed engines may inject a different
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Replay-safety pin for `ctx.invokeChild` (parity plan P3.3): the
3
+ * Idempotency-Key a child dispatch carries is DETERMINISTIC per
4
+ * (parentRunId, stepIndex, childName, call ordinal) — a step body re-run
5
+ * from the top (pause/resume re-entry, crash-replayed attempt) re-derives
6
+ * byte-identical keys, so the server's idempotency window collapses the
7
+ * replayed dispatch into the original child run instead of double-running.
8
+ */
9
+ export {};
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@agent-compose/sdk",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Client library for agent-compose — define agents, runtimes, and workflows, and invoke them against an agent-compose server.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
8
- "url": "git+https://github.com/Layr-Labs/agent-compose.git",
8
+ "url": "git+https://github.com/Chris-Moller/agent-compose.git",
9
9
  "directory": "sdk"
10
10
  },
11
11
  "type": "module",
@@ -126,6 +126,11 @@ export type AgentLifecycleEvent =
126
126
  /** Short runtime self-identifier (`claude`, `openai-desktop`, …).
127
127
  * Drives the per-agent runtime icon on the dashboard. */
128
128
  runtimeKind?: string;
129
+ /** Authored plan-phase this agent belongs to (e.g. dynamic-task's
130
+ * `phase.name`). Purely observability: the dashboard groups agents
131
+ * under named phases without parsing the label prefix. Optional and
132
+ * additive — absent for agents outside a phased plan. */
133
+ phase?: string;
129
134
  }
130
135
  | { event: "agent.message"; at: number; agentId: string; label: string; iteration: number; message: AgentMessageSummary }
131
136
  | { event: "agent.iteration"; at: number; agentId: string; label: string; iteration: number; status: AgentStatus | null }
@@ -134,6 +139,9 @@ export type AgentLifecycleEvent =
134
139
  export interface AgentLoopOpts<TResponse = unknown> {
135
140
  agentId?: string;
136
141
  label?: string;
142
+ /** Authored plan-phase name carried onto `agent.spawned` (see
143
+ * AgentLifecycleEvent.phase). Optional, observability-only. */
144
+ phase?: string;
137
145
  onAgentLifecycleEvent?: (event: AgentLifecycleEvent) => void;
138
146
  onIteration?: (iteration: number, status: AgentStatus | null) => void;
139
147
  turnsPerIteration?: number;
@@ -307,6 +315,7 @@ export async function agentLoop<TResponse = unknown>(opts: AgentLoopOpts<TRespon
307
315
  allowedTools: opts.allowedTools ?? DEFAULT_ALLOWED_TOOLS,
308
316
  ...(client.model != null ? { model: client.model } : {}),
309
317
  ...(client.kind != null ? { runtimeKind: client.kind } : {}),
318
+ ...(opts.phase != null ? { phase: opts.phase } : {}),
310
319
  });
311
320
  }
312
321
 
@@ -183,6 +183,10 @@ export interface AgentOpts<T = unknown> {
183
183
  responseSchema?: z.ZodType<T>;
184
184
  /** Label prefix for runtime stderr ("[sbid][agent]" by default). */
185
185
  label?: string;
186
+ /** Authored plan-phase this agent belongs to. Rides `agent.spawned` so the
187
+ * dashboard groups agents under named phases. Optional, additive,
188
+ * observability-only — it never affects execution. */
189
+ phase?: string;
186
190
  /** Lifecycle event sink from workflow ctx. Emits agent.spawned / iteration / settled. */
187
191
  events?: { emit: (event: AgentLifecycleEvent) => void | Promise<void> };
188
192
  /** Per-message event callback — wire this to your workflow's event
@@ -349,6 +353,7 @@ export async function agent<T = unknown>(opts: AgentOpts<T>): Promise<AgentLoopR
349
353
  runtime: (runtimeOpts: RuntimeOptions) => opts.runtime.create(opts.sandbox, { ...runtimeOpts, agentManual: buildAgentContextDoc(process.env) }),
350
354
  agentId,
351
355
  ...(opts.label !== undefined ? { label: opts.label } : {}),
356
+ ...(opts.phase !== undefined ? { phase: opts.phase } : {}),
352
357
  ...(opts.budget?.turnsPerIteration !== undefined ? { turnsPerIteration: opts.budget.turnsPerIteration } : {}),
353
358
  ...(opts.budget?.maxIterations !== undefined ? { maxIterations: opts.budget.maxIterations } : {}),
354
359
  ...(opts.tools !== undefined ? { allowedTools: opts.tools } : {}),
package/src/client.ts CHANGED
@@ -18,7 +18,8 @@ import type { RunEvent } from "./types/events.js";
18
18
  import type { ConversationStreamEvent } from "./types/conversation-stream.js";
19
19
  import { normalizeConversationStreamEvent } from "./types/conversation-stream.js";
20
20
  import type {
21
- InvokeWorkflowOptions, InvokeAndWaitOptions, InvokeResult, StreamRunLogsOptions,
21
+ InvokeWorkflowOptions, InvokeAndWaitOptions, InvokeResult, FundingChoice, StreamRunLogsOptions,
22
+ InlineWorkflowPayload, InvokeInlineOptions, InvokeInlineAndWaitOptions,
22
23
  RunStatus, ResumePauseOptions, ResumePauseResponse, AnswerSteerOptions,
23
24
  RequestAgentPauseOptions, RequestAgentPauseResponse,
24
25
  SendAgentMessageOptions, SendAgentMessageResponse,
@@ -53,6 +54,7 @@ import type {
53
54
  SecretOptions, SetSecretResult, SecretListEntry,
54
55
  CreateApiKeyInput, ApiKey, ApiKeyCreated, UsageResponse,
55
56
  DriveRepoLink, CreateDriveRepoLinkInput,
57
+ DriveMountSession, CreateDriveMountSessionInput,
56
58
  } from "./types/api-factory.js";
57
59
  import type {
58
60
  ComplianceSession, RequestComplianceSessionInput, ListComplianceSessionsOptions,
@@ -64,7 +66,8 @@ import type {
64
66
  // here so `client.js` stays the single import surface for the client and its
65
67
  // shapes (index.ts re-exports from here).
66
68
  export type {
67
- RunState, InvokeWorkflowOptions, InvokeAndWaitOptions, InvokeResult, StreamRunLogsOptions,
69
+ RunState, InvokeWorkflowOptions, InvokeAndWaitOptions, InvokeResult, FundingChoice, StreamRunLogsOptions,
70
+ InlineWorkflowPayload, InvokeInlineOptions, InvokeInlineAndWaitOptions,
68
71
  RunStatus, ResumePauseActor, ResumePauseSuccess, ResumePausePending, ResumePauseResponse,
69
72
  ResumePauseOptions, RequestAgentPauseOptions, AnswerSteerOptions, RequestAgentPauseResponse,
70
73
  SendAgentMessageOptions, SendAgentMessageResponse,
@@ -108,6 +111,7 @@ export type {
108
111
  CreateApiKeyInput, ApiKey, ApiKeyCreated,
109
112
  UsageRollupRow, UsageResponse,
110
113
  DriveRepoLink, CreateDriveRepoLinkInput,
114
+ DriveMountSession, CreateDriveMountSessionInput,
111
115
  } from "./types/api-factory.js";
112
116
  export type {
113
117
  ComplianceScopeKind, ComplianceStatus, ComplianceSession, ComplianceAccess,
@@ -296,6 +300,45 @@ export class AgentComposeClient {
296
300
  ...(opts?.size !== undefined ? { size: opts.size } : {}),
297
301
  ...(parentRunId ? { parentRunId } : {}),
298
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 } : {}),
299
342
  },
300
343
  });
301
344
  }
@@ -312,12 +355,33 @@ export class AgentComposeClient {
312
355
  input?: Record<string, unknown>,
313
356
  opts?: InvokeAndWaitOptions,
314
357
  ): Promise<RunStatus<TOutput>> {
315
- const timeoutMs = opts?.timeoutMs ?? 30 * 60 * 1000;
316
- const pollMs = opts?.pollIntervalMs ?? 1000;
317
358
  // InvokeAndWaitOptions extends InvokeWorkflowOptions, so we can forward
318
359
  // `opts` directly — invoke() picks only the fields it sends, so the
319
360
  // extra `timeoutMs` / `pollIntervalMs` never leak into the request body.
320
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;
321
385
  const deadline = Date.now() + timeoutMs;
322
386
  while (Date.now() < deadline) {
323
387
  const status = await this.getStatus<TOutput>(runId);
@@ -329,7 +393,7 @@ export class AgentComposeClient {
329
393
  // Use AgentComposeError (not plain Error) so catch-blocks handling SDK
330
394
  // transport failures also handle timeouts uniformly. HTTP 504 is the
331
395
  // closest idiomatic status for "upstream didn't answer in time."
332
- 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`);
333
397
  }
334
398
 
335
399
  /** List captured snapshots in a factory. */
@@ -622,6 +686,21 @@ export class AgentComposeClient {
622
686
  }));
623
687
  }
624
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
+
625
704
  // ── Factory files ──────────────────────────────────────────────────────────
626
705
  // The factory drive: documents surfaced in the dashboard's Files tab.
627
706
  // Writes from inside a sandbox automatically carry the run-callback token
@@ -665,13 +744,17 @@ export class AgentComposeClient {
665
744
  * U+FFFD and the original bytes are unrecoverable). */
666
745
  async getFactoryFileBytes(
667
746
  path: string,
668
- opts?: { factorySlug?: string; revision?: number },
747
+ opts?: { factorySlug?: string; revision?: number; branch?: string },
669
748
  ): Promise<Uint8Array> {
670
749
  const factorySlug = opts?.factorySlug
671
750
  ?? (typeof process !== "undefined" ? process.env?.AGENT_COMPOSE_FACTORY : undefined)
672
751
  ?? DEFAULT_FACTORY;
673
752
  const q = new URLSearchParams({ path });
674
753
  if (opts?.revision !== undefined) q.set("revision", String(opts.revision));
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);
675
758
  const buf = await this.fetch<ArrayBuffer, "arrayBuffer">(
676
759
  `/api/v1/factories/${encodeURIComponent(factorySlug)}/files/content?${q}`,
677
760
  { responseType: "arrayBuffer" },
@@ -684,11 +767,57 @@ export class AgentComposeClient {
684
767
  * text corrupts the bytes irreversibly. */
685
768
  async getFactoryFile(
686
769
  path: string,
687
- opts?: { factorySlug?: string; revision?: number },
770
+ opts?: { factorySlug?: string; revision?: number; branch?: string },
688
771
  ): Promise<string> {
689
772
  return new TextDecoder().decode(await this.getFactoryFileBytes(path, opts));
690
773
  }
691
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
+
692
821
  // ── Conversations ──────────────────────────────────────────────────────────
693
822
  // The chat surfaces (channels / solo chats / sessions) the caller can
694
823
  // access. Key callers see what their sender scope allows; the server is the
@@ -711,9 +840,12 @@ export class AgentComposeClient {
711
840
  return this.fetch<SessionsPage>(`/api/v1/sessions?${q}`);
712
841
  }
713
842
 
714
- /** One conversation with its latest page of messages. */
715
- getConversation(id: string): Promise<ConversationDetail> {
716
- return this.fetch<ConversationDetail>(`/api/v1/conversations/${encodeURIComponent(id)}`);
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}`);
717
849
  }
718
850
 
719
851
  /** Provision a CLOUD-native session (ADR-0037 Phase 3b / ADR-0055 §9): a
@@ -742,6 +874,9 @@ export class AgentComposeClient {
742
874
  ...(input.sandboxSize ? { sandboxSize: input.sandboxSize } : {}),
743
875
  ...(input.networkPolicy !== undefined ? { networkPolicy: input.networkPolicy } : {}),
744
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 } : {}),
745
880
  },
746
881
  });
747
882
  }
package/src/display.ts CHANGED
@@ -101,8 +101,32 @@ export const TABLE_MAX_COLUMNS = 12;
101
101
  export const TABLE_MAX_ROWS = 100;
102
102
  export const TABLE_CELL_MAX_CHARS = 200;
103
103
 
104
- /** Drive-path bound for image markers (mirrors the directive path cap). */
105
- const IMAGE_PATH_MAX_CHARS = 512;
104
+ /** Drive-path bound for promoted/marker paths (mirrors the directive cap). */
105
+ export const DRIVE_PATH_MAX_CHARS = 512;
106
+
107
+ /**
108
+ * Whether a string is a PLAUSIBLE factory-drive path — the gate every
109
+ * promoted document/image/preview/diff path and document/image marker
110
+ * passes before it can become a card. A malformed agent command can hand
111
+ * the promoter shell fragments instead of a path (live failure: a partial
112
+ * `agentc display document` call promoted the redirect word `2>&1` — and a
113
+ * bare `/` — into "Showed document" cards whose viewer link was dead), so
114
+ * anything that reads as a shell operator, flag, or empty reference is not
115
+ * a path: reject whitespace-only, over-long, flag-shaped (leading `-`),
116
+ * shell operators / substitution (`|`, `<`, `>`, `;`, backticks, `$(`,
117
+ * which covers `2>&1`), control characters, backslashes, bare/duplicate
118
+ * slashes, and `.`/`..` segments (the server rejects those anyway).
119
+ */
120
+ export function isPlausibleDrivePath(raw: string): boolean {
121
+ if (raw.trim().length === 0 || raw.length > DRIVE_PATH_MAX_CHARS) return false;
122
+ if (raw.startsWith("-")) return false;
123
+ // eslint-disable-next-line no-control-regex
124
+ if (/[\u0000-\u001f\u007f\\]/.test(raw)) return false;
125
+ if (/[|<>;`]|\$\(/.test(raw)) return false;
126
+ const trimmed = raw.replace(/^\/+|\/+$/g, "");
127
+ if (trimmed.length === 0) return false; // bare "/" (or only slashes)
128
+ return trimmed.split("/").every((seg) => seg !== "" && seg !== "." && seg !== "..");
129
+ }
106
130
 
107
131
  function clampCellString(s: string): string {
108
132
  return s.length > TABLE_CELL_MAX_CHARS ? `${s.slice(0, TABLE_CELL_MAX_CHARS - 1)}…` : s;
@@ -288,7 +312,7 @@ export function parseDisplayMarker(line: string): DisplayMarker | null {
288
312
  }
289
313
  case "document": {
290
314
  const path = str("path");
291
- if (!path) return null;
315
+ if (!path || !isPlausibleDrivePath(path)) return null;
292
316
  return {
293
317
  kind: "document", path,
294
318
  ...(str("factorySlug") ? { factorySlug: str("factorySlug") } : {}),
@@ -301,7 +325,7 @@ export function parseDisplayMarker(line: string): DisplayMarker | null {
301
325
  }
302
326
  case "image": {
303
327
  const path = str("path");
304
- if (!path || path.length > IMAGE_PATH_MAX_CHARS) return null;
328
+ if (!path || !isPlausibleDrivePath(path)) return null;
305
329
  return {
306
330
  kind: "image", path,
307
331
  ...(str("factorySlug") ? { factorySlug: str("factorySlug") } : {}),
@@ -446,6 +470,11 @@ export function detectAgentcInvocation(command: string): AgentcInvocation | null
446
470
  for (let i = start + 1; i < words.length; i += 1) {
447
471
  const w = words[i];
448
472
  if (SHELL_STOPPERS.has(w)) break;
473
+ // A merged-stream redirect (`2>&1`) is tolerated by the control-word
474
+ // gate above, but it is shell syntax, never an argument — collecting
475
+ // it turned a truncated `agentc display document 2>&1` into a "Showed
476
+ // document" card whose path was the redirect itself (live failure).
477
+ if (/^\d*>&\d+$/.test(w)) continue;
449
478
  if (w.startsWith("--")) {
450
479
  if (VALUE_FLAGS.has(w) && i + 1 < words.length && !SHELL_STOPPERS.has(words[i + 1])) {
451
480
  flags.set(w, words[i + 1]);
@@ -458,6 +487,12 @@ export function detectAgentcInvocation(command: string): AgentcInvocation | null
458
487
  const factorySlug = flags.get("--factory");
459
488
  const withSlug = factorySlug ? { factorySlug } : {};
460
489
  const uuid = (v: string | undefined): string | null => (v && UUID_RE.test(v) ? v : null);
490
+ // Path-taking verbs promote only PLAUSIBLE drive paths — a shell
491
+ // fragment or bare `/` from a malformed command is unparseable-argument
492
+ // territory (the pair persists raw, the CLI's own error is the agent's
493
+ // feedback), never a document card.
494
+ const drivePath = (v: string | undefined): string | null =>
495
+ (v && isPlausibleDrivePath(v) ? v : null);
461
496
 
462
497
  if (args[0] === "display") {
463
498
  switch (args[1]) {
@@ -469,19 +504,28 @@ export function detectAgentcInvocation(command: string): AgentcInvocation | null
469
504
  const runId = uuid(args[2]);
470
505
  return runId ? { verb: "display-changes", runId, ...withSlug } : null;
471
506
  }
472
- case "document":
473
- return args[2] ? { verb: "display-document", path: args[2], ...withSlug } : null;
507
+ case "document": {
508
+ const path = drivePath(args[2]);
509
+ return path ? { verb: "display-document", path, ...withSlug } : null;
510
+ }
474
511
  case "plan":
475
512
  return { verb: "display-plan" };
476
- case "image":
477
- return args[2] ? { verb: "display-image", path: args[2], ...withSlug } : null;
478
- case "preview":
479
- return args[2] ? { verb: "display-preview", path: args[2] } : null;
513
+ case "image": {
514
+ const path = drivePath(args[2]);
515
+ return path ? { verb: "display-image", path, ...withSlug } : null;
516
+ }
517
+ case "preview": {
518
+ const path = drivePath(args[2]);
519
+ return path ? { verb: "display-preview", path } : null;
520
+ }
480
521
  case "table": {
481
522
  const file = flags.get("--file");
482
523
  const hasData = flags.has("--data");
483
524
  // Exactly one source; both or neither is an unpromotable combination.
484
- if (file && !hasData) return { verb: "display-table", source: "file", path: file };
525
+ if (file && !hasData) {
526
+ const path = drivePath(file);
527
+ return path ? { verb: "display-table", source: "file", path } : null;
528
+ }
485
529
  if (hasData && !file) return { verb: "display-table", source: "inline" };
486
530
  return null;
487
531
  }
@@ -494,9 +538,10 @@ export function detectAgentcInvocation(command: string): AgentcInvocation | null
494
538
  case "diff": {
495
539
  const from = flags.get("--from");
496
540
  const to = flags.get("--to");
497
- if (!args[2] || !from || !to) return null;
541
+ const path = drivePath(args[2]);
542
+ if (!path || !from || !to) return null;
498
543
  if (!REVISION_SELECTOR_RE.test(from) || !REVISION_SELECTOR_RE.test(to)) return null;
499
- return { verb: "display-diff", path: args[2], from, to };
544
+ return { verb: "display-diff", path, from, to };
500
545
  }
501
546
  case "ask": {
502
547
  const prompt = flags.get("--prompt");
@@ -524,8 +569,9 @@ export function detectAgentcInvocation(command: string): AgentcInvocation | null
524
569
  const runId = uuid(args[2]);
525
570
  return runId ? { verb: "run-get", runId } : null;
526
571
  }
527
- if (args[0] === "files" && args[1] === "read" && args[2]) {
528
- return { verb: "files-read", path: args[2], ...withSlug };
572
+ if (args[0] === "files" && args[1] === "read") {
573
+ const path = drivePath(args[2]);
574
+ return path ? { verb: "files-read", path, ...withSlug } : null;
529
575
  }
530
576
  return null;
531
577
  }