@aexhq/sdk 0.39.0 → 0.40.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 (88) hide show
  1. package/README.md +22 -16
  2. package/dist/_contracts/api-key.d.ts +49 -0
  3. package/dist/_contracts/api-key.js +87 -0
  4. package/dist/_contracts/bundle-manifest.d.ts +86 -0
  5. package/dist/_contracts/bundle-manifest.js +157 -0
  6. package/dist/_contracts/error-codes.d.ts +26 -0
  7. package/dist/_contracts/error-codes.js +79 -0
  8. package/dist/_contracts/error-factory.d.ts +32 -0
  9. package/dist/_contracts/error-factory.js +142 -0
  10. package/dist/_contracts/event-envelope.d.ts +33 -10
  11. package/dist/_contracts/event-envelope.js +46 -10
  12. package/dist/_contracts/event-view.d.ts +123 -0
  13. package/dist/_contracts/event-view.js +120 -0
  14. package/dist/_contracts/http.js +12 -3
  15. package/dist/_contracts/index.d.ts +8 -4
  16. package/dist/_contracts/index.js +11 -7
  17. package/dist/_contracts/models.d.ts +15 -0
  18. package/dist/_contracts/models.js +33 -0
  19. package/dist/_contracts/operations.d.ts +53 -2
  20. package/dist/_contracts/operations.js +119 -7
  21. package/dist/_contracts/run-record.d.ts +3 -2
  22. package/dist/_contracts/runtime-types.d.ts +126 -36
  23. package/dist/_contracts/runtime-types.js +33 -1
  24. package/dist/_contracts/sdk-errors.d.ts +61 -2
  25. package/dist/_contracts/sdk-errors.js +83 -3
  26. package/dist/_contracts/sdk-secrets.js +31 -6
  27. package/dist/_contracts/stable.d.ts +14 -0
  28. package/dist/_contracts/stable.js +14 -0
  29. package/dist/_contracts/status.d.ts +28 -1
  30. package/dist/_contracts/status.js +48 -3
  31. package/dist/_contracts/submission.d.ts +79 -2
  32. package/dist/_contracts/submission.js +148 -5
  33. package/dist/_contracts/suggest.d.ts +15 -0
  34. package/dist/_contracts/suggest.js +54 -0
  35. package/dist/asset-upload.d.ts +26 -0
  36. package/dist/asset-upload.js +218 -3
  37. package/dist/asset-upload.js.map +1 -1
  38. package/dist/bundle.d.ts +14 -3
  39. package/dist/bundle.js +34 -14
  40. package/dist/bundle.js.map +1 -1
  41. package/dist/canonical-zip.d.ts +68 -0
  42. package/dist/canonical-zip.js +307 -0
  43. package/dist/canonical-zip.js.map +1 -0
  44. package/dist/cli.mjs +1923 -338
  45. package/dist/cli.mjs.sha256 +1 -1
  46. package/dist/client.d.ts +217 -58
  47. package/dist/client.js +749 -261
  48. package/dist/client.js.map +1 -1
  49. package/dist/file.d.ts +33 -6
  50. package/dist/file.js +120 -54
  51. package/dist/file.js.map +1 -1
  52. package/dist/index.d.ts +17 -8
  53. package/dist/index.js +26 -9
  54. package/dist/index.js.map +1 -1
  55. package/dist/node-fs.d.ts +26 -9
  56. package/dist/node-fs.js +13 -38
  57. package/dist/node-fs.js.map +1 -1
  58. package/dist/node-walk.d.ts +69 -0
  59. package/dist/node-walk.js +146 -0
  60. package/dist/node-walk.js.map +1 -0
  61. package/dist/retry.d.ts +9 -13
  62. package/dist/retry.js +18 -17
  63. package/dist/retry.js.map +1 -1
  64. package/dist/skill.d.ts +16 -4
  65. package/dist/skill.js +18 -9
  66. package/dist/skill.js.map +1 -1
  67. package/dist/tool.d.ts +14 -2
  68. package/dist/tool.js +33 -7
  69. package/dist/tool.js.map +1 -1
  70. package/dist/version.d.ts +1 -1
  71. package/dist/version.js +1 -1
  72. package/docs/authentication.md +29 -5
  73. package/docs/billing.md +6 -0
  74. package/docs/concepts/agent-tools.md +11 -0
  75. package/docs/defaults.md +1 -0
  76. package/docs/errors.md +64 -4
  77. package/docs/events.md +84 -49
  78. package/docs/limits-and-quotas.md +24 -0
  79. package/docs/networking.md +7 -1
  80. package/docs/outputs.md +36 -7
  81. package/docs/quickstart.md +17 -7
  82. package/docs/run-config.md +3 -3
  83. package/docs/secrets.md +14 -0
  84. package/examples/feature-tour.ts +4 -6
  85. package/examples/spike-settle-latency.ts +125 -0
  86. package/package.json +4 -3
  87. package/dist/_contracts/event-guards.d.ts +0 -67
  88. package/dist/_contracts/event-guards.js +0 -36
package/docs/events.md CHANGED
@@ -23,10 +23,12 @@ const events = await session.events().list();
23
23
  ```
24
24
 
25
25
  ```ts
