@aexhq/sdk 0.38.1 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +23 -17
  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 +70 -2
  20. package/dist/_contracts/operations.js +143 -7
  21. package/dist/_contracts/run-config.d.ts +35 -10
  22. package/dist/_contracts/run-config.js +17 -5
  23. package/dist/_contracts/run-record.d.ts +3 -2
  24. package/dist/_contracts/runtime-types.d.ts +148 -36
  25. package/dist/_contracts/runtime-types.js +33 -1
  26. package/dist/_contracts/sdk-errors.d.ts +61 -2
  27. package/dist/_contracts/sdk-errors.js +83 -3
  28. package/dist/_contracts/sdk-secrets.js +31 -6
  29. package/dist/_contracts/stable.d.ts +14 -0
  30. package/dist/_contracts/stable.js +14 -0
  31. package/dist/_contracts/status.d.ts +28 -1
  32. package/dist/_contracts/status.js +48 -3
  33. package/dist/_contracts/submission.d.ts +157 -14
  34. package/dist/_contracts/submission.js +319 -55
  35. package/dist/_contracts/suggest.d.ts +15 -0
  36. package/dist/_contracts/suggest.js +54 -0
  37. package/dist/asset-upload.d.ts +27 -1
  38. package/dist/asset-upload.js +219 -4
  39. package/dist/asset-upload.js.map +1 -1
  40. package/dist/bundle.d.ts +23 -14
  41. package/dist/bundle.js +39 -20
  42. package/dist/bundle.js.map +1 -1
  43. package/dist/canonical-zip.d.ts +68 -0
  44. package/dist/canonical-zip.js +307 -0
  45. package/dist/canonical-zip.js.map +1 -0
  46. package/dist/cli.mjs +1927 -326
  47. package/dist/cli.mjs.sha256 +1 -1
  48. package/dist/client.d.ts +273 -70
  49. package/dist/client.js +885 -335
  50. package/dist/client.js.map +1 -1
  51. package/dist/fetch-archive.js +14 -14
  52. package/dist/fetch-archive.js.map +1 -1
  53. package/dist/file.d.ts +33 -6
  54. package/dist/file.js +120 -54
  55. package/dist/file.js.map +1 -1
  56. package/dist/index.d.ts +23 -14
  57. package/dist/index.js +31 -14
  58. package/dist/index.js.map +1 -1
  59. package/dist/node-fs.d.ts +26 -9
  60. package/dist/node-fs.js +13 -38
  61. package/dist/node-fs.js.map +1 -1
  62. package/dist/node-walk.d.ts +69 -0
  63. package/dist/node-walk.js +146 -0
  64. package/dist/node-walk.js.map +1 -0
  65. package/dist/retry.d.ts +9 -13
  66. package/dist/retry.js +18 -17
  67. package/dist/retry.js.map +1 -1
  68. package/dist/skill.d.ts +151 -0
  69. package/dist/skill.js +298 -0
  70. package/dist/skill.js.map +1 -0
  71. package/dist/tool.d.ts +14 -2
  72. package/dist/tool.js +33 -7
  73. package/dist/tool.js.map +1 -1
  74. package/dist/version.d.ts +1 -1
  75. package/dist/version.js +1 -1
  76. package/docs/authentication.md +29 -5
  77. package/docs/billing.md +6 -0
  78. package/docs/concepts/agent-tools.md +11 -0
  79. package/docs/concepts/composition.md +3 -3
  80. package/docs/defaults.md +1 -0
  81. package/docs/errors.md +64 -4
  82. package/docs/events.md +84 -49
  83. package/docs/limits-and-quotas.md +24 -0
  84. package/docs/mcp.md +3 -2
  85. package/docs/networking.md +7 -1
  86. package/docs/outputs.md +36 -7
  87. package/docs/provider-runtime-capabilities.md +1 -1
  88. package/docs/quickstart.md +17 -7
  89. package/docs/run-config.md +3 -3
  90. package/docs/secrets.md +14 -0
  91. package/docs/skills.md +74 -44
  92. package/docs/vision-skills.md +3 -3
  93. package/examples/feature-tour.ts +4 -6
  94. package/examples/spike-settle-latency.ts +125 -0
  95. package/package.json +4 -3
  96. package/dist/_contracts/event-guards.d.ts +0 -67
  97. package/dist/_contracts/event-guards.js +0 -36
  98. package/dist/skill-tool.d.ts +0 -102
  99. package/dist/skill-tool.js +0 -190
  100. package/dist/skill-tool.js.map +0 -1
package/dist/client.js CHANGED
@@ -1,11 +1,11 @@
1
- import { AexApiError, AexError, CredentialValidationError, DEFAULT_RUN_PROVIDER, HttpClient, RunConfigValidationError, RunStateError, SecretString, customName, isRunSettled, operations, providersForModel, streamCoordinatorEvents, parseRunLimits, parseRuntimeSize, parseRunTimeout, parseRunWebhook, BUILTIN_TOOL_NAMES, TERMINAL_RUN_STATUSES } from "./_contracts/index.js";
1
+ import { AexApiError, CredentialValidationError, HttpClient, PLANE_BASE_URLS, RunConfigValidationError, RunStateError, SecretString, asAexEventView, customName, isRunSettled, assertStreamableOutputMode, operations, parseApiKey, resolveModelProvider, streamCoordinatorEvents, usageFromProviderUsage, parseRunLimits, parseRuntimeSize, parseRunTimeout, parseRunWebhook, BUILTIN_TOOL_NAMES, SKILLS_MAX, TERMINAL_RUN_STATUSES } from "./_contracts/index.js";
2
2
  import { AgentsMd } from "./agents-md.js";
3
- import { uploadAsset } from "./asset-upload.js";
3
+ import { uploadAsset, uploadAssetMultipart } from "./asset-upload.js";
4
4
  import { File } from "./file.js";
5
5
  import { McpServer } from "./mcp-server.js";
6
6
  import { AexRateLimitError, isThrottleFault, parseProviderFault, withRetry } from "./retry.js";
7
7
  import { splitSecretEnv } from "./secret.js";
8
- import { SkillTool } from "./skill-tool.js";
8
+ import { Skill } from "./skill.js";
9
9
  import { Tool } from "./tool.js";
10
10
  export class SessionTurnStream {
11
11
  #run;
@@ -122,9 +122,9 @@ export class SessionHandle {
122
122
  });
123
123
  }
124
124
  async *#send(input, options) {
125
- const idempotencyKey = options.idempotencyKey ?? generateIdempotencyKey();
125
+ const idempotencyKey = operations.resolveIdempotencyKey(options.idempotencyKey);
126
126
  this.#lastSend = { input, idempotencyKey };
