@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/dist/client.js CHANGED
@@ -1,6 +1,6 @@
1
- import { AexApiError, AexError, CredentialValidationError, DEFAULT_RUN_PROVIDER, HttpClient, RunConfigValidationError, RunStateError, SecretString, customName, isRunSettled, operations, providersForModel, streamCoordinatorEvents, parseRunLimits, parseRuntimeSize, parseRunTimeout, parseRunWebhook, BUILTIN_TOOL_NAMES, SKILLS_MAX, 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";
@@ -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`.
@@ -924,6 +1315,8 @@ export class Aex {
924
1315
  skills;
925
1316
  secrets;
926
1317
  sessions;
1318
+ /** Cross-run output search (`aex.outputs.search(...)`). */
1319
+ outputs;
927
1320
  constructor(options, overrides = {}) {
928
1321
  const resolved = typeof options === "string" ? { ...overrides, apiKey: options } : options;
929
1322
  const apiKey = resolved.apiKey;
@@ -932,6 +1325,12 @@ export class Aex {
932
1325
  // missing credential too, instead of a bare Error slipping the taxonomy.
933
1326
  throw new CredentialValidationError("Aex: apiKey is required");
934
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);
935
1334
  // Wrap the transport fetch (the caller's override, or global `fetch`) with
936
1335
  // the bounded-retry layer so every BFF request gets default resilience.
937
1336
  // The raw `#fetch` below stays unwrapped for the direct-to-storage asset PUT
@@ -939,7 +1338,7 @@ export class Aex {
939
1338
  const baseFetch = resolved.fetch ?? ((input, init) => fetch(input, init));
940
1339
  const retryingFetch = withRetry(baseFetch, resolved.retry);
941
1340
  this.#http = new HttpClient({
942
- ...(resolved.baseUrl ? { baseUrl: resolved.baseUrl } : {}),
1341
+ ...(baseUrl ? { baseUrl } : {}),
943
1342
  apiKey,
944
1343
  fetch: retryingFetch,
945
1344
  // Opt-in local diagnostics: emit a redacted per-request trace to
@@ -955,6 +1354,7 @@ export class Aex {
955
1354
  this.skills = new SkillsClient(this.#http);
956
1355
  this.secrets = new SecretsClient(this.#http);
957
1356
  this.sessions = new SessionClient(this.#http, (options) => this.#buildSessionCreateRequest(options), this.#fetch);
1357
+ this.outputs = new OutputsClient(this.#http);
958
1358
  }
959
1359
  /**
960
1360
  * Internal: satisfies the `SecretUploader` surface so a
@@ -982,6 +1382,21 @@ export class Aex {
982
1382
  ...(this.#fetch ? { fetch: this.#fetch } : {})
983
1383
  });
984
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
+ }
985
1400
  /**
986
1401
  * Internal: upsert already-uploaded skill metadata into the workspace registry.
987
1402
  * The bytes are staged through `_uploadAsset`; this call binds the content hash
@@ -1004,61 +1419,46 @@ export class Aex {
1004
1419
  assertNoLegacySessionFields(options, "Aex.run");
1005
1420
  const input = normaliseSessionInput(message, "Aex.run", "message");
1006
1421
  assertNoSessionSendSignal(stream, "Aex.run stream");
1007
- const streamOptions = {
1422
+ const sendOptions = {
1008
1423
  ...(stream ?? {}),
1009
1424
  ...(scopedSignal?.signal ? { signal: scopedSignal.signal } : {}),
1010
1425
  ...(opts.webSocketFactory ? { webSocketFactory: opts.webSocketFactory } : {}),
1011
1426
  ...(opts.idleTimeoutMs !== undefined ? { idleTimeoutMs: opts.idleTimeoutMs } : {}),
1012
- ...(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 } : {})
1013
1430
  };
1014
1431
  // Derive the message key from the create key (like the CLI) so a retried
1015
1432
  // run with the same `idempotencyKey` de-duplicates BOTH the create and the
1016
1433
  // billable turn server-side — never a duplicate billable run (sdk-dx-3).
1017
- const createKey = createOptions.idempotencyKey ?? generateIdempotencyKey();
1018
- const messageKey = messageIdempotencyKey ?? deriveMessageKey(createKey);
1434
+ const createKey = operations.resolveIdempotencyKey(createOptions.idempotencyKey);
1435
+ const messageKey = messageIdempotencyKey !== undefined ? operations.resolveIdempotencyKey(messageIdempotencyKey) : deriveMessageKey(createKey);
1019
1436
  const session = await this.sessions.create({ ...createOptions, idempotencyKey: createKey });
1020
- const turnResult = await sendSessionInternal(session, input, {
1021
- ...streamOptions,
1022
- idempotencyKey: messageKey
1023
- }).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
+ }
1024
1454
  const runId = turnResult.sessionId;
1025
- // Settle-consistent enrichment (opt-in): the park EVENT that ends the
1026
- // stream lands tens of seconds BEFORE the settle write that flips the
1027
- // record and stamps costTelemetry/costUsd, so an immediate read misses
1028
- // the showback on virtually every fresh run. `settleConsistent: true`
1029
- // polls for the parked RECORD (bounded; degrades to the immediate read).
1030
- const settledRecord = opts.settleConsistent === true
1031
- ? await settledSessionRecord(this.#http, runId, turnResult.session, scopedSignal?.signal)
1032
- : undefined;
1033
- const sessionRecord = settledRecord ?? turnResult.session;
1034
1455
  if (deleteAfter) {
1035
1456
  await session.delete();
1036
1457
  }
1458
+ const sessionRecord = turnResult.session;
1037
1459
  const run = sessionToRun(sessionRecord);
1038
- const events = turnResult.events;
1039
- const outputs = turnResult.outputs;
1040
- const ok = turnResult.status === "idle" || turnResult.status === "suspended";
1041
- if (!ok && scopedSignal?.signal.aborted) {
1042
- // The client-side wait budget (opts.timeoutMs) expired before the run
1043
- // reached a terminal park. Parity with SessionHandle.wait(): THROW rather
1044
- // than silently returning a misleading {ok:false,status:"running"} with no
1045
- // error (pre-launch edge-sweep F3). The run continues server-side.
1046
- throw new RunStateError(`Aex.run: timed out after ${opts.timeoutMs}ms waiting for run ${runId} to park (last status ` +
1047
- `${JSON.stringify(turnResult.status)}); the run continues server-side — cancel via ` +
1048
- `session.cancel() or resume with openSession(${JSON.stringify(runId)})`);
1049
- }
1050
- const trace = runTraceFromEvents(events);
1051
- // Surface the trace-derived usage at the top level when the run record does
1052
- // not carry its own usage (the managed plane doesn't populate session.usage);
1053
- // the per-event trace still yields token counts (pre-launch edge-sweep F5).
1054
- // When NEITHER source carries token counts (trace usage is `{}` — the
1055
- // managed plane emits no `aex.usage` events today), leave `usage` absent so
1056
- // `result.usage` honors its "when the deployment exposes it" contract
1057
- // instead of surfacing a truthy-but-empty object.
1058
- const recordUsage = hasUsageCounts(sessionRecord.usage) ? sessionRecord.usage : undefined;
1059
- const usage = recordUsage ?? (hasUsageCounts(trace.usage) ? trace.usage : undefined);
1060
- const costUsd = typeof sessionRecord.costUsd === "number" ? sessionRecord.costUsd : undefined;
1061
- const errorMessage = typeof sessionRecord.errorMessage === "string" && sessionRecord.errorMessage ? sessionRecord.errorMessage : undefined;
1460
+ const trace = runTraceFromEvents(turnResult.events);
1461
+ const outcome = turnResult.outcome;
1062
1462
  const result = {
1063
1463
  runId,
1064
1464
  run,
@@ -1066,17 +1466,18 @@ export class Aex {
1066
1466
  session: sessionRecord,
1067
1467
  turn: turnResult.turn,
1068
1468
  status: turnResult.status,
1069
- ok,
1469
+ ok: turnResult.ok,
1470
+ costUsd: turnResult.costUsd,
1471
+ usage: turnResult.usage,
1070
1472
  text: turnResult.text,
1071
1473
  messages: turnResult.messages,
1072
- events,
1474
+ events: turnResult.events,
1073
1475
  trace,
1074
- outputs,
1075
- ...(usage ? { usage } : {}),
1076
- ...(typeof costUsd === "number" ? { costUsd } : {}),
1077
- ...(!ok && errorMessage ? { error: errorMessage } : {})
1476
+ outputs: turnResult.outputs,
1477
+ ...(turnResult.error !== undefined ? { error: turnResult.error } : {}),
1478
+ ...(outcome !== undefined ? { outcome } : {})
1078
1479
  };
1079
- if (opts.throwOnFailure && !ok) {
1480
+ if (opts.throwOnFailure && !turnResult.ok) {
1080
1481
  // A turn that failed because the upstream provider throttled us surfaces
1081
1482
  // as a structured, non-leaky AexRateLimitError carrying the provider
1082
1483
  // fault, so callers can branch on `isRateLimited(err)` and replay.
@@ -1090,7 +1491,7 @@ export class Aex {
1090
1491
  ...(throttle.retryAfterMs !== undefined ? { retryAfterMs: throttle.retryAfterMs } : {})
1091
1492
  });
1092
1493
  }
1093
- 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 });
1094
1495
  }
1095
1496
  return result;
1096
1497
  }
@@ -1098,6 +1499,49 @@ export class Aex {
1098
1499
  scopedSignal?.clear();
1099
1500
  }
1100
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, 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 }, {
1514
+ idempotencyKey: operations.resolveIdempotencyKey(createOptions.idempotencyKey),
1515
+ ...(messageIdempotencyKey !== undefined ? { messageIdempotencyKey } : {})
1516
+ });
1517
+ return { runId, session: new SessionHandle(this.#http, session, this.#fetch) };
1518
+ }
1519
+ /**
1520
+ * Run a batch of one-shot items with a bounded worker pool, returning every
1521
+ * item's settled result PLUS a REAL cost/usage rollup. The rollup is honest
1522
+ * because each `run()` awaits settle, so each item's `costUsd`/`usage` is
1523
+ * populated — a failed item lands in `failed[]`, never a silent `$0` success.
1524
+ * Concurrency is clamped below the workspace tier cap.
1525
+ */
1526
+ async batch(items, options = {}) {
1527
+ if (!Array.isArray(items)) {
1528
+ throw new RunConfigValidationError("Aex.batch: items must be an array of run options");
1529
+ }
1530
+ const concurrency = Math.min(Math.max(1, Math.floor(options.concurrency ?? DEFAULT_BATCH_CONCURRENCY)), BATCH_MAX_CONCURRENCY);
1531
+ const results = (await mapWithConcurrency(items, concurrency, async (item) => {
1532
+ const result = await this.run(item);
1533
+ return {
1534
+ runId: result.runId,
1535
+ status: result.status,
1536
+ ok: result.ok,
1537
+ costUsd: result.costUsd,
1538
+ usage: result.usage,
1539
+ ...(result.error !== undefined ? { error: result.error } : {}),
1540
+ ...(result.outcome !== undefined ? { outcome: result.outcome } : {})
1541
+ };
1542
+ }));
1543
+ return rollupBatch(results);
1544
+ }
1101
1545
  openSession(optionsOrId) {
1102
1546
  return typeof optionsOrId === "string"
1103
1547
  ? this.sessions.open(optionsOrId)
@@ -1116,31 +1560,33 @@ export class Aex {
1116
1560
  throw new RunConfigValidationError("Aex.openSession: message is not a supported option; sessions are created without a first " +
1117
1561
  "message — send it with session.send(...), or use run({ message }) for a one-shot.");
1118
1562
  }
1119
- const supportedProviders = providersForModel(options.model);
1120
- if (options.provider &&
1121
- supportedProviders.length > 0 &&
1122
- !supportedProviders.includes(options.provider)) {
1123
- throw new RunConfigValidationError(`Aex.openSession: provider ${JSON.stringify(options.provider)} is not available for ` +
1124
- `model ${JSON.stringify(options.model)} (supported: ${supportedProviders.join(", ")})`);
1563
+ // Model is REQUIRED and checked BEFORE the provider key, so omitting `model`
1564
+ // reports "model is required" rather than a misleading provider-key message.
1565
+ if (typeof options.model !== "string" || !options.model) {
1566
+ throw configError("Aex.openSession", "model", "model is required");
1567
+ }
1568
+ // One model→provider resolver (SSoT), shared with the CLI: it honors an
1569
+ // explicit provider (forward-compat: an unknown model is allowed through so a
1570
+ // slightly-old SDK can still run a newly-launched model), infers the default
1571
+ // provider for a known model, and — for an UNKNOWN model with no provider —
1572
+ // throws a shared `did you mean?` suggestion.
1573
+ let provider;
1574
+ try {
1575
+ provider = resolveModelProvider(options.model, options.provider);
1125
1576
  }
1126
- const provider = options.provider ?? supportedProviders[0] ?? DEFAULT_RUN_PROVIDER;
1127
- if (options.provider === undefined &&
1128
- supportedProviders.length === 0 &&
1129
- typeof options.model === "string" &&
1130
- options.model.length > 0 &&
1131
- (typeof options.apiKeys?.[provider] !== "string" || options.apiKeys[provider].length === 0)) {
1132
- // Unknown model with no explicit provider: the DEFAULT_RUN_PROVIDER
1133
- // fallback exists for forward-compat with models added server-side, but
1134
- // without a key for that default the generic missing-key error below
1135
- // would point at the wrong problem (e.g. "pass apiKeys[\"anthropic\"]"
1136
- // when the caller mistyped a deepseek model id). Name the real issue.
1137
- throw new RunConfigValidationError(`Aex.openSession: model ${JSON.stringify(options.model)} is not a known model id, so its provider ` +
1138
- `cannot be inferred — pass provider explicitly (with a matching apiKeys entry) to run a model ` +
1139
- `this SDK version does not know about.`);
1577
+ catch (err) {
1578
+ throw configError("Aex.openSession", options.provider === undefined ? "model" : "provider", err instanceof Error ? err.message : String(err), options.provider ?? options.model);
1140
1579
  }
1141
1580
  validateApiKeys(options.apiKeys, provider, "Aex.openSession");
1142
- if (typeof options.model !== "string" || !options.model) {
1143
- throw new RunConfigValidationError("Aex.openSession: model is required");
1581
+ // WS9 fail-closed: `outputMode:'stream'` on a NON-streamable provider is a
1582
+ // hard reject at the earliest seam (no silent downgrade to buffered).
1583
+ if (options.outputMode !== undefined) {
1584
+ try {
1585
+ assertStreamableOutputMode(options.outputMode, provider);
1586
+ }
1587
+ catch (err) {
1588
+ throw configError("Aex.openSession", "outputMode", err instanceof Error ? err.message : String(err), options.outputMode);
1589
+ }
1144
1590
  }
1145
1591
  // Fast client-side validation via the contract parsers (the SSoT). runtimeSize
1146
1592
  // and timeout are STABLE closed sets whose invalid values the create endpoint
@@ -1161,22 +1607,28 @@ export class Aex {
1161
1607
  }
1162
1608
  const { declarations: secretEnvDeclarations, values: envSecretValues } = splitSecretEnv(options.environment?.secrets);
1163
1609
  let limits;
1610
+ const limitsInput = {};
1611
+ if (options.overrides?.maxSpendUsd !== undefined)
1612
+ limitsInput.maxSpendUsd = options.overrides.maxSpendUsd;
1613
+ if (options.overrides?.maxTurns !== undefined)
1614
+ limitsInput.maxTurns = options.overrides.maxTurns;
1164
1615
  try {
1165
- limits = parseRunLimits(options.overrides?.maxSpendUsd === undefined
1166
- ? undefined
1167
- : { maxSpendUsd: options.overrides.maxSpendUsd });
1616
+ limits = parseRunLimits(Object.keys(limitsInput).length > 0 ? limitsInput : undefined);
1168
1617
  }
1169
1618
  catch (err) {
1170
- throw new AexError("RUN_CONFIG_INVALID", `Aex.openSession: ${err instanceof Error ? err.message : String(err)}`);
1619
+ // One `configError` factory for every client-side validation throw, so
1620
+ // maxSpendUsd/maxTurns are `RunConfigValidationError` (not a base AexError).
1621
+ throw configError("Aex.openSession", "limits", err instanceof Error ? err.message : String(err));
1171
1622
  }
1172
1623
  const uploader = (args) => this._uploadAsset(args);
1624
+ const streamUploader = (args) => this._uploadAssetStream(args);
1173
1625
  // The four prepare passes touch disjoint instance sets, so run them
1174
1626
  // concurrently; each internally uploads its drafts with bounded concurrency.
1175
1627
  const [preparedTools, preparedSkills, preparedAgentsMd, preparedFiles] = await Promise.all([
1176
1628
  prepareTools(options.tools ?? [], uploader),
1177
1629
  prepareSkills(options.skills ?? [], this),
1178
1630
  prepareAgentsMd(options.agentsMd ?? [], uploader),
1179
- prepareFiles(options.files ?? [], uploader)
1631
+ prepareFiles(options.files ?? [], uploader, streamUploader)
1180
1632
  ]);
1181
1633
  const { submissionMcpServers, mergedMcpSecrets } = mergeMcpServers(options.mcpServers ?? [], []);
1182
1634
  const outputCapture = outputsForWire(options.outputs);
@@ -1201,7 +1653,9 @@ export class Aex {
1201
1653
  ...(options.includeBuiltinTools !== undefined
1202
1654
  ? { includeBuiltinTools: options.includeBuiltinTools }
1203
1655
  : {}),
1204
- ...(options.outputMode !== undefined ? { outputMode: options.outputMode } : {})
1656
+ ...(options.outputMode !== undefined ? { outputMode: options.outputMode } : {}),
1657
+ ...(options.responseFormat !== undefined ? { responseFormat: options.responseFormat } : {}),
1658
+ ...(options.approvalGate !== undefined ? { approvalGate: options.approvalGate } : {})
1205
1659
  };
1206
1660
  const secrets = {
1207
1661
  ...(options.apiKeys ? { apiKeys: options.apiKeys } : {}),
@@ -1277,6 +1731,35 @@ export class Aex {
1277
1731
  return operations.getWebhookSigningSecret(this.#http);
1278
1732
  }
1279
1733
  }
1734
+ /**
1735
+ * Resolve the effective `baseUrl` from a self-describing API key (ZERO-network):
1736
+ * - key omitted-of-shape (opaque/legacy): return the caller's `baseUrl`
1737
+ * unchanged (HttpClient falls back to the prd default).
1738
+ * - `baseUrl` omitted: DERIVE it from the key's plane; a plane with no default
1739
+ * host (dev) requires an explicit `baseUrl` — throw with that guidance.
1740
+ * - `baseUrl` supplied but its plane DISAGREES with the key's plane: throw
1741
+ * {@link CredentialValidationError} BEFORE any request (the exact `dev key
1742
+ * against the prd default → bare token_invalid` trap).
1743
+ */
1744
+ function resolveBaseUrlForKey(apiKey, baseUrl) {
1745
+ const parsed = parseApiKey(apiKey);
1746
+ if (parsed === null)
1747
+ return baseUrl;
1748
+ const planeUrl = PLANE_BASE_URLS[parsed.plane];
1749
+ if (baseUrl === undefined) {
1750
+ if (planeUrl === null) {
1751
+ throw new CredentialValidationError(`Aex: this API key is for the ${parsed.plane} plane, which has no default host — pass baseUrl explicitly.`, { plane: parsed.plane });
1752
+ }
1753
+ return planeUrl;
1754
+ }
1755
+ // The only client-detectable mismatch: a non-prd key pointed at the canonical
1756
+ // prd host (dev has no canonical host to compare a prd key against).
1757
+ if (baseUrl === PLANE_BASE_URLS.prd && parsed.plane !== "prd") {
1758
+ throw new CredentialValidationError(`Aex: this API key is for the ${parsed.plane} plane but baseUrl targets the prd plane (${PLANE_BASE_URLS.prd}) — ` +
1759
+ `pass the ${parsed.plane} plane baseUrl, or omit baseUrl to auto-route.`, { plane: parsed.plane, baseUrl });
1760
+ }
1761
+ return baseUrl;
1762
+ }
1280
1763
  // `Run.status` is a loose `string` on the wire shape, so we membership-test
1281
1764
  // against the canonical terminal set rather than re-deriving one (which is how
1282
1765
  // `timed_out` got dropped from the old hardcoded list).
@@ -1289,21 +1772,33 @@ const SETTLE_POLL_DEADLINE_MS = 60_000;
1289
1772
  /** Interval between settle-poll reads (ms). */
1290
1773
  const SETTLE_POLL_INTERVAL_MS = 750;
1291
1774
  /**
1292
- * Poll for the session RECORD to reach a parked status i.e. for the settle
1293
- * write (which also stamps `costUsd`/`usage`/`errorMessage`) to land. Returns
1294
- * the settled record, or `undefined` on timeout/abort/read-failure so the
1295
- * caller can fall back to the record it already holds. The first read is
1775
+ * A settle marker on a record: the settle commit stamps `settledAt` (and always
1776
+ * `costUsd`, default 0) plus the terminal outcome / cost telemetry. The SAME
1777
+ * predicate gates the early-return and the loop-return so a $0 turn (costUsd:0
1778
+ * present) resolves as settled never a hang, never an undefined cost.
1779
+ */
1780
+ function isSettledRecord(record) {
1781
+ if (!isSessionParked(record.status))
1782
+ return false;
1783
+ return (record.settledAt !== undefined ||
1784
+ typeof record.costUsd === "number" ||
1785
+ record.lastTurnOutcome !== undefined ||
1786
+ providerUsageOf(record) !== undefined);
1787
+ }
1788
+ /**
1789
+ * Poll for the session RECORD to reach a SETTLED state — i.e. for the settle
1790
+ * write (which stamps `settledAt` + `costUsd` + the terminal outcome) to land.
1791
+ * Returns the settled record, or `undefined` on timeout/abort/read-failure so
1792
+ * the caller can fall back to the record it already holds. The first read is
1296
1793
  * immediate, so a fast settle costs one extra GET and no added latency.
1297
1794
  */
1298
1795
  async function settledSessionRecord(http, sessionId, lastSeen, signal) {
1299
- // `lastSeen.status` may be client-side patched (withTerminalSessionStatus), so
1300
- // only trust it when it is parked AND already carries the settle-stamped cost.
1301
- if (isSessionParked(lastSeen.status) && typeof lastSeen.costUsd === "number")
1796
+ if (isSettledRecord(lastSeen))
1302
1797
  return lastSeen;
1303
1798
  const deadline = Date.now() + SETTLE_POLL_DEADLINE_MS;
1304
1799
  while (signal?.aborted !== true && Date.now() < deadline) {
1305
1800
  const record = await operations.getSession(http, sessionId).catch(() => undefined);
1306
- if (record !== undefined && isSessionParked(record.status))
1801
+ if (record !== undefined && isSettledRecord(record))
1307
1802
  return record;
1308
1803
  try {
1309
1804
  await sleep(SETTLE_POLL_INTERVAL_MS, signal);
@@ -1314,6 +1809,28 @@ async function settledSessionRecord(http, sessionId, lastSeen, signal) {
1314
1809
  }
1315
1810
  return undefined;
1316
1811
  }
1812
+ /**
1813
+ * How long a follow-up `send()` waits for the session RECORD to catch up after
1814
+ * our own turn parked idle before treating the `session_busy` as a genuinely
1815
+ * in-flight turn (ms). The idle park EVENT ends the turn stream, but the record
1816
+ * commit lags briefly behind it.
1817
+ */
1818
+ const SESSION_BUSY_RECONCILE_DEADLINE_MS = 30_000;
1819
+ /** Interval between record reads while reconciling a settle-lag `session_busy` (ms). */
1820
+ const SESSION_BUSY_RECONCILE_INTERVAL_MS = 500;
1821
+ /**
1822
+ * A 409 `session_busy` whose CURRENT status is `running` — the one case that is
1823
+ * transient: the platform is still catching up from our own just-parked turn.
1824
+ * The 409 body carries `{ error:"session_busy", status:<current> }`; any other
1825
+ * busy status (suspended / cancelling / deleted) is a real, non-retryable
1826
+ * rejection that must surface immediately.
1827
+ */
1828
+ function isSettlingSessionBusy(err) {
1829
+ if (!(err instanceof AexApiError) || err.status !== 409)
1830
+ return false;
1831
+ const body = asRecord(err.body);
1832
+ return body.error === "session_busy" && body.status === "running";
1833
+ }
1317
1834
  /**
1318
1835
  * A session is "parked" once it stops making progress: it reached one of the
1319
1836
  * turn-terminal statuses (`idle` / `suspended` / `error`) or a terminal run
@@ -1322,6 +1839,7 @@ async function settledSessionRecord(http, sessionId, lastSeen, signal) {
1322
1839
  function isSessionParked(status) {
1323
1840
  return (status === "idle" ||
1324
1841
  status === "suspended" ||
1842
+ status === "awaiting_approval" ||
1325
1843
  status === "error" ||
1326
1844
  status === "deleted" ||
1327
1845
  status === "expired" ||
@@ -1352,49 +1870,6 @@ function scopedAbortSignal(timeoutMs) {
1352
1870
  }
1353
1871
  };
1354
1872
  }
1355
- /** Escape a literal string for safe interpolation into a RegExp. */
1356
- function escapeRegExp(input) {
1357
- return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1358
- }
1359
- function isOutputPathSelector(selector) {
1360
- return Boolean(selector && typeof selector === "object" && "path" in selector);
1361
- }
1362
- function resolveOutputFileSelector(outputs, selector, runId) {
1363
- if (isOutputPathSelector(selector)) {
1364
- const target = normalizeOutputLookupPath(selector.path);
1365
- if (!target) {
1366
- throw new RunStateError("Aex.downloadOutput: output path must be non-empty", {
1367
- runId,
1368
- path: selector.path
1369
- });
1370
- }
1371
- const matches = outputs.filter((output) => {
1372
- if (typeof output.filename !== "string")
1373
- return false;
1374
- const filename = normalizeOutputLookupPath(output.filename);
1375
- if (selector.match === "suffix") {
1376
- return filename === target || filename.endsWith(`/${target}`);
1377
- }
1378
- return filename === target;
1379
- });
1380
- if (matches.length === 1)
1381
- return matches[0];
1382
- if (matches.length > 1) {
1383
- throw new RunStateError(`Aex.downloadOutput: output path "${selector.path}" matched multiple files`, { runId, path: selector.path, matches: matches.map((output) => output.filename ?? output.id) });
1384
- }
1385
- throw new RunStateError(`Aex.downloadOutput: output path "${selector.path}" was not found`, {
1386
- runId,
1387
- path: selector.path
1388
- });
1389
- }
1390
- if (typeof selector.id !== "string" || selector.id.length === 0) {
1391
- throw new RunStateError("Aex.downloadOutput: selector must include an output id or path", { runId });
1392
- }
1393
- return { ...selector, id: selector.id };
1394
- }
1395
- function normalizeOutputLookupPath(path) {
1396
- return path.replace(/\\/g, "/").replace(/^\/+/, "");
1397
- }
1398
1873
  async function writeOptionalFile(bytes, to) {
1399
1874
  if (to !== undefined) {
1400
1875
  const { writeFile } = await import("node:fs/promises");
@@ -1419,11 +1894,13 @@ function sleep(ms, signal) {
1419
1894
  signal?.addEventListener("abort", onAbort, { once: true });
1420
1895
  });
1421
1896
  }
1422
- function generateIdempotencyKey() {
1423
- const cryptoObj = globalThis.crypto;
1424
- if (cryptoObj?.randomUUID)
1425
- return cryptoObj.randomUUID();
1426
- return `idem-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
1897
+ /**
1898
+ * ONE factory for every client-side validation throw: a
1899
+ * {@link RunConfigValidationError} carrying structured `details: { field, value? }`
1900
+ * so callers branch on `err.details.field` instead of string-parsing the message.
1901
+ */
1902
+ function configError(surface, field, message, value) {
1903
+ return new RunConfigValidationError(`${surface}: ${message}`, value === undefined ? { field } : { field, value });
1427
1904
  }
1428
1905
  /**
1429
1906
  * Derive the message idempotency key from the session-create key. Mirrors the
@@ -1478,7 +1955,7 @@ function normaliseSessionInput(input, surface, field) {
1478
1955
  }
1479
1956
  function assertNoLegacySessionFields(options, surface) {
1480
1957
  const record = options;
1481
- const removedProxyField = "proxy" + "Endpoints";
1958
+ const removedProxyField = ["proxy", "Endpoints"].join("");
1482
1959
  const messages = {
1483
1960
  input: "send user messages with session.send(...) or use run({ message }).",
1484
1961
  prompt: "use message for one-shot run input or session.send(...) for follow-up messages.",
@@ -1489,9 +1966,9 @@ function assertNoLegacySessionFields(options, surface) {
1489
1966
  secretEnv: "use environment.secrets.",
1490
1967
  secrets: "use top-level apiKeys for provider keys and environment.secrets for run secrets.",
1491
1968
  runtimeSize: "use runtime.",
1492
- parentRunId: "subagents are session-internal; parentRunId is not part of the session API.",
1493
1969
  limits: "use overrides.",
1494
1970
  timeout: "use overrides.timeout.",
1971
+ parentRunId: "subagent lineage is assigned by the platform.",
1495
1972
  signal: "use session.cancel() / session.suspend() for remote control.",
1496
1973
  postHook: "send a follow-up validation message when the session returns idle.",
1497
1974
  [removedProxyField]: "proxy endpoints are not part of the public SDK session API."
@@ -1701,13 +2178,24 @@ async function prepareAgentsMd(agentsMds, uploader) {
1701
2178
  });
1702
2179
  }
1703
2180
  /** Walk File[], eagerly upload drafts as assets (bounded concurrency), and return plain asset refs. */
1704
- async function prepareFiles(files, uploader) {
2181
+ async function prepareFiles(files, uploader, streamUploader) {
1705
2182
  return mapWithConcurrency(files, UPLOAD_CONCURRENCY, async (entry, i) => {
1706
2183
  if (!(entry instanceof File)) {
1707
2184
  throw new RunConfigValidationError(`aex: files[${i}] must be a File instance`);
1708
2185
  }
1709
2186
  const ref = entry.ref;
1710
2187
  if (ref.kind === "draft") {
2188
+ // A large draft carries a streaming driver instead of in-memory bytes.
2189
+ const stream = entry._takeDraftStream();
2190
+ if (stream) {
2191
+ const cached = entry._cachedAssetId;
2192
+ if (cached !== undefined) {
2193
+ return { kind: "asset", assetId: cached, name: stream.name, mountPath: stream.mountPath };
2194
+ }
2195
+ const uploaded = await streamUploader({ drive: stream.drive, contentType: "application/zip" });
2196
+ entry._rememberAsset(uploaded.assetId);
2197
+ return { kind: "asset", assetId: uploaded.assetId, name: stream.name, mountPath: stream.mountPath };
2198
+ }
1711
2199
  const bundle = entry._takeDraftBundle();
1712
2200
  if (!bundle) {
1713
2201
  throw new RunConfigValidationError(`aex: files[${i}] is draft but has no bytes`);