26
- // Stream the RunEvent snapshot shape: yields each event once, stops when the
27
- // session parks. Backed by polling the aex events endpoint.
26
+ // Stream events as guarded `AexEventView`s: yields each event once, stops when
27
+ // the session parks. Backed by polling the aex events endpoint. Every surface —
28
+ // list(), stream(), streamEnvelopes() — yields the same `AexEventView` with a
29
+ // populated `sequence` and the `is*()` guard methods.
28
30
  for await (const event of session.events().stream({ intervalMs: 1000 })) {
29
- if (event.type === "TEXT_MESSAGE_CONTENT") {
31
+ if (event.isTextMessage()) {
30
32
  // ...
31
33
  }
32
34
  }
@@ -79,8 +81,9 @@ They need a global `WebSocket` (Bun or Node ≥ 22).
79
81
 
80
82
  `aex wait` is the host mirror of `session.wait()`:
81
83
  it polls until the session parks and prints the final `Session` record. Exit `0`
82
- when the session parked cleanly (`idle`/`suspended`), `1` for any other park
83
- (`error` / a non-clean terminal status), and `3` when `--timeout` elapses first
84
+ when the session parked cleanly (`idle`/`suspended`/`succeeded`), `1` for any
85
+ non-clean terminal outcome (`failed`/`timed_out`/`cancelled`), and `3` when
86
+ `--timeout` elapses first
84
87
  (a `--timeout` on `events --follow` / `run --follow` uses the same exit-`3`
85
88
  convention). Durations accept `ms`/`s`/`m`/`h` suffixes or a bare millisecond integer.
86
89
 
@@ -100,13 +103,34 @@ for await (const event of turn) {
100
103
  console.log(event.sequence, event.type);
101
104
  }
102
105
 
103
- const result = await turn.done(); // status is usually "idle"
106
+ const result = await turn.done(); // status is the terminal outcome, e.g. "succeeded"
104
107
  ```
105
108
 
106
- The turn stream ends when it sees `aex.session.idle`, `aex.session.suspended`, or
107
- `aex.session.error` for that turn. `aex.run(config)` is a convenience wrapper
108
- over the same flow: it opens a session, sends `message` once, and returns the
109
- collected session turn. The returned `runId` is the session id.
109
+ The turn stream ends on the turn's park terminal: a resumable
110
+ `aex.session.idle` / `aex.session.suspended`, a terminal outcome
111
+ `aex.session.succeeded` / `.failed` / `.timed_out` / `.cancelled`, or the held
112
+ `aex.session.awaiting_approval`. (The bare `aex.session.error` park is retired
113
+ a failed turn is `failed`, a wall-clock kill `timed_out`, a cancel `cancelled`.)
114
+ `aex.run(config)` is a convenience wrapper over the same flow: it opens a
115
+ session, sends `message` once, and returns the collected session turn. The
116
+ returned `runId` is the session id.
117
+
118
+ ## Per-token streaming (`outputMode: 'stream'`)
119
+
120
+ Assistant-text granularity is controlled by `outputMode` on `openSession` / `run`:
121
+
122
+ - **`buffered`** (the default) — the assistant text arrives coalesced as one
123
+ `TEXT_MESSAGE_CONTENT` event per message.
124
+ - **`stream`** — on a **streamable** provider you also receive per-token
125
+ `TEXT_MESSAGE_CONTENT` **deltas** as the model produces them, and a coalesced
126
+ final `TEXT_MESSAGE_CONTENT` (the archived twin) always follows. Streamable
127
+ providers today are Anthropic and the OpenAI-chat-shaped providers (DeepSeek,
128
+ OpenAI, Mistral, OpenRouter, Doubao / Doubao China).
129
+
130
+ Streaming is **capability-gated and fail-closed**: requesting `outputMode: 'stream'`
131
+ on a provider that has no streaming producer (currently `gemini`) is a typed
132
+ rejection at submission parse — **not** a silent downgrade to buffered. Choose a
133
+ streamable provider or drop back to `buffered` explicitly.
110
134
 
111
135
  ## Terminal events vs. the run record
112
136
 
@@ -119,21 +143,30 @@ on how the turn ends:
119
143
  instead (see below). Expect `RUN_ERROR` on stream-level failures, and treat
120
144
  `RUN_FINISHED` — when it does appear — as a low-latency "stop the spinner"
121
145
  hint, not a read-consistency barrier.
122
- - **`aex.session.*` park terminals** — `CUSTOM` events named `aex.session.idle`,
123
- `aex.session.suspended`, or `aex.session.error`. On the managed plane these
124
- are what actually end a turn: the session parks with the matching status, and
125
- by the time the park event is broadcast the session record has already
126
- reached that status. This is the terminal you should expect from a managed
127
- run's event stream.
128
-
129
- The SDK's helpers cover both families so you never have to switch on the plane:
130
-
131
- - `isRunTerminal(event)` true for the AG-UI `RUN_FINISHED` / `RUN_ERROR` pair.
132
- - `isRunSettled(event)` true for the `aex.run.settled` settle barrier **and**
146
+ - **`aex.session.*` park terminals** — `CUSTOM` events carrying the turn's
147
+ OUTCOME: `aex.session.succeeded` / `aex.session.failed` / `aex.session.timed_out`
148
+ / `aex.session.cancelled`, or a resumable/held park `aex.session.idle` /
149
+ `aex.session.suspended` / `aex.session.awaiting_approval`. On the managed plane
150
+ these are what actually end a turn. The park event ends the RENDER; the settle
151
+ commit (cost + usage + the authoritative outcome) lands shortly after — which
152
+ is why `run()`/`done()` await settle by DEFAULT and return the outcome as
153
+ `result.status` with `result.costUsd`/`result.usage` always populated (never a
154
+ bare `idle`). Pass `await: 'park'` (on `run(...)` or `session.send(msg, {...})`)
155
+ to return early at the park event when you don't need cost/usage. `result.costUsd`
156
+ is aex runtime/storage spend and **excludes** your BYOK provider charges — price
157
+ those from `result.usage` token counts. Ending a raw stream on the park event is
158
+ fine for live UI; read the settled record (or the default await-settle result)
159
+ when you need cost/usage/outcome.
160
+
161
+ Each event the stream yields carries method guards that cover both families so
162
+ you never have to switch on the plane:
163
+
164
+ - `event.isRunTerminal()` — true for the AG-UI `RUN_FINISHED` / `RUN_ERROR` pair.
165
+ - `event.isRunSettled()` — true for the `aex.run.settled` settle barrier **and**
133
166
  for any `aex.session.*` park terminal. The managed plane does not broadcast a
134
167
  separate `aex.run.settled` barrier — the park event plays that role — so
135
- `isRunSettled` is the one guard that reliably means "this stream is done and
136
- the record is authoritative".
168
+ `event.isRunSettled()` is the one check that reliably means "this stream is done
169
+ and the record is authoritative".
137
170
 
138
171
  To read the authoritative status consistently, use one of:
139
172
 
@@ -154,8 +187,8 @@ for await (const event of session.events().streamEnvelopes({ settleConsistent: t
154
187
  const settled = await aex.sessions.get(session.id); // parked/terminal here
155
188
  ```
156
189
 
157
- `settleConsistent: true` makes the iterator end exactly when `isRunSettled(event)`
158
- first fires; on a raw stream, apply `isRunSettled(event)` yourself. What it
190
+ `settleConsistent: true` makes the iterator end exactly when `event.isRunSettled()`
191
+ first fires; on a raw stream, call `event.isRunSettled()` yourself. What it
159
192
  guarantees: when the stream ends, a subsequent `aex.sessions.get(id)` reads a
160
193
  parked/terminal status and `session.outputs().list()` is complete. Outputs are
161
194
  uploaded before the terminal is broadcast, so they are readable the moment the
@@ -175,34 +208,36 @@ const jsonl = await response.text();
175
208
 
176
209
  ## Event shape
177
210
 
178
- Events are typed as the discriminated `RunEvent` union for compatibility and as the versioned coordinator envelope for live consumers. aex records raw runtime/provider payloads **after** secret redaction and structural sanitization, so the bytes you see never contain provider keys, MCP credentials, or runtime secrets supplied when the session was opened.
211
+ Every read and stream surface yields one canonical shape — the versioned coordinator envelope, wrapped as an `AexEventView` with a populated non-optional `sequence` and the `is*()` guard methods. `sequence` is **sparse by design** (it encodes journal position, not a dense 0..N counter), so resume by cursor VALUE (`from: lastSequence`), never by integer adjacency. Tool-call START and RESULT events share a `data.id` join key, exposed as `event.toolCallId()` on those views. aex records raw runtime/provider payloads **after** secret redaction and structural sanitization, so the bytes you see never contain provider keys, MCP credentials, or runtime secrets supplied when the session was opened.
179
212
 
180
213
  ## Typed helpers
181
214
 
182
- The package exports conservative type guards over run events:
215
+ Every event the SDK yields the turn stream (`session.send()`),
216
+ `session.events().list()`, `session.events().streamEnvelopes()`, and
217
+ `RunResult.events` — carries a type-guard **method** for each standardized event
218
+ type, so you branch on the event without importing a free function or writing a
219
+ raw `event.type === "…"` compare:
183
220
 
184
221
  ```ts
185
- import {
186
- isRunStarted,
187
- isRunFinished,
188
- isRunError,
189
- isRunTerminal,
190
- isRunSettled,
191
- isTextMessage,
192
- isToolCallStart,
193
- isToolCallResult,
194
- isCustom,
195
- isLog,
196
- isEventChannel
197
- } from "@aexhq/sdk";
222
+ for await (const event of session.send("Continue the task.")) {
223
+ if (event.isTextMessage()) {
224
+ process.stdout.write(event.data.text); // `data.text` is typed `string` here
225
+ } else if (event.isToolCallStart()) {
226
+ console.log("tool:", event.data.name); // `data.name` is typed `string` here
227
+ } else if (event.isRunError()) {
228
+ console.error("run error");
229
+ }
230
+ }
198
231
  ```
199
232
 
200
- All guards test the `type` discriminant at runtime. `isTextMessage`,
201
- `isToolCallStart`, `isToolCallResult`, and `isRunFinished` operate on the loose
202
- `RunEvent` snapshot (`session.events().list()` / `RunResult.events`) and additionally NARROW
203
- `event.data` to the fields that event type carries e.g. inside
204
- `if (isTextMessage(e))`, `e.data.text` is typed `string`. The lifecycle/channel
205
- guards (`isRunStarted`, `isRunError`, `isCustom`, `isLog`, …) operate on the
206
- coordinator envelope and narrow only the discriminant. Use `result.text` or
207
- `session.messages.all()` when you need assistant text without inspecting the
208
- event stream directly.
233
+ The full method set: `isRunStarted()`, `isRunFinished()`, `isRunError()`,
234
+ `isRunTerminal()`, `isTextMessage()`, `isToolCallStart()`, `isToolCallResult()`,
235
+ `isCustom()`, `isLog()`, `isEventChannel()`, `isRunSettled()`, and
236
+ `isFromSource(source)`. All test the `type` (or `channel`/`source`) discriminant
237
+ at runtime. `isTextMessage()`, `isToolCallStart()`, and `isToolCallResult()` are
238
+ TS type predicates: inside the guarded branch `event.data` NARROWS to that event
239
+ type's payload fields (e.g. `event.data.text` is typed `string`). Annotate a
240
+ narrowed event with the exported `TextMessageEventView` / `ToolCallStartEventView`
241
+ / `ToolCallResultEventView` types, or a raw event with `AexEventView`. Use
242
+ `result.text` or `session.messages.all()` when you need assistant text without
243
+ inspecting the event stream directly.
@@ -31,6 +31,7 @@ And whether you can **raise** it: per-run option, per-plan, or no.
31
31
  | MCP connect timeout (default) | 30 seconds | aex policy | Per-port via `connectTimeoutMs` | `RUN_DEFAULT_MCP_CONNECT_TIMEOUT_MS` |
32
32
  | MCP call timeout (default) | 30 minutes | aex policy | Per-port via `callTimeoutMs` | `RUN_DEFAULT_MCP_CALL_TIMEOUT_MS` |
33
33
  | Per-session spend cap | None by default; when set, the session is stopped once its spend would exceed the cap | aex policy | Per-session via `overrides.maxSpendUsd` (a positive USD amount) | — |
34
+ | Max agent iterations (turns) per run | 20 by default; hard ceiling 200 | aex policy | Per-session via `overrides.maxTurns` (a positive integer, clamped to the ceiling) | `RUN_DEFAULT_MAX_TURNS` / `RUN_MAX_TURNS_CEILING` |
34
35
 
35
36
  ### Output capture (per run)
36
37
 
@@ -69,6 +70,29 @@ silently lost.
69
70
  | Per-run metadata record TTL | 24 hours | aex policy | No (hard ceiling) | `RUN_KV_RECORD_TTL_SECONDS` |
70
71
  | Per-run secret-envelope TTL | 24 hours | aex policy | No (hard ceiling) | `RUN_KV_SECRET_TTL_SECONDS` |
71
72
 
73
+ ## Sandbox (managed runtime)
74
+
75
+ Each run executes in an ephemeral Linux container sized by the `runtime` preset.
76
+ A few behaviours are worth knowing before you rely on the filesystem or RAM:
77
+
78
+ - **Only `/workspace` persists across turns** of the same session. Everything
79
+ outside `/workspace` is reset between turns and is gone when the session ends —
80
+ write deliverables and any state you want to survive a turn under `/workspace`.
81
+ - **The image is minimal.** Declare OS/language packages with
82
+ `environment.packages` so they are present deterministically at boot. A runtime
83
+ install inside the agent (`pip install`, `apt-get`) is best-effort and
84
+ **non-persistent** (reset next turn), and `pip` is subject to PEP 668
85
+ (`externally-managed-environment`) — prefer `environment.packages`, or a
86
+ virtualenv you create under `/workspace`.
87
+ - **`/proc/meminfo` reports the HOST's RAM, not your preset's.** Your actual
88
+ memory ceiling is the preset's `memoryMb` (e.g. `shared-0.25x-1gb` = 1 GB, the
89
+ default; `shared-4x-12gb` = 12 GB). The presets are the SSoT in
90
+ [`packages/contracts/src/runtime-sizes.ts`](https://github.com/aexhq/aex/blob/main/packages/contracts/src/runtime-sizes.ts);
91
+ see [Defaults](defaults.md).
92
+ - **The agent loop is bounded** by `maxTurns` (default 20, ceiling 200) — a
93
+ documented, per-run-overridable limit (see the Run scope table above and
94
+ `overrides.maxTurns`), not a silent cutoff.
95
+
72
96
  ## Workspace scope
73
97
 
74
98
  | Limit | Value | Source | Raisable? | Constant |
@@ -15,7 +15,13 @@ internet**. Outbound traffic is governed by **two layers**:
15
15
  (model providers, built-in tool endpoints, package registries, and related
16
16
  well-known development hosts such as `github.com`) and enforces a fixed SSRF
17
17
  deny-list: loopback, link-local, cloud-metadata, and other private ranges are
18
- always blocked, including hostnames that resolve to those ranges.
18
+ blocked on the standard proxy path every normal HTTP client uses, including
19
+ hostnames that resolve to those ranges. Note: on the current managed (Fargate)
20
+ plane a subprocess that deliberately bypasses the proxy with a **raw socket**
21
+ can still reach the on-link task-metadata IP (`169.254.170.2`), which exposes
22
+ non-secret task identity (AWS account id via the task ARN, cluster/image ref)
23
+ — but **never IAM credentials** (the run's task role is unset, so the metadata
24
+ credential endpoint serves nothing) and never another tenant's data.
19
25
 
20
26
  Honest boundary statement: the per-run `allowedHosts` policy is enforced by the
21
27
  run's own runtime on the standard proxy path — it is **not** yet enforced at
package/docs/outputs.md CHANGED
@@ -4,7 +4,7 @@ title: Outputs
4
4
 
5
5
  # Outputs
6
6
 
7
- Every session produces durable metadata (status, events, snapshots, cleanup state) and an outputs namespace. By default, managed runs capture every regular file the agent creates or modifies in the container: the runner snapshots the filesystem just before the agent starts, rescans it when the agent exits, and uploads the delta. There is no default or official output directory. Use `outputs.allowedDirs` only when you want to narrow capture to specific roots, and `outputs.deniedDirs` to subtract noise. `session.download()` returns the public session record — metadata, typed events, and captured output bytes — as a zip; the per-namespace verbs (`session.outputs().download()` / `session.events().download()` / `session.downloadMetadata()`) return one slice each.
7
+ Every session produces durable metadata (status, events, cleanup state) and an outputs namespace. By default, managed runs capture the regular files present in the container when the agent exits, EXCLUDING the inputs the platform itself materialized for you (your mounted `files`/`skills`) those are excluded by IDENTITY (their exact destination paths and skill-dir prefixes), not by any before/after timing comparison. There is no default or official output directory. Use `outputs.allowedDirs` only when you want to narrow capture to specific roots, and `outputs.deniedDirs` to subtract noise. `session.download()` returns the public session record — metadata, typed events, and captured output bytes — as a zip; the per-namespace verbs (`session.outputs().download()` / `session.events().download()` / `session.downloadMetadata()`) return one slice each.
8
8
 
9
9
  The output verbs below hang off the session's `outputs()` accessor
10
10
  (`session.outputs().list()`, `.read()`, `.download()`, …). Reach a handle from a
@@ -28,7 +28,7 @@ await session.download({ to: "./session.zip" });
28
28
  ```
29
29
 
30
30
  ```bash
31
- aex download <session-id> --out ./session.zip --api-key …
31
+ npx aex download <session-id> --out ./session.zip --api-key …
32
32
  ```
33
33
 
34
34
  ## The three namespaces
@@ -133,6 +133,35 @@ Query fields compose with AND semantics:
133
133
 
134
134
  `session.outputs().findOne(query)` returns `null` when nothing matches and throws `RunStateError` when the query matches more than one output.
135
135
 
136
+ ## Searching outputs
137
+
138
+ Search is metadata-only (reference hits — filename / extension / content type — no bytes). `filename` accepts a `string` (case-insensitive substring) or a `RegExp`. A content-shaped query (`content`/`text`/…) throws a typed "content search unsupported" rather than silently returning zero hits.
139
+
140
+ ```ts
141
+ // One session's outputs:
142
+ const hits = await session.outputs().search({ filename: /report/i });
143
+
144
+ // Across every run in the workspace (or scope with runIds):
145
+ const all = await aex.outputs.search({ extension: "md", runIds: ["run-a", "run-b"] });
146
+ ```
147
+
148
+ Each hit is `{ runId, outputId, filename?, sizeBytes?, contentType? }`; read the bytes with `session.outputs().read(...)` / `.download(...)`.
149
+
150
+ ## CLI
151
+
152
+ The `aex outputs` verb is a thin pass-through over the same SDK accessor, so every per-file operation has a subcommand (`npx aex` on a local install):
153
+
154
+ ```bash
155
+ npx aex outputs <session-id> # list captured outputs (NDJSON)
156
+ npx aex outputs read <session-id> <path> # read one file as capped text (JSON)
157
+ npx aex outputs download <session-id> <path> --out f # download one file's raw bytes
158
+ npx aex outputs link <session-id> <path> # mint a temporary download URL (JSON)
159
+ npx aex outputs find <session-id> --name S --ext E --type T
160
+ npx aex outputs search --query S --ext E --run-id ID # cross-run metadata search
161
+ ```
162
+
163
+ `aex outputs search` (no session id) is the cross-run search (`aex.outputs.search`); the whole-namespace zip stays `aex download <session-id>`.
164
+
136
165
  ## Temporary output links
137
166
 
138
167
  Use `session.outputs().link(selectorOrQuery, options?)` when another process, browser, media tag, or downloader needs a direct artifact URL instead of bytes buffered through the SDK.
@@ -190,7 +219,7 @@ Validation:
190
219
 
191
220
  Runtime notes:
192
221
 
193
- - The managed runtime captures files by diffing the filesystem against a baseline snapshot taken just before the agent starts. Platform setup files, installed packages, and materialized inputs are already present before the baseline, so they are excluded by timing.
222
+ - The managed runtime captures the regular files under the capture roots at terminal time, EXCLUDING the inputs the platform itself materialized (your mounted `files`/`skills`) by IDENTITY — their exact destination paths and skill-dir prefixes are threaded into the capture filter, so an untouched mounted input is never re-emitted as an output regardless of path policy or timing.
194
223
  - If you pass an explicit root that does not exist by terminal time, that root contributes no files.
195
224
 
196
225
  ## `outputs.deniedDirs` — subtract noise
@@ -208,10 +237,10 @@ aex.openSession({
208
237
 
209
238
  Mechanism (no platform-magical paths — this is honest):
210
239
 
211
- 1. The hosted platform materializes the workspace, opens runtime logs, and records a filesystem baseline across the capture roots.
240
+ 1. The hosted platform materializes the workspace (your mounted `files`/`skills`) and records the exact destination paths + skill-dir prefixes it wrote.
212
241
  2. The agent runs normally. There is no extra model turn and no synthetic sync instruction.
213
- 3. When the agent exits, the runner rescans the capture roots and finds files that are new or whose metadata changed.
214
- 4. The runner uploads changed regular files to durable run artifact storage. Diagnostic log paths are routed to internal diagnostics under `runs/<runId>/internal/logs/`; other paths are routed to `outputs`.
242
+ 3. When the agent exits, the runner scans the capture roots and drops any file whose path is a materialized INPUT (exact path or under a materialized skill dir) — inputs are excluded by WHO PUT THEM THERE (the platform), not by timing.
243
+ 4. The runner uploads the remaining regular files to durable run artifact storage. Diagnostic log paths are routed to internal diagnostics under `runs/<runId>/internal/logs/`; other paths are routed to `outputs`.
215
244
 
216
245
  Cost: output capture does not add a model turn. The runner pays a filesystem scan and upload cost near the end of the run.
217
246
 
@@ -228,7 +257,7 @@ Metadata still gets the full treatment. aex captures every regular file the run
228
257
 
229
258
  ## Mid-session download semantics
230
259
 
231
- Mid-session calls are **best-effort and side-effect-free**: they expose whatever artifacts have already been uploaded. Files written by the agent are normally uploaded near terminal, after the filesystem diff. If you need the full output set, wait for the session to park and call `session.download()` again.
260
+ Mid-session calls are **best-effort and side-effect-free**: they expose whatever artifacts have already been uploaded. Files written by the agent are normally uploaded near terminal, after the runner scans the capture roots. If you need the full output set, wait for the session to park and call `session.download()` again.
232
261
 
233
262
  ## Safety
234
263
 
@@ -16,9 +16,10 @@ This installs the TypeScript SDK exports and the bundled `aex` CLI.
16
16
 
17
17
  aex is currently in **invite-only beta**: workspaces and API keys are issued
18
18
  by the aex team — contact <support@aex.dev> for beta access. Once you have
19
- access, create a quickstart SDK token with `runs:read`, `runs:write`, and
20
- `outputs:read` in the dashboard at <https://aex.dev>. The examples also need
21
- your BYOK provider key for the model you choose. For the Claude examples below:
19
+ access, create a quickstart SDK token with `runs:read`, `runs:write`,
20
+ `outputs:read`, and `billing:read` in the dashboard at <https://aex.dev>. The
21
+ examples also need your BYOK provider key for the model you choose. For the
22
+ Claude examples below:
22
23
 
23
24
  ```bash
24
25
  export AEX_API_KEY="<your-aex-api-key>"
@@ -41,9 +42,16 @@ const session = await aex.openSession({
41
42
  });
42
43
 
43
44
  const first = await session.send("Write a short report and save it as a file.").done();
44
- console.log(first.status, first.text);
45
+ console.log(first.status, first.costUsd, first.text);
45
46
  ```
46
47
 
48
+ `send().done()` (and `run()`) **await settle by default**, so the result always
49
+ carries a terminal `status` (`succeeded` / `failed` / `timed_out` / `cancelled`
50
+ — never a bare `idle`) plus `costUsd` and `usage`. Pass `await: 'park'` to
51
+ return early at the render-complete park event when you don't need cost/usage.
52
+ `costUsd` is aex runtime/storage spend and **excludes** your BYOK provider
53
+ charges — price those from `usage` token counts against your provider's rates.
54
+
47
55
  The session parks as `idle` between turns and automatically moves to
48
56
  `suspended` after the idle window. Keep the session id and resume later:
49
57
 
@@ -88,7 +96,8 @@ await turn.done();
88
96
  const messages = await session.messages().list();
89
97
  console.log(messages.at(-1)?.text);
90
98
 
91
- // Poll the record until the session parks (idle / suspended / error).
99
+ // Poll the record until the session parks: a resumable `idle` / `suspended`,
100
+ // or a terminal outcome (`succeeded` / `failed` / `timed_out` / `cancelled`).
92
101
  const record = await session.wait();
93
102
  console.log(record.status);
94
103
 
@@ -96,10 +105,11 @@ console.log(record.status);
96
105
  await session.download({ to: "./session.zip" });
97
106
  ```
98
107
 
99
- The same run from the bundled CLI:
108
+ The same run from the bundled CLI (`npx aex` on a local install; or
109
+ `npm i -g @aexhq/sdk` for a bare `aex`):
100
110
 
101
111
  ```bash
102
- aex run \
112
+ npx aex run \
103
113
  --api-key "$AEX_API_KEY" \
104
114
  --anthropic-api-key "$ANTHROPIC_API_KEY" \
105
115
  --model claude-haiku-4-5 \
@@ -14,9 +14,9 @@ Allowed fields:
14
14
  - `environment` - `{ networking?, packages?, variables? }`. Networking is open by default; set `networking.mode` to `limited` only when you want an allowlist. `variables` are merged into the in-container `RUNTIME.env` / `RUNTIME.json` mounts. (Run secrets go in `environment.secrets`, which carries live `Secret` instances and is not part of a shareable config.)
15
15
  - `runtime` - optional managed-runtime preset. Prefer `Sizes` in TypeScript.
16
16
  - `metadata` - non-secret structured metadata.
17
- - `overrides` - `{ idleTtl?, timeout?, maxSpendUsd? }`. `timeout` is an optional session deadline (e.g. `"30m"`, `"2h"`); `maxSpendUsd` stops the session once its spend would exceed the cap (see [Limits & quotas](limits-and-quotas.md)).
17
+ - `overrides` - `{ idleTtl?, timeout?, maxSpendUsd?, maxTurns? }`. `timeout` is an optional session deadline (e.g. `"30m"`, `"2h"`); `maxSpendUsd` stops the session once its spend would exceed the cap; `maxTurns` caps the agent's iteration count for the turn (a positive integer — omit to accept the platform default of 20, clamped to the ceiling). See [Limits & quotas](limits-and-quotas.md).
18
18
 
19
- `message` (the one-shot `run` input), `agentsMd`, `files`, `outputs`, `tools`, `skills`, `includeBuiltinTools`, and `outputMode` are `openSession` / `run` options, not reusable run-config fields. They carry the turn input, bytes, capture behavior, or agent tool/output controls that belong on a concrete call. Skill bundles are `skills` entries built with `Skill.fromDir(...)`, `Skill.fromUrl(...)`, or the other `Skill.from*` factories, so they too are SDK-code options rather than config fields. Subagents are session-internal (the in-run `subagent` tool — see [Subagents](concepts/subagents.md)); there is no `parentRunId` option. The wire contract carries a per-run `limits` object (the exported `RunLimits` type: `maxConcurrentChildRuns`, `maxSubagentDepth`, `maxSpendUsd`), but the session surface exposes only its spend dial set it with `overrides.maxSpendUsd`; the subagent depth/breadth dials are not settable per-session today and take the platform defaults.
19
+ `message` (the one-shot `run` input), `agentsMd`, `files`, `outputs`, `tools`, `skills`, `includeBuiltinTools`, and `outputMode` are `openSession` / `run` options, not reusable run-config fields. They carry the turn input, bytes, capture behavior, or agent tool/output controls that belong on a concrete call. Skill bundles are `skills` entries built with `Skill.fromDir(...)`, `Skill.fromUrl(...)`, or the other `Skill.from*` factories, so they too are SDK-code options rather than config fields. Subagents are session-internal (the in-run `subagent` tool — see [Subagents](concepts/subagents.md)); there is no `parentRunId` option. The wire contract carries a per-run `limits` object (the exported `RunLimits` type: `maxConcurrentChildRuns`, `maxSubagentDepth`, `maxSpendUsd`, `maxTurns`), and the session surface exposes its spend and iteration dials via `overrides.maxSpendUsd` and `overrides.maxTurns`; the subagent depth/breadth dials are not settable per-session today and take the platform defaults.
20
20
 
21
21
  Secrets never live in run config. Pass provider keys through the top-level
22
22
  `apiKeys` map and runtime secrets through `environment.secrets` in the SDK, or
@@ -55,4 +55,4 @@ aex run --config ./run.json \
55
55
  --anthropic-api-key "$ANTHROPIC_API_KEY"
56
56
  ```
57
57
 
58
- ...or as explicit flags (`--model`, `--system`, `--prompt`, `--mcp`, `--mcp-auth`, `--runtime-size`, `--run-timeout`, `--metadata`). The two modes are mutually exclusive.
58
+ ...or as explicit flags (`--model`, `--system`, `--prompt`, `--mcp`, `--mcp-auth`, `--runtime-size`, `--run-timeout`, `--metadata`). The two modes are mutually exclusive. Composition inputs attach with repeatable flags that map onto the SDK's `Skill`/`Tool`/`AgentsMd`/`File` factories: `--skill @bundle` (workspace skill), `--tool @tool.js` (custom tool module), `--agents-md @AGENTS.md`, and `--file @path` (mount a file into `/workspace`).
package/docs/secrets.md CHANGED
@@ -118,3 +118,17 @@ await aex.secrets.delete("serper-api-key");
118
118
 
119
119
  The CLI supports per-run provider and MCP credentials. Workspace secret
120
120
  administration is exposed through the SDK.
121
+
122
+ ## Redaction Scope And Output Files
123
+
124
+ Registered secret values are redacted from the run's **event stream** (both tool
125
+ output and model-authored surfaces) — a value you inject via `environment.secrets`
126
+ is masked regardless of its shape. Two surfaces are intentionally *not* scrubbed:
127
+
128
+ - **Captured output files** (`outputs().download()` / `read()` / the `aex download`
129
+ zip) are returned **verbatim**. They are your run's own artifacts, so the platform
130
+ does not rewrite their bytes — if the agent writes a secret into a deliverable file,
131
+ that file contains it. Treat downloaded outputs as unredacted.
132
+ - An **unregistered** secret (a credential the run produces itself and never declared
133
+ via `environment.secrets`) can only be masked heuristically by shape; register the
134
+ values you care about so they are masked by value.
@@ -22,7 +22,6 @@ import {
22
22
  BuiltinTools,
23
23
  File,
24
24
  isRateLimited,
25
- isTextMessage,
26
25
  McpServer,
27
26
  Models,
28
27
  Providers,
@@ -200,12 +199,11 @@ for (;;) {
200
199
  break;
201
200
  }
202
201
  const event = next.value;
203
- if (isTextMessage(event)) {
202
+ if (event.isTextMessage()) {
204
203
  process.stdout.write(event.data.text);
205
- } else if (event.type === "TOOL_CALL_START") {
206
- const name = typeof event.data.name === "string" ? event.data.name : "tool";
207
- process.stdout.write(`\n[tool:start] ${name}\n`);
208
- } else if (event.type === "TOOL_CALL_RESULT") {
204
+ } else if (event.isToolCallStart()) {
205
+ process.stdout.write(`\n[tool:start] ${event.data.name}\n`);
206
+ } else if (event.isToolCallResult()) {
209
207
  process.stdout.write("[tool:result]\n");
210
208
  }
211
209
  }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * SPIKE (throwaway): measure the real park-event -> record-idle gap for interactive
3
+ * session turns. This is the extra wall-clock Option B (settle-consistent turn stream)
4
+ * would force onto EVERY turn: the turn stream currently ends on the RUN_FINISHED park
5
+ * event, but the session RECORD flips running->idle later, in the async settle lambda.
6
+ *
7
+ * AEX_API_KEY=... DEEPSEEK_API_KEY=... AEX_API_URL=... bun packages/sdk/examples/spike-settle-latency.ts
8
+ *
9
+ * Optional: SPIKE_TURNS=8 (samples), SPIKE_POLL_MS=120 (record poll interval).
10
+ *
11
+ * Method per turn:
12
+ * 1) send a tiny prompt, stream events, capture t_runFinished at the RUN_FINISHED event
13
+ * (this is where the default turn stream ends), then stop consuming.
14
+ * 2) tight-poll session.refresh() until status === "idle"; capture t_idle.
15
+ * 3) settleGapMs = t_idle - t_runFinished <-- the Option B tax.
16
+ * The next turn is only sent AFTER idle, so each measurement is isolated (no reconcile).
17
+ */
18
+ import { Aex, Models, Providers, Sizes } from "@aexhq/sdk";
19
+
20
+ const apiKey = required("AEX_API_KEY");
21
+ const deepseekKey = required("DEEPSEEK_API_KEY");
22
+ const apiUrl = process.env.AEX_API_URL;
23
+ const turns = Number(process.env.SPIKE_TURNS ?? "8");
24
+ const pollMs = Number(process.env.SPIKE_POLL_MS ?? "120");
25
+
26
+ const aex = new Aex({
27
+ apiKey,
28
+ ...(apiUrl ? { baseUrl: apiUrl } : {}),
29
+ retry: { maxAttempts: 4, initialDelayMs: 500, maxDelayMs: 10_000, maxElapsedMs: 90_000 }
30
+ });
31
+
32
+ console.log(`opening session (dev)...`);
33
+ const session = await aex.openSession({
34
+ provider: Providers.DEEPSEEK,
35
+ model: Models.DEEPSEEK_V4_FLASH,
36
+ system: "Reply with a single short sentence. Never use tools or write files.",
37
+ includeBuiltinTools: false,
38
+ tools: [],
39
+ runtime: Sizes.SHARED_0_25X_1GB,
40
+ overrides: { idleTtl: "10m", timeout: "5m", maxSpendUsd: 1 },
41
+ apiKeys: { deepseek: deepseekKey }
42
+ });
43
+ console.log(`session: ${session.id}`);
44
+
45
+ type Sample = { turn: number; turnMs: number; settleGapMs: number; polls: number; timedOut: boolean };
46
+ const samples: Sample[] = [];
47
+
48
+ for (let i = 0; i < turns; i++) {
49
+ const stream = session.send(`Say hello #${i + 1} in one short sentence.`);
50
+ const tStart = performance.now();
51
+ let tRunFinished = 0;
52
+ let errored = false;
53
+ for await (const event of stream) {
54
+ if (event.isRunError()) {
55
+ errored = true;
56
+ tRunFinished = performance.now();
57
+ break;
58
+ }
59
+ if (event.isRunFinished()) {
60
+ tRunFinished = performance.now();
61
+ break; // default turn stream would end HERE
62
+ }
63
+ }
64
+ if (tRunFinished === 0) tRunFinished = performance.now();
65
+ const turnMs = tRunFinished - tStart;
66
+
67
+ // Tight-poll the authoritative record until it leaves running.
68
+ let polls = 0;
69
+ let tIdle = 0;
70
+ let timedOut = false;
71
+ const deadline = performance.now() + 90_000;
72
+ for (;;) {
73
+ polls++;
74
+ const rec = await session.refresh().catch(() => undefined);
75
+ const status = rec?.status;
76
+ if (status === "idle" || status === "suspended" || status === "error") {
77
+ tIdle = performance.now();
78
+ break;
79
+ }
80
+ if (performance.now() >= deadline) {
81
+ tIdle = performance.now();
82
+ timedOut = true;
83
+ break;
84
+ }
85
+ await sleep(pollMs);
86
+ }
87
+ const settleGapMs = tIdle - tRunFinished;
88
+ samples.push({ turn: i + 1, turnMs, settleGapMs, polls, timedOut });
89
+ console.log(
90
+ `turn ${String(i + 1).padStart(2)} turn=${fmt(turnMs)} settleGap=${fmt(settleGapMs)} polls=${polls}` +
91
+ (errored ? " (RUN_ERROR)" : "") +
92
+ (timedOut ? " (TIMEOUT>90s)" : "")
93
+ );
94
+ }
95
+
96
+ const gaps = samples.map((s) => s.settleGapMs).sort((a, b) => a - b);
97
+ console.log("\n=== settle-gap distribution (park event -> record idle) ===");
98
+ console.log(`n=${gaps.length}`);
99
+ console.log(`min ${fmt(gaps[0])}`);
100
+ console.log(`median ${fmt(pct(gaps, 50))}`);
101
+ console.log(`p90 ${fmt(pct(gaps, 90))}`);
102
+ console.log(`max ${fmt(gaps[gaps.length - 1])}`);
103
+ console.log(`mean ${fmt(gaps.reduce((a, b) => a + b, 0) / gaps.length)}`);
104
+ console.log("\nInterpretation: settleGap is the per-turn latency Option B adds before the");
105
+ console.log("caller regains control (the turn stream would block until the record is idle).");
106
+
107
+ function pct(sorted: number[], p: number): number {
108
+ if (sorted.length === 0) return 0;
109
+ const idx = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length));
110
+ return sorted[idx];
111
+ }
112
+ function fmt(ms: number): string {
113
+ return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${Math.round(ms)}ms`;
114
+ }
115
+ function sleep(ms: number): Promise<void> {
116
+ return new Promise((r) => setTimeout(r, ms));
117
+ }
118
+ function required(name: string): string {
119
+ const v = process.env[name];
120
+ if (!v) {
121
+ console.error(`Missing env var ${name}`);
122
+ process.exit(1);
123
+ }
124
+ return v;
125
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexhq/sdk",
3
- "version": "0.39.0",
3
+ "version": "0.40.1",
4
4
  "description": "TypeScript SDK for running autonomous agent sessions across providers (Anthropic, OpenAI, DeepSeek, Gemini, Mistral) behind one interface.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -27,7 +27,7 @@
27
27
  ],
28
28
  "scripts": {
29
29
  "build": "bun ../../scripts/with-generated-dist-lock.mjs bun run build:unlocked",
30
- "build:unlocked": "bun ./scripts/clean-dist.mjs && bun run --cwd ../.. --filter @aexhq/contracts build && bun run --cwd ../.. --filter @aexhq/cli build && tsc -p tsconfig.build.json && bun ./scripts/bundle-cli.mjs && bun ./scripts/inline-contracts.mjs",
30
+ "build:unlocked": "bun ./scripts/clean-dist.mjs && bun run --cwd ../.. --filter @aexhq/contracts build && tsc -p tsconfig.build.json && bun run --cwd ../.. --filter @aexhq/cli build && bun ./scripts/bundle-cli.mjs && bun ./scripts/inline-contracts.mjs",
31
31
  "lint": "bun ../../scripts/with-generated-dist-lock.mjs bun run lint:unlocked",
32
32
  "lint:unlocked": "tsc --noEmit",
33
33
  "test": "bun run test:unit",
@@ -47,6 +47,7 @@
47
47
  "bun": ">=1.3.14"
48
48
  },
49
49
  "dependencies": {
50
- "fflate": "0.8.2"
50
+ "fflate": "0.8.2",
51
+ "ignore": "7.0.5"
51
52
  }
52
53
  }