127
- const accepted = await operations.sendSessionMessage(this.#http, this.id, { input }, { idempotencyKey });
127
+ const accepted = await this.#acceptTurn(input, idempotencyKey, options.signal);
128
128
  this.#session = accepted.session;
129
129
  const turn = accepted.turn;
130
130
  const events = [];
@@ -135,21 +135,70 @@ export class SessionHandle {
135
135
  events.push(event);
136
136
  yield event;
137
137
  }
138
- const terminalStatus = terminalSessionStatusFromEvents(events, turn.turnSeq);
138
+ // Read the CARRIED terminal outcome from the events (never re-derive a lossy idle).
139
+ const read = terminalSessionStatusFromEvents(events, turn.turnSeq);
140
+ // Await the settle commit by DEFAULT so cost/usage + the terminal outcome are
141
+ // always present; `await: 'park'` returns at the render-complete park event.
139
142
  const readSession = await operations.getSession(this.#http, this.id).catch(() => this.#session);
140
- this.#session = withTerminalSessionStatus(readSession, terminalStatus);
143
+ const settled = (options.await ?? "settle") === "park"
144
+ ? readSession
145
+ : (await settledSessionRecord(this.#http, this.id, readSession, options.signal)) ?? readSession;
146
+ this.#session = withTerminalSessionStatus(settled, read);
141
147
  const outputs = await operations.listSessionOutputs(this.#http, this.id).catch(() => []);
142
148
  const messages = projectAssistantMessages(events);
143
- return {
144
- sessionId: this.id,
145
- session: this.#session,
146
- turn,
147
- status: this.#session.status,
148
- text: assistantTextFromEvents(events),
149
- events,
150
- outputs,
151
- messages
152
- };
149
+ return settledTurnResult(this.id, this.#session, turn, events, outputs, messages, read);
150
+ }
151
+ /**
152
+ * POST the next turn, reconciling the settle-lag race. A turn stream ends on
153
+ * the idle park EVENT, but the session RECORD can lag at `running` for a short
154
+ * window before the platform commits it — so an immediate follow-up `send()`
155
+ * 409s `session_busy` even though, from the caller's view, the previous turn
156
+ * already parked. When that happens we poll the record until it leaves
157
+ * `running`, then retry the SAME idempotent POST (a replay de-duplicates, so
158
+ * this never creates a second billable turn). A session that stays busy past
159
+ * {@link SESSION_BUSY_RECONCILE_DEADLINE_MS} is a genuinely in-flight turn: we
160
+ * surface a clear, bounded {@link RunStateError} rather than masking it or
161
+ * waiting forever. Any non-`running` busy status (suspended / cancelling /
162
+ * deleted) is a real rejection and passes straight through.
163
+ */
164
+ async #acceptTurn(input, idempotencyKey, signal) {
165
+ const deadline = Date.now() + SESSION_BUSY_RECONCILE_DEADLINE_MS;
166
+ for (;;) {
167
+ try {
168
+ return await operations.sendSessionMessage(this.#http, this.id, { input }, { idempotencyKey });
169
+ }
170
+ catch (err) {
171
+ if (!isSettlingSessionBusy(err) || signal?.aborted)
172
+ throw err;
173
+ if (Date.now() >= deadline) {
174
+ throw new RunStateError(`SessionHandle.send: session ${this.id} was still running ${SESSION_BUSY_RECONCILE_DEADLINE_MS}ms after the previous turn parked — a turn is still in flight`, { sessionId: this.id, status: "running", cause: err });
175
+ }
176
+ await this.#awaitLeftRunning(deadline, signal);
177
+ }
178
+ }
179
+ }
180
+ /**
181
+ * Poll the session record until it is no longer `running`/`creating`, or the
182
+ * reconcile deadline elapses. Updates the stored record so the eventual retry
183
+ * (or the terminal error) reflects the freshest status.
184
+ */
185
+ async #awaitLeftRunning(deadline, signal) {
186
+ for (;;) {
187
+ try {
188
+ await sleep(SESSION_BUSY_RECONCILE_INTERVAL_MS, signal);
189
+ }
190
+ catch {
191
+ return; // aborted — let the retry POST surface the real state
192
+ }
193
+ const record = await operations.getSession(this.#http, this.id).catch(() => undefined);
194
+ if (record !== undefined) {
195
+ this.#session = record;
196
+ if (record.status !== "running" && record.status !== "creating")
197
+ return;
198
+ }
199
+ if (Date.now() >= deadline)
200
+ return;
201
+ }
153
202
  }
154
203
  async suspend(options = {}) {
155
204
  const accepted = await operations.suspendSession(this.#http, this.id, options);
@@ -172,6 +221,39 @@ export class SessionHandle {
172
221
  this.#session = accepted.session;
173
222
  }
174
223
  }
224
+ /**
225
+ * Request the HITL write-gate: park this session `awaiting_approval` before
226
+ * its next gated action. Imperative counterpart to the declarative
227
+ * `approvalGate` submission option. Resume with {@link approve} / reject with
228
+ * {@link deny}.
229
+ */
230
+ async requestApproval(options = {}) {
231
+ const accepted = await operations.requestApproval(this.#http, this.id, options);
232
+ this.#session = accepted.session;
233
+ return accepted;
234
+ }
235
+ /** Approve an `awaiting_approval` session so the held turn resumes (→ running). */
236
+ async approve(options = {}) {
237
+ const accepted = await operations.approveSession(this.#http, this.id, options);
238
+ this.#session = accepted.session;
239
+ return accepted;
240
+ }
241
+ /** Deny an `awaiting_approval` session so the held turn is cancelled (→ cancelled). */
242
+ async deny(options = {}) {
243
+ const accepted = await operations.denySession(this.#http, this.id, options);
244
+ this.#session = accepted.session;
245
+ return accepted;
246
+ }
247
+ /**
248
+ * Enumerate this run's subagent CHILD runs (`GET /runs/:id/children`). Each is
249
+ * a {@link ChildRunHandle} backed by the RUN facade (getRun/events/outputs) —
250
+ * NOT `openSession` — so every child the platform hands you is resolvable, with
251
+ * its lineage (`parentRunId`/`depth`) and terminal outcome exposed.
252
+ */
253
+ async children() {
254
+ const refs = await operations.listRunChildren(this.#http, this.id);
255
+ return refs.map((ref) => new ChildRunHandle(this.#http, ref, this.#fetch));
256
+ }
175
257
  /**
176
258
  * Accessor for the session's assistant messages. `all()` returns them
177
259
  * oldest-first; `last()`/`first()` return a single entry or `undefined` when
@@ -209,14 +291,14 @@ export class SessionHandle {
209
291
  }
210
292
  /**
211
293
  * Accessor for the session's event stream: the buffered `SessionEvent`
212
- * snapshots (`list`/`last`/`first`), the polling `RunEvent` iterator
294
+ * snapshots (`list`/`last`/`first`), the polling `AexEventView` iterator
213
295
  * (`stream`), the live coordinator envelope iterator (`streamEnvelopes`), and
214
296
  * the events-namespace archive (`archiveLink`/`download`).
215
297
  */
216
298
  events() {
217
299
  const http = this.#http;
218
300
  const id = this.id;
219
- const list = () => operations.listSessionEvents(http, id);
301
+ const list = async () => (await operations.listSessionEvents(http, id)).map(asAexEventView);
220
302
  return {
221
303
  list,
222
304
  last: async () => (await list()).at(-1),
@@ -315,7 +397,7 @@ export class SessionClient {
315
397
  }
316
398
  async create(options) {
317
399
  const request = await this.#buildCreateRequest(options);
318
- const session = await operations.createSession(this.#http, request, { idempotencyKey: options.idempotencyKey ?? generateIdempotencyKey() });
400
+ const session = await operations.createSession(this.#http, request, { idempotencyKey: operations.resolveIdempotencyKey(options.idempotencyKey) });
319
401
  return new SessionHandle(this.#http, session, this.#fetch);
320
402
  }
321
403
  async open(sessionId) {
@@ -340,65 +422,73 @@ export class SessionClient {
340
422
  outputs(sessionId) {
341
423
  return sessionOutputs(this.#http, sessionId, this.#fetch);
342
424
  }
343
- /**
344
- * Find output files across sessions by filename / extension / content type.
345
- * Returns lean REFERENCE hits (never bytes; fetch content with `readOutput`).
346
- * Scope the search to a corpus with `query.runIds` (a session-id allow-list);
347
- * omit it to scan every session in the workspace. Composed client-side (per-
348
- * session `listSessionOutputs` + the contracts output filter), bounded by
349
- * `query.limit` (default 100).
350
- */
351
- async searchOutputs(query = {}) {
352
- // Dedup the caller-supplied allow-list so a run repeated in `runIds` (e.g. from
353
- // concatenating corpora) isn't scanned twice and doesn't inflate the hit count
354
- // with duplicates (pre-launch edge-sweep F27).
425
+ async run(options) {
426
+ const { message, deleteAfter, messageIdempotencyKey, stream, ...createOptions } = options;
427
+ assertNoLegacySessionFields(options, "Aex.sessions.run");
428
+ const input = normaliseSessionInput(message, "Aex.sessions.run", "message");
429
+ // Derive the message key from the create key (like the CLI) so a retried run
430
+ // with the same `idempotencyKey` de-duplicates BOTH the create and the
431
+ // billable turn — never a duplicate billable run.
432
+ const createKey = operations.resolveIdempotencyKey(createOptions.idempotencyKey);
433
+ const messageKey = messageIdempotencyKey !== undefined ? operations.resolveIdempotencyKey(messageIdempotencyKey) : deriveMessageKey(createKey);
434
+ const session = await this.create({ ...createOptions, idempotencyKey: createKey });
435
+ const result = await session.send(input, {
436
+ ...(stream ?? {}),
437
+ idempotencyKey: messageKey
438
+ }).done();
439
+ if (deleteAfter) {
440
+ await session.delete();
441
+ }
442
+ return result;
443
+ }
444
+ }
445
+ /**
446
+ * Cross-run output search (`aex.outputs`). Composed client-side (per-run
447
+ * `listSessionOutputs` + the contracts output filter): scope a corpus with
448
+ * `query.runIds`, or omit it to scan every run in the workspace. Metadata-only
449
+ * (reference hits, no bytes); a content-shaped query throws a typed
450
+ * "content search unsupported".
451
+ */
452
+ export class OutputsClient {
453
+ #http;
454
+ constructor(http) {
455
+ this.#http = http;
456
+ }
457
+ async search(query = {}) {
458
+ assertMetadataOnlyOutputSearch(query, "aex.outputs.search");
459
+ // Dedup the caller-supplied allow-list so a run repeated in `runIds` (from
460
+ // concatenating corpora) isn't scanned twice and doesn't inflate hit count.
355
461
  const unscoped = query.runIds === undefined;
356
- const sessionIds = unscoped ? await this.#allSessionIds() : [...new Set(query.runIds)];
462
+ const runIds = unscoped ? await this.#allRunIds() : [...new Set(query.runIds)];
357
463
  const limit = query.limit ?? 100;
358
- // Translate the search query to an OutputQuery so the contracts output
359
- // filter does the matching — no re-derived filter logic here.
360
- const outputQuery = {
361
- ...(query.filename ? { filename: new RegExp(escapeRegExp(query.filename), "i") } : {}),
362
- ...(query.extension ? { extension: query.extension } : {}),
363
- ...(query.contentType ? { contentType: query.contentType } : {})
364
- };
365
- const hasFilter = Object.keys(outputQuery).length > 0;
366
464
  const hits = [];
367
- for (const sessionId of sessionIds) {
465
+ for (const runId of runIds) {
368
466
  let outputs;
369
467
  try {
370
- outputs = hasFilter
371
- ? await operations.listSessionOutputs(this.#http, sessionId, outputQuery)
372
- : await operations.listSessionOutputs(this.#http, sessionId);
468
+ outputs = await searchRunOutputs(this.#http, runId, query);
373
469
  }
374
470
  catch (err) {
375
471
  if (unscoped && isMissingOutputsSession(err))
376
472
  continue;
377
473
  throw err;
378
474
  }
379
- for (const o of outputs) {
380
- hits.push({
381
- runId: sessionId,
382
- outputId: o.id,
383
- ...(o.filename !== undefined ? { filename: o.filename } : {}),
384
- ...(o.sizeBytes !== undefined ? { sizeBytes: o.sizeBytes } : {}),
385
- ...(o.contentType !== undefined ? { contentType: o.contentType } : {})
386
- });
475
+ for (const hit of outputHits(runId, outputs, limit - hits.length)) {
476
+ hits.push(hit);
387
477
  if (hits.length >= limit)
388
478
  return { hits };
389
479
  }
390
480
  }
391
481
  return { hits };
392
482
  }
393
- /** Enumerate every session id in the workspace by paging `listSessions`. */
394
- async #allSessionIds() {
483
+ /** Enumerate every run id in the workspace by paging `listSessions`. */
484
+ async #allRunIds() {
395
485
  const ids = [];
396
486
  const seenCursors = new Set();
397
487
  let cursor;
398
488
  do {
399
489
  if (cursor !== undefined) {
400
490
  if (seenCursors.has(cursor)) {
401
- throw new Error("Aex.sessions.searchOutputs: listSessions returned a repeated cursor");
491
+ throw new Error("aex.outputs.search: listSessions returned a repeated cursor");
402
492
  }
403
493
  seenCursors.add(cursor);
404
494
  }
@@ -409,29 +499,152 @@ export class SessionClient {
409
499
  } while (cursor);
410
500
  return ids;
411
501
  }
412
- async run(options) {
413
- const { message, deleteAfter, messageIdempotencyKey, stream, ...createOptions } = options;
414
- assertNoLegacySessionFields(options, "Aex.sessions.run");
415
- const input = normaliseSessionInput(message, "Aex.sessions.run", "message");
416
- // Derive the message key from the create key (like the CLI) so a retried run
417
- // with the same `idempotencyKey` de-duplicates BOTH the create and the
418
- // billable turn never a duplicate billable run.
419
- const createKey = createOptions.idempotencyKey ?? generateIdempotencyKey();
420
- const messageKey = messageIdempotencyKey ?? deriveMessageKey(createKey);
421
- const session = await this.create({ ...createOptions, idempotencyKey: createKey });
422
- const result = await session.send(input, {
423
- ...(stream ?? {}),
424
- idempotencyKey: messageKey
425
- }).done();
426
- if (deleteAfter) {
427
- await session.delete();
502
+ }
503
+ /**
504
+ * A first-class, lineage-discoverable SUBAGENT CHILD run — handed out by
505
+ * `session.children()` / `run.children()`, backed by the RUN facade
506
+ * (getRun/events/outputs), NOT `openSession`. Every child the platform hands you
507
+ * is resolvable through this handle; its lineage (`parentRunId`/`depth`) and
508
+ * terminal outcome (`status`) are first-class.
509
+ */
510
+ export class ChildRunHandle {
511
+ #http;
512
+ #fetch;
513
+ #ref;
514
+ constructor(http, ref, fetch) {
515
+ this.#http = http;
516
+ this.#ref = ref;
517
+ this.#fetch = fetch;
518
+ }
519
+ get id() {
520
+ return this.#ref.id;
521
+ }
522
+ get parentRunId() {
523
+ return this.#ref.parentRunId;
524
+ }
525
+ get depth() {
526
+ return this.#ref.depth;
527
+ }
528
+ /** The child's run status (a real run terminal outcome once settled). */
529
+ get status() {
530
+ return this.#ref.status;
531
+ }
532
+ get ref() {
533
+ return this.#ref;
534
+ }
535
+ /** Re-read the child run record (status, lineage, costTelemetry). */
536
+ get() {
537
+ return operations.getRun(this.#http, this.id);
538
+ }
539
+ /** The child's events over the RUN facade (`/runs/:id/events`). */
540
+ events() {
541
+ return runEventsAccessor(this.#http, this.id);
542
+ }
543
+ /** The child's captured outputs over the RUN facade (`/runs/:id/outputs`). */
544
+ outputs() {
545
+ return runOutputsAccessor(this.#http, this.id, this.#fetch);
546
+ }
547
+ /** This child's own subagent children (recursive lineage). */
548
+ async children() {
549
+ const refs = await operations.listRunChildren(this.#http, this.id);
550
+ return refs.map((ref) => new ChildRunHandle(this.#http, ref, this.#fetch));
551
+ }
552
+ /** Cancel the child run (run facade `POST /runs/:id/cancel`). */
553
+ cancel() {
554
+ return operations.cancelRun(this.#http, this.id);
555
+ }
556
+ }
557
+ /** Run-facade events accessor (list + polling stream) — used by {@link ChildRunHandle}. */
558
+ function runEventsAccessor(http, id) {
559
+ return {
560
+ list: async () => (await operations.listRunEvents(http, id)).map(asAexEventView),
561
+ stream: (options) => streamRunEventsPolling(http, id, options ?? {})
562
+ };
563
+ }
564
+ /** Run-facade outputs accessor — used by {@link ChildRunHandle}. */
565
+ function runOutputsAccessor(http, id, fetchLike) {
566
+ void fetchLike;
567
+ return {
568
+ list: (query) => operations.listOutputs(http, id, query),
569
+ find: (query) => operations.findOutputs(http, id, query),
570
+ findOne: (query) => operations.findOutput(http, id, query),
571
+ read: (selector, options) => operations.readOutputText(http, id, selector, options),
572
+ link: (selectorOrQuery, options) => operations.outputLink(http, id, selectorOrQuery, options),
573
+ download: (selector, options) => downloadSessionOutput(http, id, selector, options)
574
+ };
575
+ }
576
+ /**
577
+ * Poll a RUN's events (via the run facade) until it reaches a terminal status,
578
+ * the signal aborts, or the caller breaks the iterator. Uses `getRun` (not
579
+ * `getSession`) so it resolves for a child run that has no session facade.
580
+ */
581
+ async function* streamRunEventsPolling(http, id, options) {
582
+ if (options.signal?.aborted)
583
+ return;
584
+ const seenIds = new Set();
585
+ const intervalMs = options.intervalMs ?? 1_000;
586
+ const signal = options.signal;
587
+ while (!signal?.aborted) {
588
+ const events = await operations.listRunEvents(http, id);
589
+ for (const event of events) {
590
+ if (!seenIds.has(event.id)) {
591
+ seenIds.add(event.id);
592
+ yield asAexEventView(event);
593
+ }
594
+ }
595
+ const run = await operations.getRun(http, id);
596
+ if (isSessionParked(run.status))
597
+ return;
598
+ try {
599
+ await sleep(intervalMs, signal);
600
+ }
601
+ catch {
602
+ return;
428
603
  }
429
- return result;
430
604
  }
431
605
  }
606
+ /** Default / max concurrent items for {@link Aex.batch} (bounded below the tier cap). */
607
+ const DEFAULT_BATCH_CONCURRENCY = 4;
608
+ const BATCH_MAX_CONCURRENCY = 10;
609
+ /** Sum the per-item settled cost/usage into a real {@link BatchResult} rollup. */
610
+ function rollupBatch(results) {
611
+ let totalCostUsd = 0;
612
+ let okCount = 0;
613
+ const failed = [];
614
+ for (const result of results) {
615
+ totalCostUsd += result.costUsd;
616
+ if (result.ok)
617
+ okCount += 1;
618
+ else
619
+ failed.push(result);
620
+ }
621
+ return {
622
+ results,
623
+ totalCostUsd,
624
+ totalUsage: sumUsageSummaries(results.map((result) => result.usage)),
625
+ okCount,
626
+ failed
627
+ };
628
+ }
629
+ /** Field-wise sum of {@link UsageSummary} objects; a field is present iff some item carried it. */
630
+ function sumUsageSummaries(usages) {
631
+ const keys = ["inputTokens", "outputTokens", "cacheReadInputTokens", "cacheCreationInputTokens", "totalTokens"];
632
+ const out = {};
633
+ for (const key of keys) {
634
+ let sum;
635
+ for (const usage of usages) {
636
+ const value = usage[key];
637
+ if (typeof value === "number")
638
+ sum = (sum ?? 0) + value;
639
+ }
640
+ if (sum !== undefined)
641
+ out[key] = sum;
642
+ }
643
+ return out;
644
+ }
432
645
  async function* streamSessionTurnEvents(http, sessionId, turn, options) {
433
646
  const first = await operations.getSessionCoordinatorTicket(http, sessionId);
434
- yield* streamCoordinatorEvents({
647
+ for await (const event of streamCoordinatorEvents({
435
648
  wsUrl: first.wsUrl,
436
649
  from: options.from ?? 0,
437
650
  fetchTicket: async () => (await operations.getSessionCoordinatorTicket(http, sessionId)).ticket,
@@ -440,12 +653,16 @@ async function* streamSessionTurnEvents(http, sessionId, turn, options) {
440
653
  ...(options.webSocketFactory ? { webSocketFactory: options.webSocketFactory } : {}),
441
654
  ...(options.idleTimeoutMs !== undefined ? { idleTimeoutMs: options.idleTimeoutMs } : {}),
442
655
  ...(options.pingIntervalMs !== undefined ? { pingIntervalMs: options.pingIntervalMs } : {})
443
- });
656
+ })) {
657
+ yield asAexEventView(event);
658
+ }
444
659
  }
445
660
  /**
446
- * Poll the session's `RunEvent` snapshots until the session parks, the signal
447
- * aborts, or the caller breaks the iterator, deduping by event id. Module-level
448
- * so `SessionHandle.events()` can hand it to its accessor object literal.
661
+ * Poll the session's event snapshots until the session parks, the signal
662
+ * aborts, or the caller breaks the iterator, deduping by event id. Yields the
663
+ * one canonical guard-bearing {@link AexEventView} (same shape as every other
664
+ * event surface). Module-level so `SessionHandle.events()` can hand it to its
665
+ * accessor object literal.
449
666
  */
450
667
  async function* streamSessionEventsPolling(http, id, options) {
451
668
  if (options.signal?.aborted)
@@ -458,7 +675,7 @@ async function* streamSessionEventsPolling(http, id, options) {
458
675
  for (const event of events) {
459
676
  if (!seenIds.has(event.id)) {
460
677
  seenIds.add(event.id);
461
- yield event;
678
+ yield asAexEventView(event);
462
679
  }
463
680
  }
464
681
  const session = await operations.getSession(http, id);
@@ -481,7 +698,7 @@ async function* streamSessionEventsPolling(http, id, options) {
481
698
  */
482
699
  async function* streamSessionEnvelopes(http, id, options) {
483
700
  const first = await operations.getSessionCoordinatorTicket(http, id);
484
- yield* streamCoordinatorEvents({
701
+ for await (const event of streamCoordinatorEvents({
485
702
  wsUrl: first.wsUrl,
486
703
  from: options.from ?? 0,
487
704
  fetchTicket: async () => (await operations.getSessionCoordinatorTicket(http, id)).ticket,
@@ -489,7 +706,9 @@ async function* streamSessionEnvelopes(http, id, options) {
489
706
  // the earlier RUN_FINISHED UX signal.
490
707
  ...(options.settleConsistent ? { isTerminal: isRunSettled } : {}),
491
708
  ...(options.signal ? { signal: options.signal } : {})
492
- });
709
+ })) {
710
+ yield asAexEventView(event);
711
+ }
493
712
  }
494
713
  /**
495
714
  * Download captured deliverables. No selector → the full outputs namespace as a
@@ -497,17 +716,11 @@ async function* streamSessionEnvelopes(http, id, options) {
497
716
  * `SessionHandle.outputs()` can hand it to its accessor object literal.
498
717
  */
499
718
  async function downloadSessionOutput(http, id, selector, options) {
500
- let bytes;
501
- if (selector === undefined) {
502
- bytes = await operations.downloadOutputs(http, id);
503
- }
504
- else {
505
- const output = isOutputPathSelector(selector)
506
- ? resolveOutputFileSelector(await operations.listOutputs(http, id), selector, id)
507
- : resolveOutputFileSelector([], selector, id);
508
- const { response } = await http.download(`/api/runs/${encodeURIComponent(id)}/outputs/${encodeURIComponent(output.id)}/download`);
509
- bytes = new Uint8Array(await response.arrayBuffer());
510
- }
719
+ // One selector-resolution path: the contracts `downloadOutput` lists-if-path
720
+ // then downloads, throwing with PUBLIC verb names — no duplicated resolver.
721
+ const bytes = selector === undefined
722
+ ? await operations.downloadOutputs(http, id)
723
+ : (await operations.downloadOutput(http, id, selector)).bytes;
511
724
  return writeOptionalFile(bytes, options?.to);
512
725
  }
513
726
  /**
@@ -526,6 +739,11 @@ function sessionOutputs(http, id, fetchLike) {
526
739
  read: (selector, options) => operations.readOutputText(http, id, selector, options),
527
740
  find: (query) => operations.findOutputs(http, id, query),
528
741
  findOne: (query) => operations.findOutput(http, id, query),
742
+ search: async (query = {}) => {
743
+ assertMetadataOnlyOutputSearch(query, "outputs().search");
744
+ const outputs = await searchRunOutputs(http, id, query);
745
+ return { hits: outputHits(id, outputs, query.limit ?? 100) };
746
+ },
529
747
  link: (selectorOrQuery, options) => operations.outputLink(http, id, selectorOrQuery, options),
530
748
  fetch: async (selectorOrQuery, options) => {
531
749
  const link = await operations.outputLink(http, id, selectorOrQuery, options);
@@ -534,6 +752,51 @@ function sessionOutputs(http, id, fetchLike) {
534
752
  download: (selector, options) => downloadSessionOutput(http, id, selector, options)
535
753
  };
536
754
  }
755
+ /**
756
+ * List one run's outputs matching a metadata search: `extension`/`contentType`
757
+ * via the contracts filter, and `filename` via {@link operations.toFilenameMatcher}
758
+ * — a case-insensitive SUBSTRING for a string, `.test` for a RegExp (RegExp-safe;
759
+ * no `escapeRegExp` footgun on a reused pattern).
760
+ */
761
+ async function searchRunOutputs(http, runId, query) {
762
+ const listQuery = {
763
+ ...(query.extension !== undefined ? { extension: query.extension } : {}),
764
+ ...(query.contentType !== undefined ? { contentType: query.contentType } : {})
765
+ };
766
+ const outputs = await operations.listSessionOutputs(http, runId, Object.keys(listQuery).length > 0 ? listQuery : undefined);
767
+ if (query.filename === undefined)
768
+ return outputs;
769
+ const match = operations.toFilenameMatcher(query.filename);
770
+ return outputs.filter((output) => typeof output.filename === "string" && match(output.filename));
771
+ }
772
+ /**
773
+ * Fail-fast on a CONTENT-shaped search: the search surface is metadata-only, so
774
+ * a `content`/`text`/`query` needle throws a typed error rather than silently
775
+ * returning 0 hits (which reads as "no matches" for a query that was never run).
776
+ */
777
+ function assertMetadataOnlyOutputSearch(query, surface) {
778
+ for (const key of ["content", "text", "query", "grep", "body"]) {
779
+ if (Object.prototype.hasOwnProperty.call(query, key)) {
780
+ throw new RunConfigValidationError(`${surface}: content search is not supported — search matches on filename/extension/contentType metadata only`, { field: key });
781
+ }
782
+ }
783
+ }
784
+ /** Project a run's output files to reference-only {@link OutputSearchHit}s, capped. */
785
+ function outputHits(runId, outputs, limit) {
786
+ const hits = [];
787
+ for (const o of outputs) {
788
+ hits.push({
789
+ runId,
790
+ outputId: o.id,
791
+ ...(o.filename !== undefined ? { filename: o.filename } : {}),
792
+ ...(o.sizeBytes !== undefined ? { sizeBytes: o.sizeBytes } : {}),
793
+ ...(o.contentType !== undefined ? { contentType: o.contentType } : {})
794
+ });
795
+ if (hits.length >= limit)
796
+ break;
797
+ }
798
+ return hits;
799
+ }
537
800
  function messageFromWire(message) {
538
801
  return {
539
802
  id: message.id,
@@ -603,8 +866,7 @@ function runTraceFromEvents(events) {
603
866
  }
604
867
  function assistantTextEntriesFromEvents(events) {
605
868
  const out = [];
606
- for (const raw of events) {
607
- const event = raw;
869
+ for (const event of events) {
608
870
  if (event.type !== "TEXT_MESSAGE_CONTENT")
609
871
  continue;
610
872
  const data = asRecord(event.data);
@@ -615,11 +877,10 @@ function assistantTextEntriesFromEvents(events) {
615
877
  const messageId = typeof data.messageId === "string" ? data.messageId : undefined;
616
878
  if (messageId !== undefined)
617
879
  entry.messageId = messageId;
618
- if (typeof event.seq === "number")
619
- entry.seq = event.seq;
620
- const recordedAt = typeof event.recordedAt === "string" ? event.recordedAt : undefined;
621
- if (recordedAt !== undefined)
622
- entry.recordedAt = recordedAt;
880
+ if (typeof event.sequence === "number")
881
+ entry.seq = event.sequence;
882
+ if (typeof event.time === "string")
883
+ entry.recordedAt = event.time;
623
884
  out.push(entry);
624
885
  }
625
886
  return out;
@@ -641,10 +902,10 @@ function toolCallsFromEvents(events) {
641
902
  const messageId = typeof data.messageId === "string" ? data.messageId : undefined;
642
903
  if (messageId !== undefined)
643
904
  trace.messageId = messageId;
644
- if (typeof event.seq === "number")
645
- trace.startSeq = event.seq;
646
- if (typeof event.recordedAt === "string")
647
- trace.startedAt = event.recordedAt;
905
+ if (typeof event.sequence === "number")
906
+ trace.startSeq = event.sequence;
907
+ if (typeof event.time === "string")
908
+ trace.startedAt = event.time;
648
909
  if (!byId.has(id))
649
910
  order.push(id);
650
911
  byId.set(id, trace);
@@ -658,10 +919,10 @@ function toolCallsFromEvents(events) {
658
919
  isError: data.isError === true,
659
920
  content: data.content ?? null
660
921
  };
661
- if (typeof event.seq === "number")
662
- result.seq = event.seq;
663
- if (typeof event.recordedAt === "string")
664
- result.recordedAt = event.recordedAt;
922
+ if (typeof event.sequence === "number")
923
+ result.seq = event.sequence;
924
+ if (typeof event.time === "string")
925
+ result.recordedAt = event.time;
665
926
  let trace = byId.get(id);
666
927
  if (trace === undefined) {
667
928
  trace = { id, name: "", args: {} };
@@ -676,10 +937,6 @@ function toolCallsFromEvents(events) {
676
937
  }
677
938
  return order.map((id) => byId.get(id));
678
939
  }
679
- /** True when a usage summary actually carries at least one token count. */
680
- function hasUsageCounts(usage) {
681
- return !!usage && Object.values(usage).some((n) => typeof n === "number");
682
- }
683
940
  function usageFromEvents(events) {
684
941
  const totals = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, cacheCreationInputTokens: 0 };
685
942
  let seen = false;
@@ -734,14 +991,43 @@ function durationMs(start, end) {
734
991
  const delta = b - a;
735
992
  return delta >= 0 ? delta : undefined;
736
993
  }
994
+ const SESSION_TERMINAL_READS = new Set([
995
+ "succeeded",
996
+ "failed",
997
+ "timed_out",
998
+ "cancelled",
999
+ "idle",
1000
+ "suspended",
1001
+ "awaiting_approval"
1002
+ ]);
1003
+ /** The CUSTOM `aex.session.<name>` terminal event names → the carried read. */
1004
+ const SESSION_TERMINAL_EVENT_READS = {
1005
+ "aex.session.succeeded": "succeeded",
1006
+ "aex.session.failed": "failed",
1007
+ // Clean-cut: the bare `error` park is now `failed` (one terminal vocabulary).
1008
+ "aex.session.error": "failed",
1009
+ "aex.session.timed_out": "timed_out",
1010
+ "aex.session.cancelled": "cancelled",
1011
+ "aex.session.idle": "idle",
1012
+ "aex.session.suspended": "suspended",
1013
+ "aex.session.awaiting_approval": "awaiting_approval"
1014
+ };
1015
+ /** An explicit `data.value.outcome` a park/settle event may carry (the authoritative outcome). */
1016
+ function carriedOutcome(event) {
1017
+ const value = event.data.value;
1018
+ if (!value || typeof value !== "object" || Array.isArray(value))
1019
+ return undefined;
1020
+ const outcome = value.outcome;
1021
+ return typeof outcome === "string" && SESSION_TERMINAL_READS.has(outcome)
1022
+ ? outcome
1023
+ : undefined;
1024
+ }
737
1025
  function isSessionTurnTerminalEvent(event, turnSeq) {
738
1026
  if (event.type === "RUN_FINISHED" || event.type === "RUN_ERROR") {
739
1027
  return true;
740
1028
  }
741
1029
  const name = customName(event);
742
- if (name !== "aex.session.idle" &&
743
- name !== "aex.session.suspended" &&
744
- name !== "aex.session.error") {
1030
+ if (name === null || !(name in SESSION_TERMINAL_EVENT_READS)) {
745
1031
  return false;
746
1032
  }
747
1033
  const value = event.data.value;
@@ -751,27 +1037,31 @@ function isSessionTurnTerminalEvent(event, turnSeq) {
751
1037
  const eventTurnSeq = value.turnSeq;
752
1038
  return typeof eventTurnSeq !== "number" || eventTurnSeq === turnSeq;
753
1039
  }
1040
+ /**
1041
+ * Read the CARRIED terminal outcome/park from the turn's terminal event — never
1042
+ * a lossy re-derivation. Prefers an explicit `data.value.outcome`, then the
1043
+ * `aex.session.<name>` name, then RUN_ERROR→failed / RUN_FINISHED→succeeded.
1044
+ */
754
1045
  function terminalSessionStatusFromEvents(events, turnSeq) {
755
1046
  for (let i = events.length - 1; i >= 0; i--) {
756
1047
  const event = events[i];
757
1048
  if (!isSessionTurnTerminalEvent(event, turnSeq))
758
1049
  continue;
1050
+ const carried = carriedOutcome(event);
1051
+ if (carried !== undefined)
1052
+ return carried;
759
1053
  if (event.type === "RUN_ERROR")
760
- return "error";
1054
+ return "failed";
761
1055
  if (event.type === "RUN_FINISHED")
762
- return "idle";
1056
+ return "succeeded";
763
1057
  const name = customName(event);
764
- if (name === "aex.session.idle")
765
- return "idle";
766
- if (name === "aex.session.suspended")
767
- return "suspended";
768
- if (name === "aex.session.error")
769
- return "error";
1058
+ if (name !== null && name in SESSION_TERMINAL_EVENT_READS)
1059
+ return SESSION_TERMINAL_EVENT_READS[name];
770
1060
  }
771
1061
  return undefined;
772
1062
  }
773
- function withTerminalSessionStatus(session, terminalStatus) {
774
- if (terminalStatus === undefined || session.status === terminalStatus)
1063
+ function withTerminalSessionStatus(session, read) {
1064
+ if (read === undefined || session.status === read)
775
1065
  return session;
776
1066
  if (session.status !== "creating" &&
777
1067
  session.status !== "running" &&
@@ -779,7 +1069,108 @@ function withTerminalSessionStatus(session, terminalStatus) {
779
1069
  session.status !== "cancelling") {
780
1070
  return session;
781
1071
  }
782
- return { ...session, status: terminalStatus };
1072
+ return { ...session, status: read };
1073
+ }
1074
+ /** Map a terminal READ to the 4-value {@link SessionTerminalOutcome} for the result `status`. */
1075
+ function readToOutcome(read) {
1076
+ return read === "failed" || read === "timed_out" || read === "cancelled" ? read : "succeeded";
1077
+ }
1078
+ /** `true` for an OK terminal read (succeeded / resumable idle|suspended / held awaiting_approval). */
1079
+ function isTerminalReadOk(read) {
1080
+ return read !== "failed" && read !== "timed_out" && read !== "cancelled";
1081
+ }
1082
+ /** Best-effort READ off a settled record when the events carried none. */
1083
+ function sessionRecordRead(session) {
1084
+ const outcome = session.lastTurnOutcome;
1085
+ if (outcome !== undefined && SESSION_TERMINAL_READS.has(outcome))
1086
+ return outcome;
1087
+ const status = session.status;
1088
+ if (status === "idle" || status === "suspended" || status === "awaiting_approval")
1089
+ return status;
1090
+ if (status === "succeeded" || status === "failed" || status === "timed_out" || status === "cancelled")
1091
+ return status;
1092
+ if (status === "error")
1093
+ return "failed";
1094
+ return "succeeded";
1095
+ }
1096
+ /** The settle-written provider usage entries (the SINGLE token-usage source). */
1097
+ function providerUsageOf(record) {
1098
+ const telemetry = record.costTelemetry;
1099
+ return telemetry?.providerUsage;
1100
+ }
1101
+ /**
1102
+ * The IMMEDIATE authoritative failure text — the terminal `RUN_ERROR` event's
1103
+ * `data.failureMessage`. `result.error` reads this FIRST so a failed (e.g.
1104
+ * bad-BYOK) run's error is never empty even before the settle-lagged
1105
+ * `sessionRecord.errorMessage` lands.
1106
+ */
1107
+ function failureFromEvents(events) {
1108
+ for (let i = events.length - 1; i >= 0; i--) {
1109
+ const event = events[i];
1110
+ if (event.type !== "RUN_ERROR")
1111
+ continue;
1112
+ const data = asRecord(event.data);
1113
+ for (const key of ["failureMessage", "message", "error"]) {
1114
+ const value = data[key];
1115
+ if (typeof value === "string" && value.length > 0)
1116
+ return value;
1117
+ }
1118
+ }
1119
+ return undefined;
1120
+ }
1121
+ /** The typed schema-decode outcome from the terminal `aex.result.*` event, if any. */
1122
+ function outcomeFromEvents(events) {
1123
+ for (let i = events.length - 1; i >= 0; i--) {
1124
+ const event = events[i];
1125
+ if (event.isResultDecoded()) {
1126
+ const value = asRecord(event.data).value;
1127
+ const decoded = value && typeof value === "object" && !Array.isArray(value) && "value" in value
1128
+ ? value.value
1129
+ : value;
1130
+ return { kind: "decoded", value: decoded };
1131
+ }
1132
+ if (event.isResultRefused()) {
1133
+ const payload = asRecord(asRecord(event.data).value);
1134
+ const reason = payload.reason;
1135
+ const detail = typeof payload.detail === "string" ? payload.detail : undefined;
1136
+ return {
1137
+ kind: "refused",
1138
+ reason: reason === "schema_violation" || reason === "uncertain" || reason === "refused" ? reason : "refused",
1139
+ ...(detail !== undefined ? { detail } : {})
1140
+ };
1141
+ }
1142
+ }
1143
+ return undefined;
1144
+ }
1145
+ /**
1146
+ * Build the ONE unified settled turn result (shared by `session.send().done()`
1147
+ * and `Aex.run`): the terminal outcome `status`, `ok`, `costUsd` (>= 0),
1148
+ * `usage` (from `costTelemetry.providerUsage`), and event-first `error`.
1149
+ */
1150
+ function settledTurnResult(sessionId, session, turn, events, outputs, messages, read) {
1151
+ const effectiveRead = read ?? sessionRecordRead(session);
1152
+ const status = readToOutcome(effectiveRead);
1153
+ const ok = isTerminalReadOk(effectiveRead);
1154
+ const usage = usageFromProviderUsage(providerUsageOf(session));
1155
+ const costUsd = typeof session.costUsd === "number" ? session.costUsd : 0;
1156
+ const error = failureFromEvents(events) ??
1157
+ (!ok && typeof session.errorMessage === "string" && session.errorMessage ? session.errorMessage : undefined);
1158
+ const outcome = outcomeFromEvents(events);
1159
+ return {
1160
+ sessionId,
1161
+ session,
1162
+ turn,
1163
+ status,
1164
+ ok,
1165
+ costUsd,
1166
+ usage,
1167
+ ...(error !== undefined ? { error } : {}),
1168
+ text: assistantTextFromEvents(events),
1169
+ events,
1170
+ outputs,
1171
+ messages,
1172
+ ...(outcome !== undefined ? { outcome } : {})
1173
+ };
783
1174
  }
784
1175
  /**
785
1176
  * Workspace AgentsMd admin operations exposed under `client.agentsMd`.
@@ -827,6 +1218,28 @@ export class FilesClient {
827
1218
  return operations.deleteFile(this.#http, fileId);
828
1219
  }
829
1220
  }
1221
+ /**
1222
+ * Workspace skill registry operations exposed under `client.skills`.
1223
+ *
1224
+ * Session creation normally passes `Skill.from*(...)` instances directly in the
1225
+ * `skills` option (auto-upserted by name). This namespace is the metadata
1226
+ * read/delete surface for the named workspace skill registry.
1227
+ */
1228
+ export class SkillsClient {
1229
+ #http;
1230
+ constructor(http) {
1231
+ this.#http = http;
1232
+ }
1233
+ list() {
1234
+ return operations.listSkills(this.#http);
1235
+ }
1236
+ get(name) {
1237
+ return operations.getSkill(this.#http, name);
1238
+ }
1239
+ delete(name) {
1240
+ return operations.deleteSkill(this.#http, name);
1241
+ }
1242
+ }
830
1243
  /**
831
1244
  * Workspace secret management exposed under `client.secrets`, mirroring
832
1245
  * `client.agentsMd` / `client.files`.
@@ -899,8 +1312,11 @@ export class Aex {
899
1312
  #fetch;
900
1313
  agentsMd;
901
1314
  files;
1315
+ skills;
902
1316
  secrets;
903
1317
  sessions;
1318
+ /** Cross-run output search (`aex.outputs.search(...)`). */
1319
+ outputs;
904
1320
  constructor(options, overrides = {}) {
905
1321
  const resolved = typeof options === "string" ? { ...overrides, apiKey: options } : options;
906
1322
  const apiKey = resolved.apiKey;
@@ -909,6 +1325,12 @@ export class Aex {
909
1325
  // missing credential too, instead of a bare Error slipping the taxonomy.
910
1326
  throw new CredentialValidationError("Aex: apiKey is required");
911
1327
  }
1328
+ // Self-describing key ⇒ plane-aware routing, checked ZERO-network in the
1329
+ // constructor: derive the baseUrl from the key's plane when omitted, and
1330
+ // fail fast on a plane/baseUrl mismatch instead of a bare 401 after a full
1331
+ // round-trip. An opaque/legacy key (no `aex_` shape) skips this and keeps
1332
+ // the HttpClient default.
1333
+ const baseUrl = resolveBaseUrlForKey(apiKey, resolved.baseUrl);
912
1334
  // Wrap the transport fetch (the caller's override, or global `fetch`) with
913
1335
  // the bounded-retry layer so every BFF request gets default resilience.
914
1336
  // The raw `#fetch` below stays unwrapped for the direct-to-storage asset PUT
@@ -916,7 +1338,7 @@ export class Aex {
916
1338
  const baseFetch = resolved.fetch ?? ((input, init) => fetch(input, init));
917
1339
  const retryingFetch = withRetry(baseFetch, resolved.retry);
918
1340
  this.#http = new HttpClient({
919
- ...(resolved.baseUrl ? { baseUrl: resolved.baseUrl } : {}),
1341
+ ...(baseUrl ? { baseUrl } : {}),
920
1342
  apiKey,
921
1343
  fetch: retryingFetch,
922
1344
  // Opt-in local diagnostics: emit a redacted per-request trace to
@@ -929,8 +1351,10 @@ export class Aex {
929
1351
  this.#fetch = resolved.fetch;
930
1352
  this.agentsMd = new AgentsMdClient(this.#http);
931
1353
  this.files = new FilesClient(this.#http);
1354
+ this.skills = new SkillsClient(this.#http);
932
1355
  this.secrets = new SecretsClient(this.#http);
933
1356
  this.sessions = new SessionClient(this.#http, (options) => this.#buildSessionCreateRequest(options), this.#fetch);
1357
+ this.outputs = new OutputsClient(this.#http);
934
1358
  }
935
1359
  /**
936
1360
  * Internal: satisfies the `SecretUploader` surface so a
@@ -944,8 +1368,9 @@ export class Aex {
944
1368
  /**
945
1369
  * Internal: materialize raw bytes to the content-addressable asset store
946
1370
  * (`/assets/presign` → PUT → `/assets/finalize`). Used by the session-create
947
- * prepare step to upload draft skill-tool / tool / agentsMd / file bundles so
948
- * the wire submission carries only plain `kind:"asset"` / `kind:"skill"` refs.
1371
+ * prepare step to upload draft skill / tool / agentsMd / file bundles so
1372
+ * the wire submission carries only plain `kind:"asset"` refs (skills resolve
1373
+ * to name-only `kind:"skill"` refs after their bytes upload).
949
1374
  * NOT part of the public API.
950
1375
  */
951
1376
  async _uploadAsset(args) {
@@ -957,6 +1382,29 @@ export class Aex {
957
1382
  ...(this.#fetch ? { fetch: this.#fetch } : {})
958
1383
  });
959
1384
  }
1385
+ /**
1386
+ * Internal: materialize a LARGE draft (a `File.fromPath` over the streaming
1387
+ * threshold) to the content store via the two-pass streaming multipart flow —
1388
+ * hash the deterministic canonical-zip stream, presign by hash (dedup still
1389
+ * short-circuits), then upload it in parts. Bounded memory (one entry + one
1390
+ * part). NOT part of the public API.
1391
+ */
1392
+ async _uploadAssetStream(args) {
1393
+ return uploadAssetMultipart({
1394
+ http: this.#http,
1395
+ drive: args.drive,
1396
+ ...(args.contentType ? { contentType: args.contentType } : {}),
1397
+ ...(this.#fetch ? { fetch: this.#fetch } : {})
1398
+ });
1399
+ }
1400
+ /**
1401
+ * Internal: upsert already-uploaded skill metadata into the workspace registry.
1402
+ * The bytes are staged through `_uploadAsset`; this call binds the content hash
1403
+ * to a mutable workspace skill name before the run references that name.
1404
+ */
1405
+ async _upsertSkill(args) {
1406
+ return operations.upsertSkill(this.#http, args);
1407
+ }
960
1408
  /**
961
1409
  * Convenience one-shot on top of the canonical session API:
962
1410
  * open a session, send `message` as the first turn, stream until the session
@@ -971,61 +1419,46 @@ export class Aex {
971
1419
  assertNoLegacySessionFields(options, "Aex.run");
972
1420
  const input = normaliseSessionInput(message, "Aex.run", "message");
973
1421
  assertNoSessionSendSignal(stream, "Aex.run stream");
974
- const streamOptions = {
1422
+ const sendOptions = {
975
1423
  ...(stream ?? {}),
976
1424
  ...(scopedSignal?.signal ? { signal: scopedSignal.signal } : {}),
977
1425
  ...(opts.webSocketFactory ? { webSocketFactory: opts.webSocketFactory } : {}),
978
1426
  ...(opts.idleTimeoutMs !== undefined ? { idleTimeoutMs: opts.idleTimeoutMs } : {}),
979
- ...(opts.pingIntervalMs !== undefined ? { pingIntervalMs: opts.pingIntervalMs } : {})
1427
+ ...(opts.pingIntervalMs !== undefined ? { pingIntervalMs: opts.pingIntervalMs } : {}),
1428
+ // run()/done() await settle by DEFAULT; opt out with `await: 'park'`.
1429
+ ...(opts.await !== undefined ? { await: opts.await } : {})
980
1430
  };
981
1431
  // Derive the message key from the create key (like the CLI) so a retried
982
1432
  // run with the same `idempotencyKey` de-duplicates BOTH the create and the
983
1433
  // billable turn server-side — never a duplicate billable run (sdk-dx-3).
984
- const createKey = createOptions.idempotencyKey ?? generateIdempotencyKey();
985
- const messageKey = messageIdempotencyKey ?? deriveMessageKey(createKey);
1434
+ const createKey = operations.resolveIdempotencyKey(createOptions.idempotencyKey);
1435
+ const messageKey = messageIdempotencyKey !== undefined ? operations.resolveIdempotencyKey(messageIdempotencyKey) : deriveMessageKey(createKey);
986
1436
  const session = await this.sessions.create({ ...createOptions, idempotencyKey: createKey });
987
- const turnResult = await sendSessionInternal(session, input, {
988
- ...streamOptions,
989
- idempotencyKey: messageKey
990
- }).done();
1437
+ // ONE terminal boundary: `done()` awaits settle by default, so the turn
1438
+ // result already carries the terminal outcome + cost + usage. `run()` just
1439
+ // reshapes it — `done()` == `run()` (WS3).
1440
+ let turnResult;
1441
+ try {
1442
+ turnResult = await sendSessionInternal(session, input, { ...sendOptions, idempotencyKey: messageKey }).done();
1443
+ }
1444
+ catch (err) {
1445
+ if (scopedSignal?.signal.aborted) {
1446
+ // The client-side wait budget (opts.timeoutMs) expired. Parity with
1447
+ // SessionHandle.wait(): THROW rather than a misleading silent result;
1448
+ // the run continues server-side.
1449
+ throw new RunStateError(`Aex.run: timed out after ${opts.timeoutMs}ms waiting for run ${session.id} to park; the run ` +
1450
+ `continues server-side — cancel via session.cancel() or resume with openSession(${JSON.stringify(session.id)})`);
1451
+ }
1452
+ throw err;
1453
+ }
991
1454
  const runId = turnResult.sessionId;
992
- // Settle-consistent enrichment (opt-in): the park EVENT that ends the
993
- // stream lands tens of seconds BEFORE the settle write that flips the
994
- // record and stamps costTelemetry/costUsd, so an immediate read misses
995
- // the showback on virtually every fresh run. `settleConsistent: true`
996
- // polls for the parked RECORD (bounded; degrades to the immediate read).
997
- const settledRecord = opts.settleConsistent === true
998
- ? await settledSessionRecord(this.#http, runId, turnResult.session, scopedSignal?.signal)
999
- : undefined;
1000
- const sessionRecord = settledRecord ?? turnResult.session;
1001
1455
  if (deleteAfter) {
1002
1456
  await session.delete();
1003
1457
  }
1458
+ const sessionRecord = turnResult.session;
1004
1459
  const run = sessionToRun(sessionRecord);
1005
- const events = turnResult.events;
1006
- const outputs = turnResult.outputs;
1007
- const ok = turnResult.status === "idle" || turnResult.status === "suspended";
1008
- if (!ok && scopedSignal?.signal.aborted) {
1009
- // The client-side wait budget (opts.timeoutMs) expired before the run
1010
- // reached a terminal park. Parity with SessionHandle.wait(): THROW rather
1011
- // than silently returning a misleading {ok:false,status:"running"} with no
1012
- // error (pre-launch edge-sweep F3). The run continues server-side.
1013
- throw new RunStateError(`Aex.run: timed out after ${opts.timeoutMs}ms waiting for run ${runId} to park (last status ` +
1014
- `${JSON.stringify(turnResult.status)}); the run continues server-side — cancel via ` +
1015
- `session.cancel() or resume with openSession(${JSON.stringify(runId)})`);
1016
- }
1017
- const trace = runTraceFromEvents(events);
1018
- // Surface the trace-derived usage at the top level when the run record does
1019
- // not carry its own usage (the managed plane doesn't populate session.usage);
1020
- // the per-event trace still yields token counts (pre-launch edge-sweep F5).
1021
- // When NEITHER source carries token counts (trace usage is `{}` — the
1022
- // managed plane emits no `aex.usage` events today), leave `usage` absent so
1023
- // `result.usage` honors its "when the deployment exposes it" contract
1024
- // instead of surfacing a truthy-but-empty object.
1025
- const recordUsage = hasUsageCounts(sessionRecord.usage) ? sessionRecord.usage : undefined;
1026
- const usage = recordUsage ?? (hasUsageCounts(trace.usage) ? trace.usage : undefined);
1027
- const costUsd = typeof sessionRecord.costUsd === "number" ? sessionRecord.costUsd : undefined;
1028
- const errorMessage = typeof sessionRecord.errorMessage === "string" && sessionRecord.errorMessage ? sessionRecord.errorMessage : undefined;
1460
+ const trace = runTraceFromEvents(turnResult.events);
1461
+ const outcome = turnResult.outcome;
1029
1462
  const result = {
1030
1463
  runId,
1031
1464
  run,
@@ -1033,17 +1466,18 @@ export class Aex {
1033
1466
  session: sessionRecord,
1034
1467
  turn: turnResult.turn,
1035
1468
  status: turnResult.status,
1036
- ok,
1469
+ ok: turnResult.ok,
1470
+ costUsd: turnResult.costUsd,
1471
+ usage: turnResult.usage,
1037
1472
  text: turnResult.text,
1038
1473
  messages: turnResult.messages,
1039
- events,
1474
+ events: turnResult.events,
1040
1475
  trace,
1041
- outputs,
1042
- ...(usage ? { usage } : {}),
1043
- ...(typeof costUsd === "number" ? { costUsd } : {}),
1044
- ...(!ok && errorMessage ? { error: errorMessage } : {})
1476
+ outputs: turnResult.outputs,
1477
+ ...(turnResult.error !== undefined ? { error: turnResult.error } : {}),
1478
+ ...(outcome !== undefined ? { outcome } : {})
1045
1479
  };
1046
- if (opts.throwOnFailure && !ok) {
1480
+ if (opts.throwOnFailure && !turnResult.ok) {
1047
1481
  // A turn that failed because the upstream provider throttled us surfaces
1048
1482
  // as a structured, non-leaky AexRateLimitError carrying the provider
1049
1483
  // fault, so callers can branch on `isRateLimited(err)` and replay.
@@ -1057,7 +1491,7 @@ export class Aex {
1057
1491
  ...(throttle.retryAfterMs !== undefined ? { retryAfterMs: throttle.retryAfterMs } : {})
1058
1492
  });
1059
1493
  }
1060
- throw new RunStateError(`Aex.run: session ${runId} ended ${turnResult.status}${errorMessage ? `: ${errorMessage}` : ""}`, { runId, status: turnResult.status });
1494
+ throw new RunStateError(`Aex.run: session ${runId} ended ${turnResult.status}${turnResult.error ? `: ${turnResult.error}` : ""}`, { runId, status: turnResult.status });
1061
1495
  }
1062
1496
  return result;
1063
1497
  }
@@ -1065,6 +1499,46 @@ export class Aex {
1065
1499
  scopedSignal?.clear();
1066
1500
  }
1067
1501
  }
1502
+ /**
1503
+ * Fire-and-forget: create the session and POST its first turn WITHOUT awaiting
1504
+ * settle (the honest counterpart to await-settle `run()`). Resolves with the
1505
+ * `runId` + a resumable {@link SessionHandle} immediately; observe the run via
1506
+ * a `webhook`, the event stream, or `openSession(runId)`.
1507
+ */
1508
+ async submit(options) {
1509
+ const { message, deleteAfter: _deleteAfter, messageIdempotencyKey: _messageIdempotencyKey, stream: _stream, ...createOptions } = options;
1510
+ assertNoLegacySessionFields(options, "Aex.submit");
1511
+ const input = normaliseSessionInput(message, "Aex.submit", "message");
1512
+ const request = await this.#buildSessionCreateRequest(createOptions);
1513
+ const { runId, session } = await operations.submit(this.#http, { ...request, input }, { idempotencyKey: operations.resolveIdempotencyKey(createOptions.idempotencyKey) });
1514
+ return { runId, session: new SessionHandle(this.#http, session, this.#fetch) };
1515
+ }
1516
+ /**
1517
+ * Run a batch of one-shot items with a bounded worker pool, returning every
1518
+ * item's settled result PLUS a REAL cost/usage rollup. The rollup is honest
1519
+ * because each `run()` awaits settle, so each item's `costUsd`/`usage` is
1520
+ * populated — a failed item lands in `failed[]`, never a silent `$0` success.
1521
+ * Concurrency is clamped below the workspace tier cap.
1522
+ */
1523
+ async batch(items, options = {}) {
1524
+ if (!Array.isArray(items)) {
1525
+ throw new RunConfigValidationError("Aex.batch: items must be an array of run options");
1526
+ }
1527
+ const concurrency = Math.min(Math.max(1, Math.floor(options.concurrency ?? DEFAULT_BATCH_CONCURRENCY)), BATCH_MAX_CONCURRENCY);
1528
+ const results = (await mapWithConcurrency(items, concurrency, async (item) => {
1529
+ const result = await this.run(item);
1530
+ return {
1531
+ runId: result.runId,
1532
+ status: result.status,
1533
+ ok: result.ok,
1534
+ costUsd: result.costUsd,
1535
+ usage: result.usage,
1536
+ ...(result.error !== undefined ? { error: result.error } : {}),
1537
+ ...(result.outcome !== undefined ? { outcome: result.outcome } : {})
1538
+ };
1539
+ }));
1540
+ return rollupBatch(results);
1541
+ }
1068
1542
  openSession(optionsOrId) {
1069
1543
  return typeof optionsOrId === "string"
1070
1544
  ? this.sessions.open(optionsOrId)
@@ -1083,31 +1557,33 @@ export class Aex {
1083
1557
  throw new RunConfigValidationError("Aex.openSession: message is not a supported option; sessions are created without a first " +
1084
1558
  "message — send it with session.send(...), or use run({ message }) for a one-shot.");
1085
1559
  }
1086
- const supportedProviders = providersForModel(options.model);
1087
- if (options.provider &&
1088
- supportedProviders.length > 0 &&
1089
- !supportedProviders.includes(options.provider)) {
1090
- throw new RunConfigValidationError(`Aex.openSession: provider ${JSON.stringify(options.provider)} is not available for ` +
1091
- `model ${JSON.stringify(options.model)} (supported: ${supportedProviders.join(", ")})`);
1560
+ // Model is REQUIRED and checked BEFORE the provider key, so omitting `model`
1561
+ // reports "model is required" rather than a misleading provider-key message.
1562
+ if (typeof options.model !== "string" || !options.model) {
1563
+ throw configError("Aex.openSession", "model", "model is required");
1092
1564
  }
1093
- const provider = options.provider ?? supportedProviders[0] ?? DEFAULT_RUN_PROVIDER;
1094
- if (options.provider === undefined &&
1095
- supportedProviders.length === 0 &&
1096
- typeof options.model === "string" &&
1097
- options.model.length > 0 &&
1098
- (typeof options.apiKeys?.[provider] !== "string" || options.apiKeys[provider].length === 0)) {
1099
- // Unknown model with no explicit provider: the DEFAULT_RUN_PROVIDER
1100
- // fallback exists for forward-compat with models added server-side, but
1101
- // without a key for that default the generic missing-key error below
1102
- // would point at the wrong problem (e.g. "pass apiKeys[\"anthropic\"]"
1103
- // when the caller mistyped a deepseek model id). Name the real issue.
1104
- throw new RunConfigValidationError(`Aex.openSession: model ${JSON.stringify(options.model)} is not a known model id, so its provider ` +
1105
- `cannot be inferred — pass provider explicitly (with a matching apiKeys entry) to run a model ` +
1106
- `this SDK version does not know about.`);
1565
+ // One model→provider resolver (SSoT), shared with the CLI: it honors an
1566
+ // explicit provider (forward-compat: an unknown model is allowed through so a
1567
+ // slightly-old SDK can still run a newly-launched model), infers the default
1568
+ // provider for a known model, and for an UNKNOWN model with no provider —
1569
+ // throws a shared `did you mean?` suggestion.
1570
+ let provider;
1571
+ try {
1572
+ provider = resolveModelProvider(options.model, options.provider);
1573
+ }
1574
+ catch (err) {
1575
+ throw configError("Aex.openSession", options.provider === undefined ? "model" : "provider", err instanceof Error ? err.message : String(err), options.provider ?? options.model);
1107
1576
  }
1108
1577
  validateApiKeys(options.apiKeys, provider, "Aex.openSession");
1109
- if (typeof options.model !== "string" || !options.model) {
1110
- throw new RunConfigValidationError("Aex.openSession: model is required");
1578
+ // WS9 fail-closed: `outputMode:'stream'` on a NON-streamable provider is a
1579
+ // hard reject at the earliest seam (no silent downgrade to buffered).
1580
+ if (options.outputMode !== undefined) {
1581
+ try {
1582
+ assertStreamableOutputMode(options.outputMode, provider);
1583
+ }
1584
+ catch (err) {
1585
+ throw configError("Aex.openSession", "outputMode", err instanceof Error ? err.message : String(err), options.outputMode);
1586
+ }
1111
1587
  }
1112
1588
  // Fast client-side validation via the contract parsers (the SSoT). runtimeSize
1113
1589
  // and timeout are STABLE closed sets whose invalid values the create endpoint
@@ -1128,31 +1604,42 @@ export class Aex {
1128
1604
  }
1129
1605
  const { declarations: secretEnvDeclarations, values: envSecretValues } = splitSecretEnv(options.environment?.secrets);
1130
1606
  let limits;
1607
+ const limitsInput = {};
1608
+ if (options.overrides?.maxSpendUsd !== undefined)
1609
+ limitsInput.maxSpendUsd = options.overrides.maxSpendUsd;
1610
+ if (options.overrides?.maxTurns !== undefined)
1611
+ limitsInput.maxTurns = options.overrides.maxTurns;
1131
1612
  try {
1132
- limits = parseRunLimits(options.overrides?.maxSpendUsd === undefined
1133
- ? undefined
1134
- : { maxSpendUsd: options.overrides.maxSpendUsd });
1613
+ limits = parseRunLimits(Object.keys(limitsInput).length > 0 ? limitsInput : undefined);
1135
1614
  }
1136
1615
  catch (err) {
1137
- throw new AexError("RUN_CONFIG_INVALID", `Aex.openSession: ${err instanceof Error ? err.message : String(err)}`);
1616
+ // One `configError` factory for every client-side validation throw, so
1617
+ // maxSpendUsd/maxTurns are `RunConfigValidationError` (not a base AexError).
1618
+ throw configError("Aex.openSession", "limits", err instanceof Error ? err.message : String(err));
1138
1619
  }
1139
1620
  const uploader = (args) => this._uploadAsset(args);
1140
- const preparedTools = await prepareTools(options.tools ?? [], uploader);
1141
- const preparedAgentsMd = await prepareAgentsMd(options.agentsMd ?? [], uploader);
1142
- const preparedFiles = await prepareFiles(options.files ?? [], uploader);
1621
+ const streamUploader = (args) => this._uploadAssetStream(args);
1622
+ // The four prepare passes touch disjoint instance sets, so run them
1623
+ // concurrently; each internally uploads its drafts with bounded concurrency.
1624
+ const [preparedTools, preparedSkills, preparedAgentsMd, preparedFiles] = await Promise.all([
1625
+ prepareTools(options.tools ?? [], uploader),
1626
+ prepareSkills(options.skills ?? [], this),
1627
+ prepareAgentsMd(options.agentsMd ?? [], uploader),
1628
+ prepareFiles(options.files ?? [], uploader, streamUploader)
1629
+ ]);
1143
1630
  const { submissionMcpServers, mergedMcpSecrets } = mergeMcpServers(options.mcpServers ?? [], []);
1144
1631
  const outputCapture = outputsForWire(options.outputs);
1145
1632
  const environment = sessionEnvironmentForWire(options.environment);
1146
1633
  const submission = {
1147
1634
  model: options.model,
1148
1635
  ...(options.system ? { system: options.system } : {}),
1149
- // Builtin name strings + custom tool refs + skill-tool refs all ride the
1150
- // one `tools` union; the BFF parser splits them back apart by kind.
1636
+ // Builtin name strings + custom tool refs ride the `tools` union; skills
1637
+ // are first-class name refs in `submission.skills`.
1151
1638
  tools: [
1152
1639
  ...preparedTools.builtinNames,
1153
- ...preparedTools.refs,
1154
- ...preparedTools.skillToolRefs
1640
+ ...preparedTools.refs
1155
1641
  ],
1642
+ ...(preparedSkills.length > 0 ? { skills: preparedSkills } : {}),
1156
1643
  agentsMd: preparedAgentsMd,
1157
1644
  files: preparedFiles,
1158
1645
  mcpServers: submissionMcpServers,
@@ -1163,7 +1650,9 @@ export class Aex {
1163
1650
  ...(options.includeBuiltinTools !== undefined
1164
1651
  ? { includeBuiltinTools: options.includeBuiltinTools }
1165
1652
  : {}),
1166
- ...(options.outputMode !== undefined ? { outputMode: options.outputMode } : {})
1653
+ ...(options.outputMode !== undefined ? { outputMode: options.outputMode } : {}),
1654
+ ...(options.responseFormat !== undefined ? { responseFormat: options.responseFormat } : {}),
1655
+ ...(options.approvalGate !== undefined ? { approvalGate: options.approvalGate } : {})
1167
1656
  };
1168
1657
  const secrets = {
1169
1658
  ...(options.apiKeys ? { apiKeys: options.apiKeys } : {}),
@@ -1239,6 +1728,35 @@ export class Aex {
1239
1728
  return operations.getWebhookSigningSecret(this.#http);
1240
1729
  }
1241
1730
  }
1731
+ /**
1732
+ * Resolve the effective `baseUrl` from a self-describing API key (ZERO-network):
1733
+ * - key omitted-of-shape (opaque/legacy): return the caller's `baseUrl`
1734
+ * unchanged (HttpClient falls back to the prd default).
1735
+ * - `baseUrl` omitted: DERIVE it from the key's plane; a plane with no default
1736
+ * host (dev) requires an explicit `baseUrl` — throw with that guidance.
1737
+ * - `baseUrl` supplied but its plane DISAGREES with the key's plane: throw
1738
+ * {@link CredentialValidationError} BEFORE any request (the exact `dev key
1739
+ * against the prd default → bare token_invalid` trap).
1740
+ */
1741
+ function resolveBaseUrlForKey(apiKey, baseUrl) {
1742
+ const parsed = parseApiKey(apiKey);
1743
+ if (parsed === null)
1744
+ return baseUrl;
1745
+ const planeUrl = PLANE_BASE_URLS[parsed.plane];
1746
+ if (baseUrl === undefined) {
1747
+ if (planeUrl === null) {
1748
+ throw new CredentialValidationError(`Aex: this API key is for the ${parsed.plane} plane, which has no default host — pass baseUrl explicitly.`, { plane: parsed.plane });
1749
+ }
1750
+ return planeUrl;
1751
+ }
1752
+ // The only client-detectable mismatch: a non-prd key pointed at the canonical
1753
+ // prd host (dev has no canonical host to compare a prd key against).
1754
+ if (baseUrl === PLANE_BASE_URLS.prd && parsed.plane !== "prd") {
1755
+ throw new CredentialValidationError(`Aex: this API key is for the ${parsed.plane} plane but baseUrl targets the prd plane (${PLANE_BASE_URLS.prd}) — ` +
1756
+ `pass the ${parsed.plane} plane baseUrl, or omit baseUrl to auto-route.`, { plane: parsed.plane, baseUrl });
1757
+ }
1758
+ return baseUrl;
1759
+ }
1242
1760
  // `Run.status` is a loose `string` on the wire shape, so we membership-test
1243
1761
  // against the canonical terminal set rather than re-deriving one (which is how
1244
1762
  // `timed_out` got dropped from the old hardcoded list).
@@ -1251,21 +1769,33 @@ const SETTLE_POLL_DEADLINE_MS = 60_000;
1251
1769
  /** Interval between settle-poll reads (ms). */
1252
1770
  const SETTLE_POLL_INTERVAL_MS = 750;
1253
1771
  /**
1254
- * Poll for the session RECORD to reach a parked status i.e. for the settle
1255
- * write (which also stamps `costUsd`/`usage`/`errorMessage`) to land. Returns
1256
- * the settled record, or `undefined` on timeout/abort/read-failure so the
1257
- * caller can fall back to the record it already holds. The first read is
1772
+ * A settle marker on a record: the settle commit stamps `settledAt` (and always
1773
+ * `costUsd`, default 0) plus the terminal outcome / cost telemetry. The SAME
1774
+ * predicate gates the early-return and the loop-return so a $0 turn (costUsd:0
1775
+ * present) resolves as settled never a hang, never an undefined cost.
1776
+ */
1777
+ function isSettledRecord(record) {
1778
+ if (!isSessionParked(record.status))
1779
+ return false;
1780
+ return (record.settledAt !== undefined ||
1781
+ typeof record.costUsd === "number" ||
1782
+ record.lastTurnOutcome !== undefined ||
1783
+ providerUsageOf(record) !== undefined);
1784
+ }
1785
+ /**
1786
+ * Poll for the session RECORD to reach a SETTLED state — i.e. for the settle
1787
+ * write (which stamps `settledAt` + `costUsd` + the terminal outcome) to land.
1788
+ * Returns the settled record, or `undefined` on timeout/abort/read-failure so
1789
+ * the caller can fall back to the record it already holds. The first read is
1258
1790
  * immediate, so a fast settle costs one extra GET and no added latency.
1259
1791
  */
1260
1792
  async function settledSessionRecord(http, sessionId, lastSeen, signal) {
1261
- // `lastSeen.status` may be client-side patched (withTerminalSessionStatus), so
1262
- // only trust it when it is parked AND already carries the settle-stamped cost.
1263
- if (isSessionParked(lastSeen.status) && typeof lastSeen.costUsd === "number")
1793
+ if (isSettledRecord(lastSeen))
1264
1794
  return lastSeen;
1265
1795
  const deadline = Date.now() + SETTLE_POLL_DEADLINE_MS;
1266
1796
  while (signal?.aborted !== true && Date.now() < deadline) {
1267
1797
  const record = await operations.getSession(http, sessionId).catch(() => undefined);
1268
- if (record !== undefined && isSessionParked(record.status))
1798
+ if (record !== undefined && isSettledRecord(record))
1269
1799
  return record;
1270
1800
  try {
1271
1801
  await sleep(SETTLE_POLL_INTERVAL_MS, signal);
@@ -1276,6 +1806,28 @@ async function settledSessionRecord(http, sessionId, lastSeen, signal) {
1276
1806
  }
1277
1807
  return undefined;
1278
1808
  }
1809
+ /**
1810
+ * How long a follow-up `send()` waits for the session RECORD to catch up after
1811
+ * our own turn parked idle before treating the `session_busy` as a genuinely
1812
+ * in-flight turn (ms). The idle park EVENT ends the turn stream, but the record
1813
+ * commit lags briefly behind it.
1814
+ */
1815
+ const SESSION_BUSY_RECONCILE_DEADLINE_MS = 30_000;
1816
+ /** Interval between record reads while reconciling a settle-lag `session_busy` (ms). */
1817
+ const SESSION_BUSY_RECONCILE_INTERVAL_MS = 500;
1818
+ /**
1819
+ * A 409 `session_busy` whose CURRENT status is `running` — the one case that is
1820
+ * transient: the platform is still catching up from our own just-parked turn.
1821
+ * The 409 body carries `{ error:"session_busy", status:<current> }`; any other
1822
+ * busy status (suspended / cancelling / deleted) is a real, non-retryable
1823
+ * rejection that must surface immediately.
1824
+ */
1825
+ function isSettlingSessionBusy(err) {
1826
+ if (!(err instanceof AexApiError) || err.status !== 409)
1827
+ return false;
1828
+ const body = asRecord(err.body);
1829
+ return body.error === "session_busy" && body.status === "running";
1830
+ }
1279
1831
  /**
1280
1832
  * A session is "parked" once it stops making progress: it reached one of the
1281
1833
  * turn-terminal statuses (`idle` / `suspended` / `error`) or a terminal run
@@ -1284,6 +1836,7 @@ async function settledSessionRecord(http, sessionId, lastSeen, signal) {
1284
1836
  function isSessionParked(status) {
1285
1837
  return (status === "idle" ||
1286
1838
  status === "suspended" ||
1839
+ status === "awaiting_approval" ||
1287
1840
  status === "error" ||
1288
1841
  status === "deleted" ||
1289
1842
  status === "expired" ||
@@ -1314,49 +1867,6 @@ function scopedAbortSignal(timeoutMs) {
1314
1867
  }
1315
1868
  };
1316
1869
  }
1317
- /** Escape a literal string for safe interpolation into a RegExp. */
1318
- function escapeRegExp(input) {
1319
- return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1320
- }
1321
- function isOutputPathSelector(selector) {
1322
- return Boolean(selector && typeof selector === "object" && "path" in selector);
1323
- }
1324
- function resolveOutputFileSelector(outputs, selector, runId) {
1325
- if (isOutputPathSelector(selector)) {
1326
- const target = normalizeOutputLookupPath(selector.path);
1327
- if (!target) {
1328
- throw new RunStateError("Aex.downloadOutput: output path must be non-empty", {
1329
- runId,
1330
- path: selector.path
1331
- });
1332
- }
1333
- const matches = outputs.filter((output) => {
1334
- if (typeof output.filename !== "string")
1335
- return false;
1336
- const filename = normalizeOutputLookupPath(output.filename);
1337
- if (selector.match === "suffix") {
1338
- return filename === target || filename.endsWith(`/${target}`);
1339
- }
1340
- return filename === target;
1341
- });
1342
- if (matches.length === 1)
1343
- return matches[0];
1344
- if (matches.length > 1) {
1345
- throw new RunStateError(`Aex.downloadOutput: output path "${selector.path}" matched multiple files`, { runId, path: selector.path, matches: matches.map((output) => output.filename ?? output.id) });
1346
- }
1347
- throw new RunStateError(`Aex.downloadOutput: output path "${selector.path}" was not found`, {
1348
- runId,
1349
- path: selector.path
1350
- });
1351
- }
1352
- if (typeof selector.id !== "string" || selector.id.length === 0) {
1353
- throw new RunStateError("Aex.downloadOutput: selector must include an output id or path", { runId });
1354
- }
1355
- return { ...selector, id: selector.id };
1356
- }
1357
- function normalizeOutputLookupPath(path) {
1358
- return path.replace(/\\/g, "/").replace(/^\/+/, "");
1359
- }
1360
1870
  async function writeOptionalFile(bytes, to) {
1361
1871
  if (to !== undefined) {
1362
1872
  const { writeFile } = await import("node:fs/promises");
@@ -1381,11 +1891,13 @@ function sleep(ms, signal) {
1381
1891
  signal?.addEventListener("abort", onAbort, { once: true });
1382
1892
  });
1383
1893
  }
1384
- function generateIdempotencyKey() {
1385
- const cryptoObj = globalThis.crypto;
1386
- if (cryptoObj?.randomUUID)
1387
- return cryptoObj.randomUUID();
1388
- return `idem-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
1894
+ /**
1895
+ * ONE factory for every client-side validation throw: a
1896
+ * {@link RunConfigValidationError} carrying structured `details: { field, value? }`
1897
+ * so callers branch on `err.details.field` instead of string-parsing the message.
1898
+ */
1899
+ function configError(surface, field, message, value) {
1900
+ return new RunConfigValidationError(`${surface}: ${message}`, value === undefined ? { field } : { field, value });
1389
1901
  }
1390
1902
  /**
1391
1903
  * Derive the message idempotency key from the session-create key. Mirrors the
@@ -1440,7 +1952,7 @@ function normaliseSessionInput(input, surface, field) {
1440
1952
  }
1441
1953
  function assertNoLegacySessionFields(options, surface) {
1442
1954
  const record = options;
1443
- const removedProxyField = "proxy" + "Endpoints";
1955
+ const removedProxyField = ["proxy", "Endpoints"].join("");
1444
1956
  const messages = {
1445
1957
  input: "send user messages with session.send(...) or use run({ message }).",
1446
1958
  prompt: "use message for one-shot run input or session.send(...) for follow-up messages.",
@@ -1449,12 +1961,11 @@ function assertNoLegacySessionFields(options, surface) {
1449
1961
  idleTtl: "use overrides.idleTtl.",
1450
1962
  retention: "use overrides.idleTtl.",
1451
1963
  secretEnv: "use environment.secrets.",
1452
- skills: "skills are now tools; build one with Tools.fromSkillDir/fromSkillUrl and pass it in tools.",
1453
1964
  secrets: "use top-level apiKeys for provider keys and environment.secrets for run secrets.",
1454
1965
  runtimeSize: "use runtime.",
1455
- parentRunId: "subagents are session-internal; parentRunId is not part of the session API.",
1456
1966
  limits: "use overrides.",
1457
1967
  timeout: "use overrides.timeout.",
1968
+ parentRunId: "subagent lineage is assigned by the platform.",
1458
1969
  signal: "use session.cancel() / session.suspend() for remote control.",
1459
1970
  postHook: "send a follow-up validation message when the session returns idle.",
1460
1971
  [removedProxyField]: "proxy endpoints are not part of the public SDK session API."
@@ -1541,50 +2052,52 @@ async function resolveAssetId(entry, bundle, uploader) {
1541
2052
  entry._rememberAsset(uploaded.assetId);
1542
2053
  return uploaded.assetId;
1543
2054
  }
2055
+ /**
2056
+ * Max concurrent asset uploads within a single prepare pass. Bounded so a large
2057
+ * tools/files/skills list can't stampede the presign endpoint (the transport
2058
+ * already carries bounded retry); 5 is a comfortable overlap without a burst.
2059
+ */
2060
+ const UPLOAD_CONCURRENCY = 5;
2061
+ /**
2062
+ * Map `items` through `fn` with at most `limit` in flight, PRESERVING ORDER (the
2063
+ * result at index `i` is `fn(items[i], i)` regardless of completion order). A
2064
+ * rejection from any call propagates (the first to reject wins) once the
2065
+ * in-flight batch settles. Exported for direct unit testing; not part of the
2066
+ * public SDK surface (index.ts controls that).
2067
+ */
2068
+ export async function mapWithConcurrency(items, limit, fn) {
2069
+ const out = new Array(items.length);
2070
+ let next = 0;
2071
+ const lanes = Array.from({ length: Math.min(Math.max(1, limit), items.length) }, async () => {
2072
+ for (let i = next++; i < items.length; i = next++) {
2073
+ out[i] = await fn(items[i], i);
2074
+ }
2075
+ });
2076
+ await Promise.all(lanes);
2077
+ return out;
2078
+ }
1544
2079
  /**
1545
2080
  * Split the `tools` union into custom tool refs (drafts eagerly uploaded as
1546
- * assets), skill-tool refs (skill bundles eagerly uploaded as assets), and
1547
- * builtin tool-name references (bare strings, validated against the closed
1548
- * {@link BUILTIN_TOOL_NAMES} set). Builtin names are deduped, in input order;
1549
- * the three groups are recombined on the wire by the caller (the BFF parser
1550
- * splits them back apart by kind).
2081
+ * assets) and builtin tool-name references (bare strings, validated against the
2082
+ * closed {@link BUILTIN_TOOL_NAMES} set). Builtin names are deduped, in input
2083
+ * order.
1551
2084
  */
1552
2085
  async function prepareTools(tools, uploader) {
1553
- const refs = [];
1554
- const skillToolRefs = [];
1555
- const seenBuiltins = new Set();
1556
- const builtinNames = [];
1557
- for (let i = 0; i < tools.length; i++) {
1558
- const entry = tools[i];
2086
+ const prepared = await mapWithConcurrency(tools, UPLOAD_CONCURRENCY, async (entry, i) => {
1559
2087
  // A bare string is a builtin tool reference.
1560
2088
  if (typeof entry === "string") {
1561
2089
  if (!BUILTIN_TOOL_NAMES.includes(entry)) {
1562
2090
  throw new RunConfigValidationError(`aex: tools[${i}] (${JSON.stringify(entry)}) is not a builtin tool name; ` +
1563
- `expected a Tool, a SkillTool, or one of: ${BUILTIN_TOOL_NAMES.join(", ")}`);
1564
- }
1565
- if (!seenBuiltins.has(entry)) {
1566
- seenBuiltins.add(entry);
1567
- builtinNames.push(entry);
1568
- }
1569
- continue;
1570
- }
1571
- // A skill-tool: upload its bundle (if a draft) and emit a `kind:"skill"` ref.
1572
- if (entry instanceof SkillTool) {
1573
- const ref = entry.ref;
1574
- if (ref.kind === "draft") {
1575
- const bundle = entry._takeDraftBundle();
1576
- if (!bundle) {
1577
- throw new RunConfigValidationError(`aex: tools[${i}] is a draft skill-tool but has no bytes`);
1578
- }
1579
- const assetId = await resolveAssetId(entry, bundle, uploader);
1580
- skillToolRefs.push({ kind: "skill", assetId, name: bundle.name, description: bundle.description });
1581
- continue;
2091
+ `expected a Tool or one of: ${BUILTIN_TOOL_NAMES.join(", ")}`);
1582
2092
  }
1583
- skillToolRefs.push(ref);
1584
- continue;
2093
+ return { kind: "builtin", name: entry };
1585
2094
  }
1586
2095
  if (!(entry instanceof Tool)) {
1587
- throw new RunConfigValidationError(`aex: tools[${i}] must be a Tool, a SkillTool, or a builtin tool name`);
2096
+ const maybeEntry = entry;
2097
+ if (maybeEntry instanceof Skill) {
2098
+ throw new RunConfigValidationError(`aex: tools[${i}] is a Skill; pass skills via the top-level skills option`);
2099
+ }
2100
+ throw new RunConfigValidationError(`aex: tools[${i}] must be a Tool or a builtin tool name`);
1588
2101
  }
1589
2102
  const ref = entry.ref;
1590
2103
  if (ref.kind === "draft") {
@@ -1593,18 +2106,59 @@ async function prepareTools(tools, uploader) {
1593
2106
  throw new RunConfigValidationError(`aex: tools[${i}] is draft but has no bytes`);
1594
2107
  }
1595
2108
  const assetId = await resolveAssetId(entry, bundle, uploader);
1596
- refs.push({ ...bundle.ref, assetId });
1597
- continue;
2109
+ return { kind: "ref", ref: { ...bundle.ref, assetId } };
2110
+ }
2111
+ return { kind: "ref", ref };
2112
+ });
2113
+ const refs = [];
2114
+ const seenBuiltins = new Set();
2115
+ const builtinNames = [];
2116
+ for (const item of prepared) {
2117
+ if (item.kind === "builtin") {
2118
+ if (!seenBuiltins.has(item.name)) {
2119
+ seenBuiltins.add(item.name);
2120
+ builtinNames.push(item.name);
2121
+ }
1598
2122
  }
1599
- refs.push(ref);
2123
+ else {
2124
+ refs.push(item.ref);
2125
+ }
2126
+ }
2127
+ return { refs, builtinNames };
2128
+ }
2129
+ /**
2130
+ * Upload/upsert workspace skills and return public name-only refs. Draft skills
2131
+ * auto-upsert (bytes → asset store, then registry PUT) by name; already-uploaded
2132
+ * skills pass through. Duplicate names are pre-checked (uploads run in parallel,
2133
+ * so the dedup cannot be a running set inside the map).
2134
+ */
2135
+ async function prepareSkills(skills, uploader) {
2136
+ if (skills.length > SKILLS_MAX) {
2137
+ throw new RunConfigValidationError(`aex: skills exceeds the ${SKILLS_MAX}-skill limit (got ${skills.length})`);
2138
+ }
2139
+ const seen = new Set();
2140
+ for (let i = 0; i < skills.length; i++) {
2141
+ const entry = skills[i];
2142
+ if (!(entry instanceof Skill)) {
2143
+ throw new RunConfigValidationError(`aex: skills[${i}] must be a Skill (Skill.fromDir / fromUrl / fromFiles / fromContent / fromBytes)`);
2144
+ }
2145
+ if (seen.has(entry.name)) {
2146
+ throw new RunConfigValidationError(`aex: skills duplicate name: ${entry.name}`);
2147
+ }
2148
+ seen.add(entry.name);
1600
2149
  }
1601
- return { refs, skillToolRefs, builtinNames };
2150
+ return mapWithConcurrency(skills, UPLOAD_CONCURRENCY, async (entry, i) => {
2151
+ const uploaded = entry.isDraft ? await entry.upload(uploader) : entry;
2152
+ const ref = uploaded.ref;
2153
+ if (ref.kind !== "skill") {
2154
+ throw new RunConfigValidationError(`aex: skills[${i}] did not resolve to a workspace skill ref`);
2155
+ }
2156
+ return ref;
2157
+ });
1602
2158
  }
1603
- /** Walk AgentsMd[], eagerly upload drafts as assets, and return plain asset refs. */
2159
+ /** Walk AgentsMd[], eagerly upload drafts as assets (bounded concurrency), and return plain asset refs. */
1604
2160
  async function prepareAgentsMd(agentsMds, uploader) {
1605
- const refs = [];
1606
- for (let i = 0; i < agentsMds.length; i++) {
1607
- const entry = agentsMds[i];
2161
+ return mapWithConcurrency(agentsMds, UPLOAD_CONCURRENCY, async (entry, i) => {
1608
2162
  if (!(entry instanceof AgentsMd)) {
1609
2163
  throw new RunConfigValidationError(`aex: agentsMd[${i}] must be an AgentsMd instance`);
1610
2164
  }
@@ -1615,43 +2169,39 @@ async function prepareAgentsMd(agentsMds, uploader) {
1615
2169
  throw new RunConfigValidationError(`aex: agentsMd[${i}] is draft but has no bytes`);
1616
2170
  }
1617
2171
  const assetId = await resolveAssetId(entry, bundle, uploader);
1618
- refs.push({
1619
- kind: "asset",
1620
- assetId,
1621
- name: bundle.name
1622
- });
1623
- continue;
2172
+ return { kind: "asset", assetId, name: bundle.name };
1624
2173
  }
1625
- refs.push(ref);
1626
- }
1627
- return refs;
2174
+ return ref;
2175
+ });
1628
2176
  }
1629
- /** Walk File[], eagerly upload drafts as assets, and return plain asset refs. */
1630
- async function prepareFiles(files, uploader) {
1631
- const refs = [];
1632
- for (let i = 0; i < files.length; i++) {
1633
- const entry = files[i];
2177
+ /** Walk File[], eagerly upload drafts as assets (bounded concurrency), and return plain asset refs. */
2178
+ async function prepareFiles(files, uploader, streamUploader) {
2179
+ return mapWithConcurrency(files, UPLOAD_CONCURRENCY, async (entry, i) => {
1634
2180
  if (!(entry instanceof File)) {
1635
2181
  throw new RunConfigValidationError(`aex: files[${i}] must be a File instance`);
1636
2182
  }
1637
2183
  const ref = entry.ref;
1638
2184
  if (ref.kind === "draft") {
2185
+ // A large draft carries a streaming driver instead of in-memory bytes.
2186
+ const stream = entry._takeDraftStream();
2187
+ if (stream) {
2188
+ const cached = entry._cachedAssetId;
2189
+ if (cached !== undefined) {
2190
+ return { kind: "asset", assetId: cached, name: stream.name, mountPath: stream.mountPath };
2191
+ }
2192
+ const uploaded = await streamUploader({ drive: stream.drive, contentType: "application/zip" });
2193
+ entry._rememberAsset(uploaded.assetId);
2194
+ return { kind: "asset", assetId: uploaded.assetId, name: stream.name, mountPath: stream.mountPath };
2195
+ }
1639
2196
  const bundle = entry._takeDraftBundle();
1640
2197
  if (!bundle) {
1641
2198
  throw new RunConfigValidationError(`aex: files[${i}] is draft but has no bytes`);
1642
2199
  }
1643
2200
  const assetId = await resolveAssetId(entry, bundle, uploader);
1644
- refs.push({
1645
- kind: "asset",
1646
- assetId,
1647
- name: bundle.name,
1648
- mountPath: bundle.mountPath
1649
- });
1650
- continue;
2201
+ return { kind: "asset", assetId, name: bundle.name, mountPath: bundle.mountPath };
1651
2202
  }
1652
- refs.push(ref);
1653
- }
1654
- return refs;
2203
+ return ref;
2204
+ });
1655
2205
  }
1656
2206
  function mergeMcpServers(inputs, explicitSecrets) {
1657
2207
  const submissionMcpServers = [];