@shardflux/sdk 0.9.0 → 0.10.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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,64 @@
3
3
  Every API the README shows is available from the version named here. Below 1.0, a minor release may break
4
4
  compatibility; breaking changes are marked **Breaking**.
5
5
 
6
+ ## 0.10.0 (not yet published; npm `latest` is 0.9.0)
7
+
8
+ ### A command that could not start rejects exec.run() (ExecStartError)
9
+
10
+ A minor release for one runtime change. Production (2026-09-29): `shard ws exec <key> --cwd app -- ls` exited 1 and
11
+ printed nothing, because the session ended `failed_to_start` and `exec.run()` dropped its reason.
12
+
13
+ - **Breaking:** `cell.exec.run()` rejects with the new `ExecStartError` when the command could not start (a `cwd` that
14
+ is not a directory, a program not on `PATH`, an unknown user). It was resolving with `exitCode: null`, empty output
15
+ and the reason only in `session.error`. `ExecStartError` extends `ShardfluxApiError` as a 409 `conflict` with
16
+ `reason` `exec_failed_to_start` (as a file-first execution that could not start reports it), `details.session_id`,
17
+ `details.error`, `sessionId` and `session`; its message is `The command could not start: <the workspace's reason>`,
18
+ e.g. `working directory "/home/user/app" is not a directory`. Also when a start answered `starting` ends that way.
19
+ - The `exec` agent tool (processful) returns `error: { code, message, reason }` with `exit_code: null` for such a
20
+ command, as the file-first `exec` tool does for a failed execution, instead of an empty result. Tool definitions
21
+ are unchanged.
22
+ - The cell API now refuses a relative `cwd` on exec, execution and PTY starts with 422 `validation_failed`,
23
+ `details.reason` `invalid_cwd`, `details.field` `cwd` (for every SDK version); the message names the absolute path
24
+ it likely means, e.g. `use "/home/user/app"`. `KnownErrorReason` adds `invalid_cwd`; `RunOptions.cwd` and the cell
25
+ types (regenerated) document it.
26
+
27
+ ### Opt-in overage with a spend cap
28
+
29
+ Additive: an API without overage sends no `spend_cap` and no `reason`. The usage reads change types only (regenerated
30
+ from the API's OpenAPI); the account client's `setSpendPolicy()` gains the overage fields.
31
+
32
+ - Opt-in overage: `usage.summary()`, `spend()` and `estimate()` report `spend_cap` (new type `SpendCap`: `state`
33
+ `unavailable` | `off` | `paused` | `within_allowance` | `accruing` | `warning` | `reached`, `cap_minor`,
34
+ `effective_cap_minor`, `max_cap_minor`, `charges_minor`, `remaining_minor`, `percent_of_cap`, `currency`,
35
+ `resets_at`, `lines` per allowance with `units_over`, `billed_units`, `rate_minor`, `amount_minor`, and
36
+ `projected_reached_at`). Allowances past `included` while overage is on have `cap_state: 'overage'`; `summary()`
37
+ and `spend()` add `exhausted_reason`, and `spend.usage_charges_minor` and the estimate's charges are real amounts.
38
+ - `usage.spendPolicy()` and `ShardfluxAccount.billing.spendPolicy()` return the overage settings:
39
+ `overage_available`, `overage_enabled`, `overage_state` (`unavailable` | `off` | `on` | `paused`),
40
+ `spend_cap_minor`, `spend_cap_min_minor`, `spend_cap_max_minor`, `rates`, `currency` and `version`.
41
+ - `ShardfluxAccount.billing.setSpendPolicy(orgId, update)` (owners and billing members, with a user session; an API
42
+ key gets 403) takes `overageEnabled` and `spendCapMinor` besides `alertThresholdsPercent`, all optional (at least
43
+ one; an empty update throws before any request), and `ifMatch` (the `version` read, or `'*'`), sent as If-Match. New
44
+ type `SpendPolicyUpdate`. Runtime change: the body carries only the fields given.
45
+ - 402 `allowance_exhausted` carries `details.reason` (`allowance_used`, `overage_paused`, `spend_cap_reached`; also
46
+ `err.reason`) and `details.spend_cap` (`cap_minor`, `effective_cap_minor`, `charges_minor`, `currency`).
47
+ `KnownErrorReason` adds them, the spend-policy refusals (422 `overage_unavailable`, `spend_cap_required`,
48
+ `spend_cap_below_minimum`, `spend_cap_above_plan_price`, `spend_cap_below_charges`) and 409 `version_mismatch`.
49
+
50
+ ### Suspend when idle (contracts §20.6)
51
+
52
+ - `workspace.suspendWhenIdle({ afterSeconds, idempotencyKey? })` and `cloud.workspaces.suspendWhenIdle(id, {
53
+ afterSeconds })` (POST /v1/workspaces/{id}/suspend-when-idle): the workspace is suspended once it has been idle for
54
+ `afterSeconds` (30..3600), counted from the later of its last work and the request. Meant for the end of an agent
55
+ turn. A running command, an attached stream or a keepalive postpones it; the next tool call or a resume cancels it.
56
+ Resolves with `{ suspendRequest, operation, workspace }`: `operation` is the suspend already in progress, if any
57
+ (then nothing is recorded).
58
+ - `workspace.cancelSuspendWhenIdle()` and `cloud.workspaces.cancelSuspendWhenIdle(id)` (DELETE, idempotent).
59
+ - `workspace.suspendRequest`: the pending request from the view's `idle.suspend_request`, or null.
60
+ - Tool-call capture writes recorded before `suspendWhenIdle` land first, as for `suspend`, since a later write would
61
+ count as the next turn and cancel the request.
62
+ - Types `SuspendRequest`, `SuspendWhenIdleOptions`, `SuspendWhenIdleResult`, `SuspendWhenIdleResponse`.
63
+
6
64
  ## 0.9.0 (not yet published; npm `latest` is 0.8.0)
7
65
 
8
66
  Elastic compute (decision 0007): file tools and wake hints for parked workspaces. Additive; older APIs and cell
@@ -202,6 +260,7 @@ behavior is the automatic version check (below), which makes one background requ
202
260
  - New exports: `FeedbackCategory`, `FeedbackContext`, `FeedbackReceipt`, `SendFeedbackParams`, `AccountFeedbackParams`,
203
261
  `FEEDBACK_CATEGORIES`, `FEEDBACK_MESSAGE_MAX_LENGTH`.
204
262
 
263
+
205
264
  ## 0.8.0 (2026-09-28)
206
265
 
207
266
  Types only; nothing changes at run time and the API is unchanged.
package/README.md CHANGED
@@ -10,7 +10,7 @@ tool calls into the workspace ([tool-call capture](#tool-call-capture-070)).
10
10
  > **Early access.** Shardflux is in early access. The API is versioned (`/v1`), but this SDK is
11
11
  > below 1.0: a minor release may contain breaking changes (see [Compatibility](#compatibility)).
12
12
 
13
- > **Versions.** This README describes 0.9.0. Anything marked **(0.9.0+)** is not in 0.8.x, **(0.8.0+)** not in 0.7.x,
13
+ > **Versions.** This README describes 0.10.0. Anything marked **(0.10.0+)** is not in 0.9.0, **(0.9.0+)** not in 0.8.x, **(0.8.0+)** not in 0.7.x,
14
14
  > **(0.7.0+)** not in 0.6.x and **(0.6.0+)** not in 0.5.0; [CHANGELOG.md](./CHANGELOG.md) lists what each version added. Check yours with
15
15
  > `npm ls @shardflux/sdk` or the exported `SDK_VERSION`.
16
16
 
@@ -101,6 +101,23 @@ await cell.files.remove('/home/user/data.bin');
101
101
  twice. Aborting its `signal` also cancels the command in the workspace. File writes are atomic
102
102
  and durable (acknowledged after fsync).
103
103
 
104
+ `cwd` is an absolute path (commands start in `/home/user` without one). The API refuses a relative
105
+ `cwd` with 422 `validation_failed` (`err.reason === 'invalid_cwd'`); the message names the absolute
106
+ path it likely means. A command that could not start (a `cwd` that is not a directory, a program
107
+ that is not on `PATH`) rejects with `ExecStartError` **(0.10.0+)**, whose message is the workspace's
108
+ reason; before 0.10.0 `exec.run()` resolved with `exitCode: null` and no output.
109
+
110
+ ```ts
111
+ import { ExecStartError } from '@shardflux/sdk';
112
+
113
+ try {
114
+ await cell.exec.run(['ls'], { cwd: '/home/user/app' });
115
+ } catch (err) {
116
+ if (!(err instanceof ExecStartError)) throw err;
117
+ console.error(err.message); // The command could not start: working directory "/home/user/app" is not a directory
118
+ }
119
+ ```
120
+
104
121
  ### Search, patch and revisions (0.9.0+)
105
122
 
106
123
  ```ts
@@ -194,6 +211,29 @@ try {
194
211
  }
195
212
  ```
196
213
 
214
+ ### Suspend when idle (0.10.0+)
215
+
216
+ A running workspace is billed while it is awake, and its idle policy waits a while before suspending it. When your
217
+ agent's turn ends, ask for a suspend once the workspace has been idle for a short time instead:
218
+
219
+ ```ts
220
+ const { suspendRequest } = await workspace.suspendWhenIdle({ afterSeconds: 60 }); // 30..3600
221
+ workspace.suspendRequest; // { requested_at, after_seconds, not_before } until it applies or is cancelled
222
+ await workspace.cancelSuspendWhenIdle(); // idempotent
223
+ ```
224
+
225
+ - The idle time counts from the later of the workspace's last work and the request; `not_before` is the earliest
226
+ suspend.
227
+ - A command still running, an attached exec or terminal stream, or a keepalive postpones the suspend until
228
+ `afterSeconds` after it ends.
229
+ - The next tool call on the workspace (the next turn) or a resume cancels the request. Repeating replaces it.
230
+ - It applies under every idle policy, `never` included, and never delays a suspend the policy would do sooner.
231
+ - When a suspend is already in progress, the result's `operation` is that suspend and nothing is recorded.
232
+ - Errors: `ShardfluxApiError` 409 with `reason` `not_running`, `operation_in_progress`, `session_lifetime` or
233
+ `workspace_deleted`, and 422 `validation_failed` for `afterSeconds` outside 30..3600. A file-first workspace is never
234
+ suspended: `NotSupportedForModeError` (409 `not_supported_for_mode`).
235
+ - By id: `cloud.workspaces.suspendWhenIdle(id, { afterSeconds })` and `cloud.workspaces.cancelSuspendWhenIdle(id)`.
236
+
197
237
  **Suspended workspaces wake on use.** A tool call on a suspended workspace resumes it (or joins the resume or open
198
238
  already running), then runs. A call made during a suspend or resume waits for the transition to finish. The call
199
239
  never runs twice: the cell executes nothing it refused.
@@ -589,7 +629,7 @@ never injected).
589
629
 
590
630
  **Read-your-writes.** Calls through the same client first wait for capture writes recorded before them (bounded by
591
631
  `settleTimeoutMs`, 30 s; they never fail because of capture): `exec` and files calls through `workspace.cell()`,
592
- `workspaceTools`, `snapshot`, `fork`, `suspend`, `saveAsTemplate` and `close` (on the workspace handle and on
632
+ `workspaceTools`, `snapshot`, `fork`, `suspend`, `suspendWhenIdle` (0.10.0+), `saveAsTemplate` and `close` (on the workspace handle and on
593
633
  `cloud.workspaces.*(id)`). A lifecycle call's timing shows the wait as a `capture_flush` phase. `delete` and `reset`
594
634
  drop pending writes. A write to a suspended workspace wakes it (`wake: null` opts out).
595
635
 
@@ -703,6 +743,26 @@ if (!done.subscription_active) console.log(`checkout ${done.status}`);
703
743
  // After timeoutMs: CheckoutTimeoutError (err.checkout is the last status); on abort: the signal's reason.
704
744
  ```
705
745
 
746
+ Opt-in overage and its spend cap (owners and billing members): read the policy, then change it. Every field is
747
+ optional (give at least one); `ifMatch` (the `version` you read) makes a concurrent change a 409 `version_mismatch`
748
+ instead of overwriting it.
749
+
750
+ ```ts
751
+ const policy = await account.billing.spendPolicy(org.id);
752
+ // overage_state: unavailable | off | on | paused; the cap range: spend_cap_min_minor..spend_cap_max_minor (the plan price)
753
+ if (policy.overage_available) {
754
+ await account.billing.setSpendPolicy(org.id, { overageEnabled: true, spendCapMinor: 900, ifMatch: policy.version }); // $9.00
755
+ }
756
+ await account.billing.setSpendPolicy(org.id, { overageEnabled: false }); // always allowed
757
+ await account.billing.setSpendPolicy(org.id, { alertThresholdsPercent: [50, 80, 100] });
758
+ ```
759
+
760
+ A refused change is a `ShardfluxApiError` 422 `validation_failed` with `reason` `overage_unavailable`,
761
+ `spend_cap_required`, `spend_cap_below_minimum` (`details.min_minor`), `spend_cap_above_plan_price`
762
+ (`details.max_minor`) or `spend_cap_below_charges` (`details.charges_minor`: the cap cannot go below what overage
763
+ already charged this period). Every owner and billing member gets an email when overage is turned on or off or the cap
764
+ changes.
765
+
706
766
  ## Errors
707
767
 
708
768
  - `ShardfluxApiError`: the API or the workspace refused the request. Fields: `status`, `code`,
@@ -724,6 +784,18 @@ if (!done.subscription_active) console.log(`checkout ${done.status}`);
724
784
 
725
785
  Treat unknown error codes and reasons as generic errors: show `message`, and use `retryable`.
726
786
 
787
+ A 402 `allowance_exhausted` (opens, resumes and forks refused while a CPU-hours or RAM GiB-hours allowance is used up)
788
+ carries `reason` **(0.9.0+)**:
789
+
790
+ | `reason` | Meaning | What to do |
791
+ | --- | --- | --- |
792
+ | `allowance_used` | The allowance is used up and overage is off or not on the plan. | Upgrade, or have an owner or billing member turn on overage under Usage & billing; or wait for `details.resets_at`. |
793
+ | `overage_paused` | Overage is on but paused while a plan payment is past due. | An owner or billing member updates the payment method. |
794
+ | `spend_cap_reached` | Overage charges reached the spend cap for this billing period. | Raise the cap (up to the plan price) or upgrade under Usage & billing; or wait for `details.resets_at`. |
795
+
796
+ `details.spend_cap` is `{ cap_minor, effective_cap_minor, charges_minor, currency }` (minor units, `null` when the plan
797
+ has no overage). An older API sends no `reason`. Do not retry these in a loop.
798
+
727
799
  Retryable 429/502/503/504 refusals (for example 503 `host_capacity`, when the workspace's host has no room to restore
728
800
  it right now, or `wake_failed`) are retried after `Retry-After` for reads, searches and calls that carry an
729
801
  Idempotency-Key (writes and patches); other calls surface them with `retryable: true` and `retryAfterSeconds`.
@@ -733,6 +805,36 @@ A read of a sleeping workspace that its disk cannot answer (409 `workspace_not_r
733
805
  `host_feature_unavailable` (the workspace's host predates the call, `details.feature`) is neither retried nor
734
806
  woken: it lasts until the workspace runs on an upgraded host.
735
807
 
808
+ ## Usage and overage
809
+
810
+ `cloud.usage` reads the organization's usage (API keys see organization totals and their own project's workspaces):
811
+ `summary(orgId)`, `series(orgId, params)`, `workspace(workspaceId, params)`, `estimate(orgId)`, `grants(orgId)`,
812
+ `spend(orgId)` and `spendPolicy(orgId)`.
813
+
814
+ ```ts
815
+ const s = await cloud.usage.summary(orgId);
816
+ if (s.allowance_exhausted) console.log('starts are refused:', s.exhausted_reason); // allowance_used | overage_paused | spend_cap_reached
817
+ const cap = s.spend_cap; // opt-in overage this period (0.10.0+)
818
+ const usd = (minor: number) => `$${(minor / 100).toFixed(2)}`; // amounts are minor units of cap.currency
819
+ if (cap.state === 'accruing' || cap.state === 'warning') {
820
+ console.log(`overage ${usd(cap.charges_minor)} of ${usd(cap.effective_cap_minor)}; cap reached ${cap.projected_reached_at ?? 'not this period'}`);
821
+ }
822
+ ```
823
+
824
+ Opt-in overage **(0.10.0+)**: while an owner or billing member has turned it on, workspaces keep opening and running
825
+ past the CPU-hours and RAM GiB-hours allowances (those allowances show `cap_state: 'overage'`), and the usage past them
826
+ is charged on the next invoice until the charges reach the spend cap.
827
+
828
+ - `summary()`, `spend()` and `estimate()` carry `spend_cap` (type `SpendCap`): `state` (`unavailable`, `off`,
829
+ `paused`, `within_allowance`, `accruing`, `warning`, `reached`), `cap_minor`, `effective_cap_minor`,
830
+ `charges_minor`, `remaining_minor`, `percent_of_cap`, `currency`, `resets_at`, `lines` (per allowance: `units_over`,
831
+ `billed_units`, `rate_minor`, `amount_minor`) and `projected_reached_at`. `summary()` and `spend()` also carry
832
+ `exhausted_reason`.
833
+ - `spendPolicy()` returns the settings: `overage_available`, `overage_enabled`, `overage_state` (`unavailable`, `off`,
834
+ `on`, `paused`), `spend_cap_minor`, `spend_cap_min_minor`, `spend_cap_max_minor`, `rates` and `currency`.
835
+ - An API key only reads them. Owners and billing members turn overage on or off and change the cap in the console,
836
+ or with a user session: `ShardfluxAccount.billing.setSpendPolicy()` (see below).
837
+
736
838
  ## Feedback (0.9.0+)
737
839
 
738
840
  `cloud.sendFeedback()` sends a message straight to the Shardflux founder, who reads every one. If you or your coding
package/dist/account.d.ts CHANGED
@@ -320,7 +320,7 @@ export declare class CheckoutTimeoutError extends Error {
320
320
  }
321
321
  /**
322
322
  * Billing with a user session: the catalog and subscription (as for API keys) plus Checkout, the Stripe portal,
323
- * invoices and usage alert thresholds (owner/billing members).
323
+ * invoices, usage alert thresholds and opt-in overage with its spend cap (owner/billing members).
324
324
  */
325
325
  export declare class AccountBillingApi extends BillingApi {
326
326
  #private;
@@ -342,12 +342,36 @@ export declare class AccountBillingApi extends BillingApi {
342
342
  /** A Stripe customer portal link (plan changes, payment methods, cancellation). */
343
343
  portal(organizationId: string): Promise<BillingPortalSession>;
344
344
  invoices(organizationId: string, params?: PageParams): Promise<BillingInvoicePage>;
345
- /** Usage alert thresholds. */
345
+ /**
346
+ * Usage alert thresholds and opt-in overage: `overage_available`, `overage_enabled`, `overage_state` (`unavailable`,
347
+ * `off`, `on`, `paused`), `spend_cap_minor` with `spend_cap_min_minor` and `spend_cap_max_minor` (the plan price),
348
+ * `rates`, `currency` and `version` (send it as `ifMatch`).
349
+ */
346
350
  spendPolicy(organizationId: string): Promise<SpendPolicy>;
347
- /** Sets the usage alert thresholds (percent of the allowance, 1..100, at most 5; [] turns alerts off). */
348
- setSpendPolicy(organizationId: string, params: {
349
- alertThresholdsPercent: number[];
350
- }): Promise<SpendPolicy>;
351
+ /**
352
+ * Changes the spend policy (owner/billing members): alert thresholds and opt-in overage. Every field is optional;
353
+ * give at least one. The change takes effect at once, and every owner and billing member gets an email when overage
354
+ * is turned on or off or the cap changes. Refusals: 422 `validation_failed` with `reason` `overage_unavailable`,
355
+ * `spend_cap_required`, `spend_cap_below_minimum` (`details.min_minor`), `spend_cap_above_plan_price`
356
+ * (`details.max_minor`) or `spend_cap_below_charges` (`details.charges_minor`); with `ifMatch`, 409 `conflict`
357
+ * `version_mismatch` (`details.current_version`) when the policy changed since you read it. Nothing changes then.
358
+ */
359
+ setSpendPolicy(organizationId: string, params: SpendPolicyUpdate): Promise<SpendPolicy>;
360
+ }
361
+ /** A spend-policy change (`AccountBillingApi.setSpendPolicy`): every field is optional; give at least one. */
362
+ export interface SpendPolicyUpdate {
363
+ /** Percent of each allowance that sends a usage email (1..100, at most 5); [] turns alerts off. */
364
+ alertThresholdsPercent?: number[];
365
+ /** Turns opt-in overage on (needs a spend cap, given here or set before) or off (always allowed; what it charged stays on the next invoice). */
366
+ overageEnabled?: boolean;
367
+ /**
368
+ * The spend cap per billing period in minor units of the plan currency (900 = $9.00): at least
369
+ * `spend_cap_min_minor`, at most `spend_cap_max_minor` (the plan price), and not below what overage charged this
370
+ * period.
371
+ */
372
+ spendCapMinor?: number;
373
+ /** The policy `version` you read (sent as If-Match), or '*' for any: a concurrent change answers 409 `version_mismatch`. */
374
+ ifMatch?: number | '*';
351
375
  }
352
376
  export declare class AccountExportsApi {
353
377
  #private;
package/dist/account.js CHANGED
@@ -347,7 +347,7 @@ export class CheckoutTimeoutError extends Error {
347
347
  }
348
348
  /**
349
349
  * Billing with a user session: the catalog and subscription (as for API keys) plus Checkout, the Stripe portal,
350
- * invoices and usage alert thresholds (owner/billing members).
350
+ * invoices, usage alert thresholds and opt-in overage with its spend cap (owner/billing members).
351
351
  */
352
352
  export class AccountBillingApi extends BillingApi {
353
353
  #core;
@@ -394,13 +394,34 @@ export class AccountBillingApi extends BillingApi {
394
394
  invoices(organizationId, params) {
395
395
  return json(this.#core, 'GET', `/v1/organizations/${enc(organizationId)}/billing/invoices`, pageQuery(params));
396
396
  }
397
- /** Usage alert thresholds. */
397
+ /**
398
+ * Usage alert thresholds and opt-in overage: `overage_available`, `overage_enabled`, `overage_state` (`unavailable`,
399
+ * `off`, `on`, `paused`), `spend_cap_minor` with `spend_cap_min_minor` and `spend_cap_max_minor` (the plan price),
400
+ * `rates`, `currency` and `version` (send it as `ifMatch`).
401
+ */
398
402
  spendPolicy(organizationId) {
399
403
  return json(this.#core, 'GET', `/v1/organizations/${enc(organizationId)}/spend-policy`);
400
404
  }
401
- /** Sets the usage alert thresholds (percent of the allowance, 1..100, at most 5; [] turns alerts off). */
405
+ /**
406
+ * Changes the spend policy (owner/billing members): alert thresholds and opt-in overage. Every field is optional;
407
+ * give at least one. The change takes effect at once, and every owner and billing member gets an email when overage
408
+ * is turned on or off or the cap changes. Refusals: 422 `validation_failed` with `reason` `overage_unavailable`,
409
+ * `spend_cap_required`, `spend_cap_below_minimum` (`details.min_minor`), `spend_cap_above_plan_price`
410
+ * (`details.max_minor`) or `spend_cap_below_charges` (`details.charges_minor`); with `ifMatch`, 409 `conflict`
411
+ * `version_mismatch` (`details.current_version`) when the policy changed since you read it. Nothing changes then.
412
+ */
402
413
  setSpendPolicy(organizationId, params) {
403
- return json(this.#core, 'PUT', `/v1/organizations/${enc(organizationId)}/spend-policy`, { json: { alert_thresholds_percent: params.alertThresholdsPercent } });
414
+ const body = {};
415
+ if (params.alertThresholdsPercent !== undefined)
416
+ body.alert_thresholds_percent = params.alertThresholdsPercent;
417
+ if (params.overageEnabled !== undefined)
418
+ body.overage_enabled = params.overageEnabled;
419
+ if (params.spendCapMinor !== undefined)
420
+ body.spend_cap_minor = params.spendCapMinor;
421
+ if (Object.keys(body).length === 0)
422
+ return Promise.reject(new Error('setSpendPolicy: give at least one of alertThresholdsPercent, overageEnabled, spendCapMinor'));
423
+ const headers = params.ifMatch === undefined ? {} : { 'if-match': params.ifMatch === '*' ? '*' : `"${params.ifMatch}"` };
424
+ return json(this.#core, 'PUT', `/v1/organizations/${enc(organizationId)}/spend-policy`, { json: body, headers });
404
425
  }
405
426
  }
406
427
  export class AccountExportsApi {
package/dist/cell.d.ts CHANGED
@@ -223,6 +223,10 @@ export interface RunResult {
223
223
  }
224
224
  export interface RunOptions {
225
225
  sessionId?: string;
226
+ /**
227
+ * Absolute working directory (default the workspace's, /home/user). The API refuses a relative path with 422
228
+ * `validation_failed`, details.reason `invalid_cwd`; one that is not a directory rejects with ExecStartError.
229
+ */
226
230
  cwd?: string;
227
231
  env?: Record<string, string>;
228
232
  user?: string;
@@ -297,6 +301,8 @@ export declare class CellClient {
297
301
  /**
298
302
  * Starts (or re-attaches to) a session and collects its output until it exits, reconnecting
299
303
  * with offsets after dropped streams. Never issues a second start for the same session_id.
304
+ * A command that could not start (a cwd that is not a directory, a program not on PATH, an unknown user) rejects
305
+ * with ExecStartError (0.10.0+): nothing ran, so there is no exit code to return.
300
306
  * A file-first workspace has no sessions: use `executions.run()` (refused locally when the mode is known).
301
307
  */
302
308
  run: (argv: string[], opts?: RunOptions) => Promise<RunResult>;
package/dist/cell.js CHANGED
@@ -1,4 +1,4 @@
1
- import { NotSupportedForModeError, ShardfluxApiError, ShardfluxProtocolError } from "./errors.js";
1
+ import { ExecStartError, NotSupportedForModeError, ShardfluxApiError, ShardfluxProtocolError } from "./errors.js";
2
2
  import { EXECUTION_ID, ExecutionResult, newExecutionId } from "./executions.js";
3
3
  import { HttpClient, defaultSleep, randomId, treeRevisionOf } from "./http.js";
4
4
  import { describeFailure, emitTo } from "./progress.js";
@@ -347,6 +347,8 @@ export class CellClient {
347
347
  /**
348
348
  * Starts (or re-attaches to) a session and collects its output until it exits, reconnecting
349
349
  * with offsets after dropped streams. Never issues a second start for the same session_id.
350
+ * A command that could not start (a cwd that is not a directory, a program not on PATH, an unknown user) rejects
351
+ * with ExecStartError (0.10.0+): nothing ran, so there is no exit code to return.
350
352
  * A file-first workspace has no sessions: use `executions.run()` (refused locally when the mode is known).
351
353
  */
352
354
  run: async (argv, opts = {}) => {
@@ -369,7 +371,9 @@ export class CellClient {
369
371
  req.kill_grace_ms = opts.killGraceMs;
370
372
  if (opts.secretRefs !== undefined)
371
373
  req.secret_refs = opts.secretRefs;
372
- await this.exec.start(req, opts.signal);
374
+ const started = await this.exec.start(req, opts.signal);
375
+ if (started.state === 'failed_to_start')
376
+ throw new ExecStartError(started);
373
377
  try {
374
378
  return await this.#collect(sessionId, opts);
375
379
  }
@@ -453,6 +457,9 @@ export class CellClient {
453
457
  break;
454
458
  }
455
459
  }
460
+ // A start answered while the session was still starting can end without starting the command.
461
+ if (session.state === 'failed_to_start')
462
+ throw new ExecStartError(session);
456
463
  return {
457
464
  sessionId,
458
465
  exitCode: session.exit_code ?? null,
package/dist/client.d.ts CHANGED
@@ -94,6 +94,28 @@ export type CheckoutSession = Ok<operations['postApiV1OrganizationsOrganizationI
94
94
  export type PortalSession = Ok<operations['postApiV1OrganizationsOrganizationIdBillingPortalSessions']>;
95
95
  export type InvoicePage = Ok<operations['getApiV1OrganizationsOrganizationIdBillingInvoices']>;
96
96
  export type Invoice = InvoicePage['data'][number];
97
+ /**
98
+ * A pending suspend-when-idle request (0.10.0; contracts §20.6): once the workspace has been idle for `after_seconds`
99
+ * (counted from the later of its last work and `requested_at`), it is suspended; `not_before` = requested_at +
100
+ * after_seconds is the earliest.
101
+ */
102
+ export type SuspendRequest = components['schemas']['SuspendRequest'];
103
+ /** POST /v1/workspaces/{id}/suspend-when-idle (202). */
104
+ export type SuspendWhenIdleResponse = Ok<operations['postV1WorkspacesWorkspaceIdSuspendWhenIdle']>;
105
+ export interface SuspendWhenIdleOptions {
106
+ /** Seconds the workspace must stay idle before it is suspended: an integer from 30 to 3600 (the API refuses others with 422 validation_failed). */
107
+ afterSeconds: number;
108
+ /** Replays the stored response for a repeated request (default: a fresh key per call, so transport retries replay). */
109
+ idempotencyKey?: string;
110
+ }
111
+ export interface SuspendWhenIdleResult {
112
+ /** The recorded request; null when a suspend was already in progress (then `operation` is that suspend). */
113
+ suspendRequest: SuspendRequest | null;
114
+ /** The suspend already in progress (nothing was recorded), else null: the suspend itself happens later, from the cell's idle loop. */
115
+ operation: Operation | null;
116
+ /** The workspace after the call (on a handle: the handle itself, with its view updated). */
117
+ workspace: Workspace;
118
+ }
97
119
  export interface ShardfluxOptions {
98
120
  /** Project API key: sfk_<key_id>_<secret>. */
99
121
  apiKey: string;
@@ -324,6 +346,28 @@ export declare class WorkspacesApi {
324
346
  snapshot(workspaceId: string, opts?: LifecycleOptions & {
325
347
  label?: string;
326
348
  }): Promise<Operation>;
349
+ /**
350
+ * Suspends the workspace once it has been idle for `afterSeconds` (0.9.0; contracts §20.6): meant for the end of an
351
+ * agent turn, so the workspace stops using RAM soon after instead of waiting out its idle policy. The idle time
352
+ * counts from the later of the workspace's last work and this request. A command still running, an attached exec or
353
+ * terminal stream, or a keepalive postpones the suspend until `afterSeconds` after it ends; a tool call after the
354
+ * request (the next turn) or a resume cancels it. Repeating replaces the pending request. It applies under every idle
355
+ * policy (`never` included) and never delays a suspend the policy would do sooner.
356
+ *
357
+ * Resolves with the recorded `suspendRequest`, or, when a suspend is already in progress, with that `operation` and
358
+ * nothing recorded. Tool-call capture writes recorded before the call land first (a later write would count as the
359
+ * next turn). Errors: ShardfluxApiError 409 with reason `not_running`, `operation_in_progress`, `session_lifetime` or
360
+ * `workspace_deleted`; 422 `validation_failed` for `afterSeconds` outside 30..3600.
361
+ *
362
+ * await cloud.workspaces.suspendWhenIdle(workspace.id, { afterSeconds: 60 });
363
+ */
364
+ suspendWhenIdle(workspaceId: string, opts: SuspendWhenIdleOptions): Promise<SuspendWhenIdleResult>;
365
+ /**
366
+ * Cancels a pending suspend-when-idle request (0.10.0; DELETE /v1/workspaces/{id}/suspend-when-idle). Idempotent,
367
+ * in any workspace state; resolves with the workspace (`suspendRequest` null). A suspend the request already started
368
+ * is not undone: it is the workspace's `activeOperation` (resume or open the workspace instead).
369
+ */
370
+ cancelSuspendWhenIdle(workspaceId: string): Promise<Workspace>;
327
371
  /**
328
372
  * Ends a session workspace now (contracts §19.11): the workspace is deleted exactly like delete() (ended_reason
329
373
  * closed) and returns the `delete` operation (input.reason session_closed); the key then opens a NEW workspace.
package/dist/client.js CHANGED
@@ -418,6 +418,34 @@ export class WorkspacesApi {
418
418
  snapshot(workspaceId, opts = {}) {
419
419
  return this.#op('snapshot', workspaceId, opts.label === undefined ? {} : { label: opts.label }, opts);
420
420
  }
421
+ /**
422
+ * Suspends the workspace once it has been idle for `afterSeconds` (0.9.0; contracts §20.6): meant for the end of an
423
+ * agent turn, so the workspace stops using RAM soon after instead of waiting out its idle policy. The idle time
424
+ * counts from the later of the workspace's last work and this request. A command still running, an attached exec or
425
+ * terminal stream, or a keepalive postpones the suspend until `afterSeconds` after it ends; a tool call after the
426
+ * request (the next turn) or a resume cancels it. Repeating replaces the pending request. It applies under every idle
427
+ * policy (`never` included) and never delays a suspend the policy would do sooner.
428
+ *
429
+ * Resolves with the recorded `suspendRequest`, or, when a suspend is already in progress, with that `operation` and
430
+ * nothing recorded. Tool-call capture writes recorded before the call land first (a later write would count as the
431
+ * next turn). Errors: ShardfluxApiError 409 with reason `not_running`, `operation_in_progress`, `session_lifetime` or
432
+ * `workspace_deleted`; 422 `validation_failed` for `afterSeconds` outside 30..3600.
433
+ *
434
+ * await cloud.workspaces.suspendWhenIdle(workspace.id, { afterSeconds: 60 });
435
+ */
436
+ async suspendWhenIdle(workspaceId, opts) {
437
+ await this.#ctx().captures.settle(workspaceId);
438
+ const body = await this.#http.json('POST', `/v1/workspaces/${encodeURIComponent(workspaceId)}/suspend-when-idle`, { json: { after_seconds: opts.afterSeconds }, idempotencyKey: opts.idempotencyKey ?? randomId('op-') }, this.#auth);
439
+ return { suspendRequest: body.suspend_request, operation: body.operation, workspace: this.#wrap(body.workspace) };
440
+ }
441
+ /**
442
+ * Cancels a pending suspend-when-idle request (0.10.0; DELETE /v1/workspaces/{id}/suspend-when-idle). Idempotent,
443
+ * in any workspace state; resolves with the workspace (`suspendRequest` null). A suspend the request already started
444
+ * is not undone: it is the workspace's `activeOperation` (resume or open the workspace instead).
445
+ */
446
+ async cancelSuspendWhenIdle(workspaceId) {
447
+ return this.#wrap(await this.#http.json('DELETE', `/v1/workspaces/${encodeURIComponent(workspaceId)}/suspend-when-idle`, {}, this.#auth));
448
+ }
421
449
  async close(workspaceId, opts = {}) {
422
450
  return (await this.closeWithView(workspaceId, opts)).operation;
423
451
  }
package/dist/errors.d.ts CHANGED
@@ -38,8 +38,21 @@ export type ErrorCode = AppErrorCode | CellErrorCode;
38
38
  * service_unavailable no_execution_host (retryable, Retry-After). An execution that ended `failed` or `lost` names its
39
39
  * cause in `error.details.reason`: lease_expired, host_unreachable, host_restarted, tree_moved, blob_missing,
40
40
  * blob_corrupt, exec_failed_to_start, among others.
41
+ * Working directories (0.10.0): 422 validation_failed invalid_cwd (details.field `cwd`): an exec, execution or PTY start
42
+ * named a relative cwd, which is refused rather than resolved (the message names the absolute path it likely means);
43
+ * a processful command that could not start rejects `exec.run()` with ExecStartError (409 conflict
44
+ * exec_failed_to_start).
45
+ * Opt-in overage with a spend cap (0.10.0): 402 allowance_exhausted on opens, resumes and forks carries details.reason
46
+ * allowance_used (a CPU-hours or RAM GiB-hours allowance is used up and overage is off or not on the plan),
47
+ * overage_paused (overage is on but paused while a plan payment is past due) or spend_cap_reached (overage charges
48
+ * reached the spend cap), plus details.spend_cap {cap_minor, effective_cap_minor, charges_minor, currency}; an API
49
+ * older than overage sends no reason. Changing overage is for owners and billing members (the console, or a user
50
+ * session: ShardfluxAccount.billing.setSpendPolicy; an API key gets 403), whose 422 validation_failed reasons are
51
+ * overage_unavailable, spend_cap_required, spend_cap_below_minimum (details.min_minor), spend_cap_above_plan_price
52
+ * (details.max_minor) and spend_cap_below_charges (details.charges_minor); with `ifMatch`, 409 conflict
53
+ * version_mismatch (details.current_version), as egress puts with `ifMatch` answer too.
41
54
  */
42
- export type KnownErrorReason = 'invalid_recipe' | 'base_not_layered' | 'language_unavailable' | 'language_conflict' | 'invalid_package' | 'too_many_files' | 'platform_owned_path' | 'upload_required' | 'upload_missing' | 'upload_digest_mismatch' | 'upload_too_large' | 'extra_hosts_without_auto' | 'invalid_settings' | 'services_unsupported' | 'input_required' | 'input_unknown' | 'input_invalid' | 'egress_widening' | 'reserved_session_id' | 'env_collision' | 'reserved_template_slug' | 'package_index_unavailable' | 'package_not_found' | 'startup_failed' | 'service_not_ready' | 'secrets_unavailable' | 'workspace_not_running' | 'operation_in_progress' | 'workspace_deleted' | 'secret_not_available' | 'legacy_disk_layout' | 'not_session' | 'session_lifetime' | 'lifetime_mismatch' | 'not_resettable' | 'template_not_layered' | 'draft_exists' | 'draft_stale' | 'build_in_progress' | 'file_list_unavailable' | 'file_list_indexing' | 'guest_feature_unavailable' | 'confirm_destructive_required' | 'reserved_key_prefix' | 'invalid_defaults' | 'update_policy_not_available' | 'invalid_path' | 'too_many_acknowledged_findings' | 'template_dev_mode_role' | 'draft_not_found' | 'version_not_found' | 'path_not_found' | 'revision_mismatch' | 'edit_not_found' | 'edit_ambiguous' | 'edit_not_text' | 'patch_invalid' | 'host_capacity' | 'wake_failed' | 'workspace_fenced' | 'offline_unavailable' | 'offline_budget' | 'offline_changed' | 'host_feature_unavailable' | 'not_supported_for_mode' | 'mode_mismatch' | 'mode_not_available' | 'layout_unsupported' | 'tree_revision_mismatch' | 'outside_tree_root' | 'execution_in_progress' | 'execution_id_reused' | 'operation_id_reused' | 'no_execution_host' | 'lease_expired' | 'host_unreachable' | 'host_restarted' | 'tree_moved' | 'blob_missing' | 'blob_corrupt' | 'exec_failed_to_start';
55
+ export type KnownErrorReason = 'invalid_recipe' | 'base_not_layered' | 'language_unavailable' | 'language_conflict' | 'invalid_package' | 'too_many_files' | 'platform_owned_path' | 'upload_required' | 'upload_missing' | 'upload_digest_mismatch' | 'upload_too_large' | 'extra_hosts_without_auto' | 'invalid_settings' | 'services_unsupported' | 'input_required' | 'input_unknown' | 'input_invalid' | 'egress_widening' | 'reserved_session_id' | 'env_collision' | 'reserved_template_slug' | 'package_index_unavailable' | 'package_not_found' | 'startup_failed' | 'service_not_ready' | 'secrets_unavailable' | 'workspace_not_running' | 'operation_in_progress' | 'workspace_deleted' | 'secret_not_available' | 'legacy_disk_layout' | 'not_session' | 'session_lifetime' | 'lifetime_mismatch' | 'not_resettable' | 'template_not_layered' | 'draft_exists' | 'draft_stale' | 'build_in_progress' | 'file_list_unavailable' | 'file_list_indexing' | 'guest_feature_unavailable' | 'confirm_destructive_required' | 'reserved_key_prefix' | 'invalid_defaults' | 'update_policy_not_available' | 'invalid_path' | 'too_many_acknowledged_findings' | 'template_dev_mode_role' | 'draft_not_found' | 'version_not_found' | 'path_not_found' | 'revision_mismatch' | 'edit_not_found' | 'edit_ambiguous' | 'edit_not_text' | 'patch_invalid' | 'host_capacity' | 'wake_failed' | 'workspace_fenced' | 'offline_unavailable' | 'offline_budget' | 'offline_changed' | 'host_feature_unavailable' | 'not_supported_for_mode' | 'mode_mismatch' | 'mode_not_available' | 'layout_unsupported' | 'tree_revision_mismatch' | 'outside_tree_root' | 'execution_in_progress' | 'execution_id_reused' | 'operation_id_reused' | 'no_execution_host' | 'lease_expired' | 'host_unreachable' | 'host_restarted' | 'tree_moved' | 'blob_missing' | 'blob_corrupt' | 'exec_failed_to_start' | 'invalid_cwd' | 'allowance_used' | 'overage_paused' | 'spend_cap_reached' | 'overage_unavailable' | 'spend_cap_required' | 'spend_cap_below_minimum' | 'spend_cap_above_plan_price' | 'spend_cap_below_charges' | 'version_mismatch';
43
56
  /** A known reason, or any other string the server sends (reasons are open-ended). */
44
57
  export type ErrorReason = KnownErrorReason | (string & {});
45
58
  export interface ErrorBodyLike {
@@ -104,6 +117,21 @@ export declare class TreeRevisionMismatchError extends ShardfluxApiError {
104
117
  readonly currentTreeRevision: number | null;
105
118
  constructor(status: number, body: ErrorBodyLike, source: 'api' | 'cell', retryAfterSeconds?: number, treeRevision?: number);
106
119
  }
120
+ type ExecSession = CellComponents['schemas']['ExecSession'];
121
+ /**
122
+ * A command that could not start (0.10.0+): its working directory is not a directory, its program is not on PATH, or
123
+ * its user does not exist. Nothing ran, so there is no exit code and no output. `exec.run()` rejects with it when the
124
+ * session ends `failed_to_start` (before 0.10.0 it resolved with `exitCode: null` and empty output). It is a 409
125
+ * `conflict` with details.reason `exec_failed_to_start`, as a file-first execution that could not start reports it;
126
+ * the message carries the workspace's own words, e.g. `working directory "/home/user/app" is not a directory`.
127
+ */
128
+ export declare class ExecStartError extends ShardfluxApiError {
129
+ /** The exec session that could not start. */
130
+ readonly sessionId: string;
131
+ /** The session as the cell reported it (state `failed_to_start`, `error`). */
132
+ readonly session: ExecSession;
133
+ constructor(session: ExecSession);
134
+ }
107
135
  /**
108
136
  * Builds the error for an error body, typed by its reason: NotSupportedForModeError for `not_supported_for_mode`,
109
137
  * TreeRevisionMismatchError for `tree_revision_mismatch`, else ShardfluxApiError. Every SDK request builds its errors
package/dist/errors.js CHANGED
@@ -83,6 +83,34 @@ export class TreeRevisionMismatchError extends ShardfluxApiError {
83
83
  this.currentTreeRevision = typeof cur === 'number' && Number.isSafeInteger(cur) ? cur : (treeRevision ?? null);
84
84
  }
85
85
  }
86
+ /**
87
+ * A command that could not start (0.10.0+): its working directory is not a directory, its program is not on PATH, or
88
+ * its user does not exist. Nothing ran, so there is no exit code and no output. `exec.run()` rejects with it when the
89
+ * session ends `failed_to_start` (before 0.10.0 it resolved with `exitCode: null` and empty output). It is a 409
90
+ * `conflict` with details.reason `exec_failed_to_start`, as a file-first execution that could not start reports it;
91
+ * the message carries the workspace's own words, e.g. `working directory "/home/user/app" is not a directory`.
92
+ */
93
+ export class ExecStartError extends ShardfluxApiError {
94
+ /** The exec session that could not start. */
95
+ sessionId;
96
+ /** The session as the cell reported it (state `failed_to_start`, `error`). */
97
+ session;
98
+ constructor(session) {
99
+ const why = session.error ?? 'the workspace gave no reason';
100
+ super(409, {
101
+ error: {
102
+ code: 'conflict',
103
+ message: `The command could not start: ${why}`,
104
+ request_id: '',
105
+ retryable: false,
106
+ details: { reason: 'exec_failed_to_start', session_id: session.session_id, ...(session.error ? { error: session.error } : {}) },
107
+ },
108
+ }, 'cell');
109
+ this.name = 'ExecStartError';
110
+ this.sessionId = session.session_id;
111
+ this.session = session;
112
+ }
113
+ }
86
114
  /**
87
115
  * Builds the error for an error body, typed by its reason: NotSupportedForModeError for `not_supported_for_mode`,
88
116
  * TreeRevisionMismatchError for `tree_revision_mismatch`, else ShardfluxApiError. Every SDK request builds its errors