@shardflux/cli 0.5.0 → 0.5.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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,56 @@
3
3
  Every command and option the README shows is available from the version named here. Below 1.0, a minor release may
4
4
  break compatibility; breaking changes are marked **Breaking**.
5
5
 
6
+ ## 0.5.1 (not yet published; npm `latest` is 0.5.0)
7
+
8
+ ### A command that could not start is never a silent exit
9
+
10
+ Production (2026-09-29): `shard ws exec <key> --cwd app -- ls` exited 1 and printed nothing on stdout or stderr. The
11
+ cell had ended the session `failed_to_start` (`working directory "app" is not a directory`).
12
+
13
+ - `shard ws exec` on a processful workspace: a command that could not start (a `--cwd` that is not a directory, a
14
+ program not on `PATH`, an unknown user) exits 6, as a failed file-first execution does, with the workspace's reason
15
+ and a hint on stderr: `error: The command could not start: working directory "/home/user/app" is not a directory
16
+ (exec_failed_to_start) [code=conflict source=cell]`, then `hint: --cwd must name a directory that exists in the
17
+ workspace, ...` (or the `PATH` hint). It exited 1 with no output. With `--json` the error document has
18
+ `details.reason` `exec_failed_to_start`, `details.error` and `details.session_id`. Through `@shardflux/sdk` 0.10.0
19
+ (`ExecStartError`).
20
+ - A relative `--cwd` is refused by the API before anything runs: 422 `invalid_cwd`, exit 2, and the message names the
21
+ absolute path it likely means (`use "/home/user/app"`). That holds for every CLI version once the cell refuses it.
22
+ - A session that ended without an exit code, timeout or signal (`lost`, when its host restarted) prints `exec session
23
+ <id> ended lost without an exit code: <reason>` on stderr; the exit code stays 1. `--json` adds `state` (and `error`
24
+ when the session has one) to the result.
25
+ - The file-first hint for `exec_failed_to_start` names `--cwd` when the reason is a working directory, instead of
26
+ always asking whether argv[0] is on `PATH`. `--cwd` help: an absolute path, default `/home/user`.
27
+
28
+ - A 402 `allowance_exhausted` and a spend-policy `version_mismatch` end without the `feedback:` line: they are billing
29
+ states whose hint names the next step, not problems to report.
30
+ ### Opt-in overage with a spend cap
31
+
32
+ - `shard usage` prints an overage block from the summary's `spend_cap`: the state (`off`, `paused`, `accruing`,
33
+ `warning`, `reached`, ...), the spend cap, the charges of the cap with what is left, the date the cap is projected
34
+ to be reached, and a table of the units past each allowance (CPU-hours, RAM GiB-hours) with the billed units, rate
35
+ and amount. Allowances past `included` while overage is on show cap state `overage`; `allowance exhausted` names the
36
+ reason (`allowance_used`, `overage_paused`, `spend_cap_reached`). An API without overage prints no block.
37
+ - `shard billing overage` (signed in; owners and billing members change it): `status` (the default: the state, the
38
+ spend cap and its range up to the plan price, the rates), `on [--cap <dollars>]`, `cap <dollars>` (e.g. `9` or
39
+ `9.50`) and `off`, with `--if-version <n>` and `--json` (the API's spend policy). A refusal exits 2 with a hint that
40
+ names the bound (`spend_cap_above_plan_price`: the plan price; `spend_cap_below_minimum`; `spend_cap_below_charges`:
41
+ what overage already charged) or the next step (`overage_unavailable`, `spend_cap_required`); a 409
42
+ `version_mismatch` (also from `egress set --if-version`) exits 1 and names the current version.
43
+ - 402 `allowance_exhausted` gets a hint per `details.reason`: `allowance_used` (upgrade, or `shard billing overage on
44
+ --cap <dollars>`), `spend_cap_reached` (raise the cap with `shard billing overage cap <dollars>`, or upgrade),
45
+ `overage_paused` (a payment is past due: update the payment method, `shard billing portal --open`). The exit code
46
+ stays 1.
47
+
48
+ ### Suspend when idle (contracts §20.6)
49
+
50
+ - `shard workspaces suspend <id|key> --when-idle <duration>` (30s to 1h): the workspace is suspended once it has been
51
+ idle that long, counted from the later of its last work and the request (suspend when idle, for the end of an agent
52
+ turn). A running command, an attached stream or a keepalive postpones it; the next tool call or a resume cancels it.
53
+ `--cancel-when-idle` cancels it. `--json` prints `{workspace, operation, suspend_request}`.
54
+ - `shard workspaces get` shows a pending request (`suspend when idle after N s idle, not before ...`).
55
+
6
56
  ## 0.5.0 (not yet published; npm `latest` is 0.4.1)
7
57
 
8
58
  Needs `@shardflux/sdk` 0.9.0 or later.
@@ -75,7 +125,7 @@ Needs `@shardflux/sdk` 0.9.0 or later.
75
125
 
76
126
  ### Waking a suspended workspace is one request (contracts §22.6)
77
127
 
78
- - Through `@shardflux/sdk` 0.8.0: a command on a suspended workspace (`ws exec`, `files ...`, `ws changes`) wakes it
128
+ - Through `@shardflux/sdk` 0.9.0: a command on a suspended workspace (`ws exec`, `files ...`, `ws changes`) wakes it
79
129
  with one held resume that returns the running workspace and the CLI's tool token, then runs (refused call, resume,
80
130
  the command; before: also a wait, a view and a token request). An API without the held resume works as before.
81
131
  - `--timing` shows the wake as one `request` phase `(held)`; a wake that runs out of time shows the hold, then the
@@ -178,6 +228,7 @@ unchanged and still stores nothing.
178
228
  edit that does not match, a mode the workspace lacks, a reused execution id), and `shard feedback` itself.
179
229
  - The positional usage of `shard feedback` is shown as `<message...>`; `--agent-label` also names the reporting agent.
180
230
 
231
+
181
232
  ## 0.4.1 (2026-09-28)
182
233
 
183
234
  Needs `@shardflux/sdk` 0.8.0 or later. No command changes: the SDK's 0.8.0 is a types-only release (its provider tool
package/README.md CHANGED
@@ -222,6 +222,10 @@ shard billing upgrade <plan> [--open] [--wait] [--timeout 15m]
222
222
  shard billing portal [--open] # plan changes, payment methods, invoices, cancellation
223
223
  shard billing invoices [--all]
224
224
  shard billing alerts [--set 50,80,100 | --clear] # usage alert emails, percent of each allowance
225
+ shard billing overage [status] # opt-in overage: state, spend cap and its range, rates
226
+ shard billing overage on [--cap <dollars>] # turn it on (a cap is needed the first time)
227
+ shard billing overage cap <dollars> # change the spend cap, e.g. 9 or 9.50
228
+ shard billing overage off # turn it off (always allowed)
225
229
  ```
226
230
 
227
231
  `billing upgrade` checks the plan against the public catalog (an unknown one lists the purchasable
@@ -232,6 +236,24 @@ plan only when Stripe confirms the payment, so `--wait` polls until the subscrip
232
236
  open). An organization that already has a subscription changes plans in the billing portal: the
233
237
  command prints the portal URL instead (exit 0).
234
238
 
239
+ `billing overage` shows and changes opt-in overage (owners and billing members). With it on,
240
+ workspaces keep opening and running past the CPU-hours and RAM GiB-hours allowances, and the usage
241
+ past them is charged on the next invoice until the charges reach the spend cap. The cap is per
242
+ billing period, from $1 up to the plan price; `shard usage` shows this period's charges. Every owner
243
+ and billing member gets an email when overage is turned on or off or the cap changes. `--json`
244
+ prints the API's spend policy; `--if-version <n>` (its `version`) refuses the change if someone
245
+ changed the policy since (exit 1, `version_mismatch`). A refused cap exits 2 and the hint names the
246
+ bound: the plan price (`spend_cap_above_plan_price`), $1 (`spend_cap_below_minimum`) or what overage
247
+ already charged this period (`spend_cap_below_charges`); a plan without overage answers
248
+ `overage_unavailable`, and turning it on without a cap `spend_cap_required`.
249
+
250
+ ```text
251
+ $ shard billing overage on --cap 9
252
+ Acme: overage is on, spend cap $9.00 per billing period ($1.00 to $9.00, the plan price).
253
+ Rates past the allowances: $0.04 per RAM GiB-hour, $0.12 per CPU-hour, charged on the next invoice (this period: shard usage).
254
+ Saved (policy version 1).
255
+ ```
256
+
235
257
  ## Audit, account export and deletion (0.5.0+)
236
258
 
237
259
  ```sh
@@ -330,26 +352,29 @@ shows the status; `shard version --check` asks now (`--json`: `{"cli", "sdk", "u
330
352
  | `auth register\|verify-email\|resend-verification\|login\|mfa\|status\|logout\|sessions\|step-up`, `auth password change\|reset\|reset-confirm`, `auth email change\|confirm`, `auth totp enroll\|confirm\|disable\|recovery-codes` | The account and its sign-in (0.5.0+): see [Accounts and sign-in](#accounts-and-sign-in-050). |
331
353
  | `setup [--org O] [--project P] [--org-name N] [--project-name N] [--key-name N] [--tool T]... [--new-key]`, `env [--shell sh\|fish\|powershell]` | Organization, project and a saved API key in one step; the saved key as shell exports (0.5.0+). See [Setup](#setup-050). |
332
354
  | `orgs`, `projects`, `api-keys`, `members`, `invitations`, `templates publish\|archive` | Organizations, projects, API keys, members and invitations with the session (0.5.0+). See [Organizations, projects, keys, members](#organizations-projects-keys-members-050). |
333
- | `billing plans\|status\|upgrade\|portal\|invoices\|alerts` | Plans, Stripe Checkout, the billing portal, invoices and usage alerts (0.5.0+). See [Billing](#billing-050). |
355
+ | `billing plans\|status\|upgrade\|portal\|invoices\|alerts\|overage` | Plans, Stripe Checkout, the billing portal, invoices, usage alerts (0.5.0+) and opt-in overage (`overage`, 0.5.1+). See [Billing](#billing-050). |
334
356
  | `audit list\|export`, `account status\|export\|delete\|cancel-deletion` | The audit log, data exports and account deletion (0.5.0+). |
335
357
  | `egress get\|set\|history\|clear`, `volumes list\|create\|get\|delete\|attachments\|attach\|detach` | Outbound network policies and shared volumes with the project key (0.5.0+). |
336
358
  | `login`, `whoami` | Show the principal behind the project key (`SHARDFLUX_API_KEY`, else the saved one). |
337
359
  | `version [--check]` | Print the CLI and SDK versions and the update status (`--check` asks the API now). |
360
+ | `usage [--org <id>] [--estimate \| --series \| --workspace <id\|key>] [--from T] [--to T] [--granularity hour\|day] [--meter M]` | Usage summary for the current period: plan, meters and allowances. `--estimate`: the bill so far and projected allowance use; `--series`: usage over time; `--workspace`: one workspace's usage over time (0.5.0+). The summary includes the [overage](#usage-and-overage) block (0.5.1+). |
361
+
338
362
  | `feedback [--category C] [--workspace W] [--request-id ID] [--error-code CODE] [--command TEXT] [--from-file F\|-] <message...>` | Send feedback straight to the Shardflux founder (0.5.0+). See [Feedback](#feedback-agents-use-this-while-you-work). |
339
363
  | `usage [--org <id>] [--estimate \| --series \| --workspace <id\|key>] [--from T] [--to T] [--granularity hour\|day] [--meter M]` | Usage summary for the current period: plan, meters and allowances. `--estimate`: the bill so far and projected allowance use; `--series`: usage over time; `--workspace`: one workspace's usage over time (0.5.0+). |
340
364
  | `workspaces open <key> --template <slug> [--secret NAME]... [--input NAME=value]... [--input-file F] [--lifetime persistent\|session] [--mode processful\|file-first] [--cpu-millis N --memory-mib N --disk-gib N] [--no-wait] [--timeout 5m] [--timing]` | Open by key. Creates the workspace on first use and reconnects or resumes it afterwards, never resetting it. Waits until it is ready (the template's start commands and services included) unless you pass `--no-wait`. `--secret` binds secret names (replacing the binding of an existing workspace). `--input` (0.4.0+) passes the template's text inputs (replacing those of an existing workspace); `--input-file` reads `NAME=value` lines or a JSON object. `--lifetime session`: the workspace is discarded when the session ends (`workspaces close` or the idle timeout), and the key then opens a new workspace. `--mode file-first` (0.5.0+) opens a [file-first workspace](#file-first-workspaces); the default is processful for a new key and the stored mode for an existing one. |
341
365
  | `workspaces list [--state S] [--prefix P] [--lifetime persistent\|session\|any] [--purpose standard\|template_draft\|template_test\|any] [--include-deleted] [--all] [--limit N] [--cursor C]` | One page, or every page with `--all`. The next cursor is printed on stderr. By default only persistent standard workspaces are listed. A file-first workspace's state reads `running (file-first)` (0.5.0+). |
342
366
  | `workspaces get <id\|key>` | One workspace (deleted ones included), with the startup of its template's start commands and services (0.4.0+): a failed step shows its exit code, reason and output tail. Its mode, and a file-first workspace's tree revision (0.5.0+). |
343
367
  | `workspaces inputs <id\|key>` | The workspace's text inputs (0.4.0+). |
344
- | `workspaces exec <id\|key> [--cwd D] [--env K=V]... [--timeout T] [--stdin F\|-] [-v] [--execution-id ID] [--output-limit N] -- <cmd> [args...]` | Runs argv with no shell. Output is streamed and resumes from byte offsets after dropped connections. `shard` exits with the command's exit code. Ctrl-C cancels the command. `-v` (0.5.0+) then prints the session id and exit status on stderr. On a file-first workspace (0.5.0+) the command runs as an execution instead: see [File-first workspaces](#file-first-workspaces). |
368
+ | `workspaces exec <id\|key> [--cwd D] [--env K=V]... [--timeout T] [--stdin F\|-] [-v] [--execution-id ID] [--output-limit N] -- <cmd> [args...]` | Runs argv with no shell. Output is streamed and resumes from byte offsets after dropped connections. `shard` exits with the command's exit code. Ctrl-C cancels the command. `-v` (0.5.0+) then prints the session id and exit status on stderr. `--cwd` is an absolute path (default `/home/user`); a relative one is refused (`invalid_cwd`, exit 2). A command that could not start (a `--cwd` that is not a directory, a program not on `PATH`) exits 6 with the workspace's reason and a hint (0.5.1+; before, it exited 1 with no output). On a file-first workspace (0.5.0+) the command runs as an execution instead: see [File-first workspaces](#file-first-workspaces). |
345
369
  | `executions get <id\|key> <execution-id> [--wait D]` | The result of a file-first workspace's execution (0.5.0+): its output, then what ran. Exits with the command's code, 6 if the execution failed or was lost, 5 while it still runs. |
346
370
  | `files read <id\|key> <path> [--out F]` | Raw bytes to stdout, or to a local file. |
347
371
  | `files write <id\|key> <path> [--from F\|-] [--append] [--no-parents] [--if-revision N]` | Atomic write. The default source is stdin. `--if-revision` (0.5.0+): file-first workspaces only, see [Tree revisions](#tree-revisions). |
348
372
  | `files ls <id\|key> <path> [--limit N]` | Directory listing. |
349
- | `files stat <id\|key> <path> [--revision]` | One path's metadata; `--revision` adds the content's SHA-256 (0.4.0+). |
350
- | `files search <id\|key> <path> <pattern> [--regex] [-i\|--ignore-case] [--include G]... [--exclude G]... [--max N]` | Search file contents under a directory (or in one file; gitignore-style globs): `path:line:column: text` per match (0.4.0+). |
351
- | `files patch <id\|key> <path> (--old T --new T)... [--replace-all] \| --content-file F\|- [--expected-revision R] [--if-revision N]` | Replace exact text (each `--old` must occur once unless `--replace-all`), or the whole content, atomically; `R` is a revision from `files stat --revision` or `absent` (0.4.0+). `--if-revision` (0.5.0+): file-first workspaces only. |
373
+ | `files stat <id\|key> <path> [--revision]` | One path's metadata; `--revision` adds the content's SHA-256 (0.5.0+). |
374
+ | `files search <id\|key> <path> <pattern> [--regex] [-i\|--ignore-case] [--include G]... [--exclude G]... [--max N]` | Search file contents under a directory (or in one file; gitignore-style globs): `path:line:column: text` per match (0.5.0+). |
375
+ | `files patch <id\|key> <path> (--old T --new T)... [--replace-all] \| --content-file F\|- [--expected-revision R] [--if-revision N]` | Replace exact text (each `--old` must occur once unless `--replace-all`), or the whole content, atomically; `R` is a revision from `files stat --revision` or `absent` (0.5.0+). `--if-revision` (0.5.0+): file-first workspaces only. |
352
376
  | `workspaces suspend\|resume <id\|key> [--wait] [--timeout T] [--timing]` | Lifecycle operation. Returns immediately unless you pass `--wait`. |
377
+ | `workspaces suspend <id\|key> --when-idle D` / `--cancel-when-idle` | (0.5.1+) Suspend once the workspace has been idle for `D` (30s to 1h), or cancel that. See "Suspend when idle". |
353
378
  | `workspaces fork <id\|key> <new-key> [caps] [--wait]` | Fork into a new key. |
354
379
  | `workspaces delete <id\|key> --yes [--wait]` | Delete the workspace. Tool access ends at once. The key of a persistent workspace is never reused. |
355
380
  | `workspaces close <id\|key> [--wait]` | End a session workspace now: it is deleted, and the key then opens a new workspace. A persistent workspace is refused (`not_session`). |
@@ -540,6 +565,26 @@ without waking it (the state at suspension); `files search` then notes it on std
540
565
  `shard operations wait <id>` keeps waiting.
541
566
  - A resume or open that fails exits 6.
542
567
 
568
+ ## Suspend when idle (0.5.1+)
569
+
570
+ A running workspace is billed while it is awake. When an agent's turn ends, ask for a suspend once the workspace has
571
+ been idle for a short time, instead of waiting for its idle policy:
572
+
573
+ ```sh
574
+ shard ws suspend acme/demo --when-idle 2m # 30s to 1h; whole seconds
575
+ shard ws get acme/demo # "suspend when idle after 120 s idle, not before ..."
576
+ shard ws suspend acme/demo --cancel-when-idle
577
+ ```
578
+
579
+ - The idle time counts from the later of the workspace's last work and the request.
580
+ - A command still running, an attached stream or a keepalive postpones the suspend until the duration after it ends.
581
+ - The next tool call on the workspace (the next turn) or a resume cancels the request. Repeating replaces it.
582
+ - If a suspend is already in progress, `shard` names its operation and schedules nothing.
583
+ - `--json` prints `{workspace, operation, suspend_request}`; with `--cancel-when-idle`, the workspace.
584
+ - `--when-idle` and `--cancel-when-idle` cannot be combined with each other, `--wait` or `--timeout` (exit 2). A
585
+ workspace that is not running is refused with `conflict` (`not_running`, exit 1), and a file-first workspace (never
586
+ suspended) with `not_supported_for_mode` (exit 1).
587
+
543
588
  ## Starts that wait for capacity
544
589
 
545
590
  An open, resume or fork that no host can admit yet waits in `capacity_pending` for at most 15
@@ -557,6 +602,36 @@ hint: no host could admit it before its deadline; nothing was started. Retry lat
557
602
  - `--no-wake` (or `SHARDFLUX_NO_WAKE=1`) turns this off. A workspace that is not running is then
558
603
  refused at once (exit 1, `workspace_not_running`).
559
604
 
605
+ ## Usage and overage
606
+
607
+ `shard usage` prints the current period's plan, meters and allowances. With opt-in overage (0.5.1+), an allowance past
608
+ `included` while overage is on shows cap state `overage`, and a block follows with the overage state, the spend cap,
609
+ the charges of the cap, the date the cap is projected to be reached, and per allowance the units past it and their
610
+ amount:
611
+
612
+ ```text
613
+ overage accruing (usage past the allowances is charged on the next invoice)
614
+ spend cap $9.00 (at most $9.00, the plan price)
615
+ overage charges $3.00 of $9.00 (33.33%), $6.00 left; billed on the next invoice
616
+ cap reached 2026-09-29 15:02:46Z (projected at this period's average use)
617
+
618
+ OVERAGE UNIT PAST ALLOWANCE BILLED RATE AMOUNT
619
+ ram_gib_hours gib_hours 70 60 $0.04 $2.40
620
+ cpu_hours hours 5 5 $0.12 $0.60
621
+ ```
622
+
623
+ `--json` prints the API's summary, with `spend_cap` and `exhausted_reason`. Owners and billing members turn overage on
624
+ and set the cap with [`shard billing overage`](#billing-050) or under **Usage & billing** in the console.
625
+
626
+ While an allowance is used up, opens, resumes and forks are refused with 402 `allowance_exhausted` (exit 1). The error
627
+ names `details.reason`, and a hint says what to do (0.5.0+):
628
+
629
+ | Reason | Hint |
630
+ | --- | --- |
631
+ | `allowance_used` | Upgrade (`shard billing plans`), or turn on overage: `shard billing overage on --cap <dollars>`. |
632
+ | `spend_cap_reached` | The spend cap is reached: raise it (`shard billing overage cap <dollars>`, at most the plan price) or upgrade. |
633
+ | `overage_paused` | A payment is past due: update the payment method (`shard billing portal --open`). |
634
+
560
635
  ## Timing
561
636
 
562
637
  `--timing` (0.3.0+) says where the time of an open or a wait went. After the call, `shard` prints the lifecycle
@@ -684,7 +759,7 @@ printf %s "$NEW_KEY" | shard secrets rotate OPENAI_API_KEY
684
759
  | 3 | not found: workspace, operation or route |
685
760
  | 4 | authentication or authorization: missing, malformed, revoked or insufficient key or session; `step_up_required` (run `shard auth step-up`), `mfa_required` (a sign-in waiting for its code), `email_unverified`, `invalid_credentials` |
686
761
  | 5 | timeout: waiting gave up (`--timeout`, or `--wake-timeout` while waking a workspace); the operation continues server side (`shard operations wait <id>`), a start waiting for capacity for at most 15 minutes. `executions get` (0.5.0+): the execution is still queued or running. `billing upgrade --wait` (0.5.0+): nobody paid in time (the checkout stays open) |
687
- | 6 | the awaited operation ended `failed` or `canceled` (also a resume or open that failed while waking a workspace). `retryable=true` in the error means it may succeed later (`capacity_unavailable`). Also a file-first execution that ended `failed` or `lost` (0.5.0+): nothing was published. `billing upgrade --wait` (0.5.0+): the checkout expired or was canceled (`checkout_ended`) |
762
+ | 6 | the awaited operation ended `failed` or `canceled` (also a resume or open that failed while waking a workspace). `retryable=true` in the error means it may succeed later (`capacity_unavailable`). Also a file-first execution that ended `failed` or `lost` (0.5.0+): nothing was published. `exec` (0.5.1+): the command could not start (`exec_failed_to_start`). `billing upgrade --wait` (0.5.0+): the checkout expired or was canceled (`checkout_ended`) |
688
763
  | 130 | interrupted (Ctrl-C). Waits stop at once, and exec sends a cancel to the session. A file-first execution cannot be canceled: it keeps running, and the error names the `shard executions get` command for its result. |
689
764
  | exec, executions get | the command's own code: 124 if it timed out, 128+N if killed by signal N. Failures before the command ran use the codes above; use `--json` to tell them apart. |
690
765
 
@@ -303,6 +303,18 @@ export const ACCOUNT_COMMANDS = [
303
303
  },
304
304
  examples: ['shard billing alerts --set 50,80,100'],
305
305
  },
306
+ {
307
+ path: ['billing', 'overage'],
308
+ summary: 'Show or change opt-in overage and its spend cap: status (default), on --cap <dollars>, off, cap <dollars>',
309
+ description: 'With overage on, workspaces keep opening and running past the CPU-hours and RAM GiB-hours allowances; the usage past them is charged on the next invoice until the charges reach the spend cap (per billing period, $1 up to the plan price). Off by default; turning it off is always allowed and what it charged stays on the invoice. Owners and billing members change it, and each of them gets an email about the change. This period\'s charges: shard usage.',
310
+ positionals: [{ name: 'status|on|off|cap' }, { name: 'dollars' }],
311
+ options: {
312
+ ...ORG,
313
+ cap: { type: 'string', value: '<dollars>', description: 'With on: the spend cap per billing period in dollars, e.g. 9 or 9.50 (needed unless a cap is set already).' },
314
+ 'if-version': { type: 'string', value: '<n>', description: 'Only if the policy version is still n (shard billing overage --json shows it): refuses a concurrent change.' },
315
+ },
316
+ examples: ['shard billing overage', 'shard billing overage on --cap 9', 'shard billing overage cap 25.50', 'shard billing overage off'],
317
+ },
306
318
  {
307
319
  path: ['audit', 'list'],
308
320
  summary: 'List the organization’s audit log, newest first (owners and admins)',
@@ -13,4 +13,6 @@ export type ShellKind = 'sh' | 'fish' | 'powershell';
13
13
  export declare function exportLine(shell: ShellKind, name: string, value: string): string;
14
14
  /** An RFC 3339 time, or a duration ago (`90m`, `24h`, `7d`, `2w`). */
15
15
  export declare function parseTime(text: string, option: string, now?: number): string;
16
+ /** A spend cap in dollars ("9", "9.5", "9.50", "$9") as minor units (cents); at most two decimals. */
17
+ export declare function dollarsToMinor(text: string, what: string): number;
16
18
  export declare const ACCOUNT_HANDLERS: Record<string, Handler>;
@@ -18,7 +18,7 @@ import { UUID, parseDuration, parsePositiveInt } from "./args.js";
18
18
  import { openUrl } from "./browser.js";
19
19
  import { fileExists, oneOf, out, parseSlugVersion, principal, readInput, resolveWorkspace, str, strs, waitTimeout } from "./commands.js";
20
20
  import { AuthConfigError, CheckoutEndedError, InterruptedError, MfaPendingError, NotFoundError, UsageError } from "./errors.js";
21
- import { apiKeyTable, attachmentTable, auditTable, authSessionTable, billingStatusText, dataExportText, egressVersionTable, invitationTable, invoiceTable, kv, memberTable, money, orgTable, planTable, projectEgressText, projectTable, table, time, toolsText, volumeDetail, volumeTable, workspaceEgressText, WORKSPACE_HEADERS, workspaceRow, } from "./format.js";
21
+ import { apiKeyTable, attachmentTable, auditTable, authSessionTable, billingStatusText, dataExportText, egressVersionTable, invitationTable, invoiceTable, kv, memberTable, money, orgTable, overagePolicyText, planTable, projectEgressText, projectTable, table, time, toolsText, volumeDetail, volumeTable, workspaceEgressText, WORKSPACE_HEADERS, workspaceRow, } from "./format.js";
22
22
  export const DEFAULT_API_URL = 'https://api.shardflux.dev';
23
23
  const TOTP = /^\d{6}$/;
24
24
  const ROLES = ['owner', 'admin', 'member', 'billing'];
@@ -409,6 +409,14 @@ function auditFilters(values) {
409
409
  return f;
410
410
  }
411
411
  // ---- billing ---------------------------------------------------------------------------------------------------
412
+ const OVERAGE_ACTIONS = ['status', 'on', 'off', 'cap'];
413
+ /** A spend cap in dollars ("9", "9.5", "9.50", "$9") as minor units (cents); at most two decimals. */
414
+ export function dollarsToMinor(text, what) {
415
+ const m = /^\$?(\d{1,7})(?:\.(\d{1,2}))?$/.exec(text.trim());
416
+ if (!m)
417
+ throw new UsageError(`${what} must be an amount in dollars such as 9 or 9.50 (got "${text}")`);
418
+ return Number(m[1]) * 100 + Number((m[2] ?? '').padEnd(2, '0'));
419
+ }
412
420
  function purchasablePlans(catalog) {
413
421
  return catalog.plans.filter((p) => p.prices.some((x) => x.purchasable)).map((p) => p.plan_key);
414
422
  }
@@ -1377,6 +1385,39 @@ export const ACCOUNT_HANDLERS = {
1377
1385
  out(ctx, `${org.name}: ${now}${thresholds === null ? '' : ' (saved)'}.\n`, policy);
1378
1386
  return 0;
1379
1387
  },
1388
+ async 'billing overage'(ctx, values, [action = 'status', amount]) {
1389
+ if (!OVERAGE_ACTIONS.includes(action))
1390
+ throw new UsageError(`unknown action "${action}"; use status, on, off or cap (shard billing overage --help)`);
1391
+ const capText = str(values.cap);
1392
+ if (capText !== undefined && action !== 'on')
1393
+ throw new UsageError(action === 'cap' ? 'give the cap as an argument: shard billing overage cap <dollars>' : `--cap goes with on: shard billing overage on --cap <dollars>`);
1394
+ if (action === 'cap' && amount === undefined)
1395
+ throw new UsageError('missing <dollars> for "shard billing overage cap" (e.g. shard billing overage cap 9)');
1396
+ if (action !== 'cap' && amount !== undefined)
1397
+ throw new UsageError(`unexpected argument "${amount}"${action === 'on' ? '; give the cap with --cap <dollars>' : ''}`);
1398
+ const update = {};
1399
+ if (action === 'on')
1400
+ update.overageEnabled = true;
1401
+ if (action === 'off')
1402
+ update.overageEnabled = false;
1403
+ if (action === 'on' && capText !== undefined)
1404
+ update.spendCapMinor = dollarsToMinor(capText, '--cap');
1405
+ if (action === 'cap')
1406
+ update.spendCapMinor = dollarsToMinor(amount, 'the cap');
1407
+ const version = str(values['if-version']);
1408
+ if (version !== undefined) {
1409
+ if (action === 'status')
1410
+ throw new UsageError('--if-version goes with on, off or cap');
1411
+ if (!/^\d+$/.test(version))
1412
+ throw new UsageError('--if-version must be a policy version number (shard billing overage --json shows it)');
1413
+ update.ifMatch = Number(version);
1414
+ }
1415
+ const account = ctx.account();
1416
+ const org = await resolveOrg(ctx, account, values);
1417
+ const policy = action === 'status' ? await account.billing.spendPolicy(org.id) : await account.billing.setSpendPolicy(org.id, update);
1418
+ out(ctx, overagePolicyText(policy, org.name, action !== 'status'), policy);
1419
+ return 0;
1420
+ },
1380
1421
  async 'audit list'(ctx, values) {
1381
1422
  const filters = auditFilters(values);
1382
1423
  const limitText = str(values.limit);
package/dist/args.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const CLI_VERSION = "0.5.0";
1
+ export declare const CLI_VERSION = "0.5.1";
2
2
  export interface OptionSpec {
3
3
  type: 'string' | 'boolean';
4
4
  short?: string;
package/dist/args.js CHANGED
@@ -12,7 +12,7 @@
12
12
  import { parseArgs } from 'node:util';
13
13
  import { ACCOUNT_COMMANDS } from "./account-args.js";
14
14
  import { UsageError } from "./errors.js";
15
- export const CLI_VERSION = '0.5.0';
15
+ export const CLI_VERSION = '0.5.1';
16
16
  export const GLOBAL_OPTIONS = {
17
17
  'api-url': { type: 'string', value: '<url>', description: 'API base URL (default $SHARDFLUX_API_URL, else https://api.shardflux.dev).' },
18
18
  json: { type: 'boolean', description: 'Print machine-readable JSON on stdout (errors as JSON on stderr).' },
@@ -241,10 +241,10 @@ const KEY_COMMANDS = [
241
241
  {
242
242
  path: ['workspaces', 'exec'],
243
243
  summary: 'Run a command in a workspace; prints its output and exits with its exit code',
244
- description: 'Runs argv directly (no shell; use `-- bash -lc "..."` for shell syntax). Processful workspaces: output is streamed as it arrives and resumes from byte offsets after dropped connections; Ctrl-C stops waiting and cancels the command. File-first workspaces: the command runs as an execution in a fresh VM on the latest tree revision; its output is printed when it ended, and the files it changed under /home/user become the next tree revision (-v lists them). An execution cannot be canceled: Ctrl-C stops waiting and names the command that fetches its result later. A failed or lost execution (nothing published) exits 6.',
244
+ description: 'Runs argv directly (no shell; use `-- bash -lc "..."` for shell syntax). Processful workspaces: output is streamed as it arrives and resumes from byte offsets after dropped connections; Ctrl-C stops waiting and cancels the command. File-first workspaces: the command runs as an execution in a fresh VM on the latest tree revision; its output is printed when it ended, and the files it changed under /home/user become the next tree revision (-v lists them). An execution cannot be canceled: Ctrl-C stops waiting and names the command that fetches its result later. A failed or lost execution (nothing published) exits 6, as does a command that could not start (a --cwd that is not a directory, a program not on PATH), with the workspace’s reason.',
245
245
  positionals: [REF, { name: 'command', required: true, variadic: true }],
246
246
  options: {
247
- cwd: { type: 'string', value: '<dir>', description: 'Working directory (absolute; under /home/user on a file-first workspace).' },
247
+ cwd: { type: 'string', value: '<dir>', description: 'Working directory, an absolute path (default /home/user; under /home/user on a file-first workspace). A relative path is refused (invalid_cwd).' },
248
248
  env: { type: 'string', multiple: true, value: '<K=V>', description: 'Environment variable (repeatable).' },
249
249
  timeout: { type: 'string', value: '<duration>', description: 'Kill the command after this long (exit code 124).' },
250
250
  stdin: { type: 'string', value: '<file|->', description: 'Send this file (or - for standard input) to the command’s stdin (max 1 MiB).' },
@@ -268,7 +268,18 @@ const KEY_COMMANDS = [
268
268
  'shard ws exec acme/agent-7 --json -- python3 /home/user/report.py > result.json',
269
269
  ],
270
270
  },
271
- { path: ['workspaces', 'suspend'], summary: 'Suspend a running workspace (durable checkpoint)', positionals: [REF], options: { ...WAIT } },
271
+ {
272
+ path: ['workspaces', 'suspend'],
273
+ summary: 'Suspend a running workspace (durable checkpoint), now or once it has been idle',
274
+ description: 'Without options the suspend is requested now. --when-idle (0.5.1+) defers it instead: the workspace is suspended once it has been idle for that long, counted from the later of its last work and the request, so call it when an agent turn ends. A command still running, an attached exec or terminal stream, or a keepalive postpones it; the next tool call (the next turn) or a resume cancels it. Repeating replaces the pending request. --cancel-when-idle cancels it.',
275
+ positionals: [REF],
276
+ options: {
277
+ 'when-idle': { type: 'string', value: '<duration>', description: 'Suspend once the workspace has been idle this long (30s to 1h, e.g. 30s, 2m; 0.5.1+). Cannot be combined with --wait.' },
278
+ 'cancel-when-idle': { type: 'boolean', description: 'Cancel a pending --when-idle request (0.5.1+; idempotent).' },
279
+ ...WAIT,
280
+ },
281
+ examples: ['shard ws suspend acme/demo --wait', 'shard ws suspend acme/demo --when-idle 2m', 'shard ws suspend acme/demo --cancel-when-idle'],
282
+ },
272
283
  { path: ['workspaces', 'resume'], summary: 'Resume a suspended workspace', positionals: [REF], options: { ...WAIT } },
273
284
  {
274
285
  path: ['workspaces', 'fork'],
@@ -700,7 +711,7 @@ const GROUPS = {
700
711
  'api-keys': 'Project API keys (signed in; secrets are shown once)',
701
712
  members: 'Members of an organization and their roles (signed in)',
702
713
  invitations: 'Invite people to an organization and accept invitations (signed in)',
703
- billing: 'Plans, upgrades (Stripe Checkout), the billing portal, invoices and usage alerts (signed in; plans is public)',
714
+ billing: 'Plans, upgrades (Stripe Checkout), the billing portal, invoices, usage alerts and overage (signed in; plans is public)',
704
715
  audit: 'The organization audit log and its export (signed in; owners and admins)',
705
716
  account: 'Your account: data export and deletion (signed in)',
706
717
  egress: 'Outbound network policies of the project and its workspaces (project API key)',
@@ -70,6 +70,8 @@ export type Handler = (ctx: Ctx, values: Values, positionals: string[]) => Promi
70
70
  export declare const str: (v: Values[string]) => string | undefined;
71
71
  export declare const strs: (v: Values[string]) => string[];
72
72
  export declare function out(ctx: Ctx, human: string, machine: unknown): void;
73
+ /** `--when-idle <duration>`: whole seconds from 30 to 3600 (the API's after_seconds bounds, contracts §20.6). */
74
+ export declare function whenIdleSeconds(text: string): number;
73
75
  export declare function waitTimeout(values: Values): number;
74
76
  /**
75
77
  * `<id|key>`: a UUID is tried as an id first; anything else (or an unknown UUID) as an exact workspace key, across every
package/dist/commands.js CHANGED
@@ -16,7 +16,7 @@ import { updateCheckDisabled } from "./update-check.js";
16
16
  import { initTemplateYaml, recipeYaml } from "./template-yaml.js";
17
17
  import { BuildTimer, buildTimingJson, buildTimingText, progressLine } from "./timing.js";
18
18
  import { timingJson } from "./timing.js";
19
- import { bindingsText, buildTable, buildText, changesTable, diffSummaryText, diffTable, draftText, draftStateTable, executionJson, executionSummary, fileInfoText, fileTable, languageTable, json, kv, modeText, operationDetail, pairs, operationTable, packageTable, searchText, secretAccessTable, secretDetail, secretTable, secretVersionTable, sessionTable, templateDetailText, templateEntryText, templateFileTable, templateTable, usageEstimateText, usageSeriesText, usageText, workspaceDetail, workspaceTable, } from "./format.js";
19
+ import { bindingsText, buildTable, buildText, changesTable, diffSummaryText, diffTable, draftText, draftStateTable, executionJson, executionSummary, fileInfoText, fileTable, languageTable, json, kv, modeText, operationDetail, pairs, operationTable, packageTable, searchText, secretAccessTable, secretDetail, secretTable, secretVersionTable, sessionTable, suspendRequestText, suspendWhenIdleText, templateDetailText, templateEntryText, templateFileTable, templateTable, usageEstimateText, usageSeriesText, usageText, workspaceDetail, workspaceTable, } from "./format.js";
20
20
  const DEFAULT_WAIT_MS = 300_000;
21
21
  const MAX_JSON_OUTPUT = 16 * 1024 * 1024;
22
22
  const MAX_STDIN = 1024 * 1024;
@@ -66,6 +66,16 @@ function caps(values) {
66
66
  c.disk_gib = parsePositiveInt(disk, 'disk-gib', 1_000_000);
67
67
  return Object.keys(c).length ? c : undefined;
68
68
  }
69
+ /** `--when-idle <duration>`: whole seconds from 30 to 3600 (the API's after_seconds bounds, contracts §20.6). */
70
+ export function whenIdleSeconds(text) {
71
+ const ms = parseDuration(text, 'when-idle');
72
+ if (ms % 1000 !== 0)
73
+ throw new UsageError('--when-idle must be a whole number of seconds (e.g. 30s, 90s, 2m)');
74
+ const s = ms / 1000;
75
+ if (s < 30 || s > 3600)
76
+ throw new UsageError(`--when-idle must be between 30s and 1h (got "${text}")`);
77
+ return s;
78
+ }
69
79
  export function waitTimeout(values) {
70
80
  const t = str(values.timeout);
71
81
  return t === undefined ? DEFAULT_WAIT_MS : parseDuration(t, 'timeout');
@@ -697,12 +707,30 @@ export const KEY_HANDLERS = {
697
707
  throw err;
698
708
  }
699
709
  const code = execExitCode(r);
710
+ // No exit code, no timeout, no signal: the session ended without the command's own ending (e.g. lost when its host
711
+ // restarted). Say so rather than exit 1 in silence (0.5.1); a command that could not start threw ExecStartError.
712
+ const unexplained = r.exitCode === null && !r.timedOut && !(r.termSignal !== null && r.termSignal > 0);
700
713
  if (values.verbose === true && !ctx.json) {
701
- const status = r.timedOut ? 'timed out' : r.canceled ? 'canceled' : r.termSignal ? `killed by signal ${r.termSignal}` : `exit code ${r.exitCode ?? '-'}`;
714
+ const status = r.timedOut ? 'timed out' : r.canceled ? 'canceled' : r.termSignal ? `killed by signal ${r.termSignal}` : unexplained ? r.session.state : `exit code ${r.exitCode ?? '-'}`;
702
715
  ctx.io.stderr.write(kv([['session', r.sessionId], ['status', status], ['output', `${r.stdoutBytes} bytes stdout, ${r.stderrBytes} bytes stderr`], ['reconnects', String(r.reconnects)]]));
703
716
  }
704
717
  if (ctx.json) {
705
- ctx.io.stdout.write(json(withTiming(ctx, { session_id: r.sessionId, exit_code: r.exitCode, term_signal: r.termSignal, timed_out: r.timedOut, canceled: r.canceled, stdout: r.stdout, stderr: r.stderr, truncated: r.truncated, reconnects: r.reconnects })));
718
+ ctx.io.stdout.write(json(withTiming(ctx, {
719
+ session_id: r.sessionId,
720
+ state: r.session.state,
721
+ exit_code: r.exitCode,
722
+ term_signal: r.termSignal,
723
+ timed_out: r.timedOut,
724
+ canceled: r.canceled,
725
+ stdout: r.stdout,
726
+ stderr: r.stderr,
727
+ truncated: r.truncated,
728
+ reconnects: r.reconnects,
729
+ ...(r.session.error ? { error: r.session.error } : {}),
730
+ })));
731
+ }
732
+ else if (unexplained) {
733
+ ctx.io.stderr.write(`exec session ${r.sessionId} ended ${r.session.state} without an exit code${r.session.error ? `: ${r.session.error}` : ''}\n`);
706
734
  }
707
735
  return code;
708
736
  },
@@ -822,7 +850,28 @@ export const KEY_HANDLERS = {
822
850
  return 0;
823
851
  },
824
852
  async 'workspaces suspend'(ctx, values, [ref]) {
825
- return lifecycle(ctx, values, ref, 'Suspending', (ws, o) => ctx.cloud().workspaces.suspend(ws.id, o), ['suspend', 'Suspend']);
853
+ const whenIdle = str(values['when-idle']);
854
+ const cancel = values['cancel-when-idle'] === true;
855
+ if (whenIdle === undefined && !cancel)
856
+ return lifecycle(ctx, values, ref, 'Suspending', (ws, o) => ctx.cloud().workspaces.suspend(ws.id, o), ['suspend', 'Suspend']);
857
+ if (whenIdle !== undefined && cancel)
858
+ throw new UsageError('--when-idle and --cancel-when-idle cannot be combined');
859
+ const flag = cancel ? '--cancel-when-idle' : '--when-idle';
860
+ if (values.wait === true || str(values.timeout) !== undefined)
861
+ throw new UsageError(`${flag} cannot be combined with --wait or --timeout (nothing is suspended now)`);
862
+ if (cancel) {
863
+ const ws = await resolveWorkspace(ctx, ref);
864
+ const pending = ws.suspendRequest;
865
+ const after = await ctx.cloud().workspaces.cancelSuspendWhenIdle(ws.id);
866
+ out(ctx, pending ? `Canceled the pending suspend-when-idle request of ${ws.key} (${ws.id}): ${suspendRequestText(pending)}.\n` : `No suspend-when-idle request was pending for ${ws.key} (${ws.id}).\n`, after.data);
867
+ return 0;
868
+ }
869
+ const afterSeconds = whenIdleSeconds(whenIdle);
870
+ const ws = await resolveWorkspace(ctx, ref);
871
+ requireProcessful(ws, 'suspend_when_idle', 'Suspend when idle');
872
+ const r = await ctx.cloud().workspaces.suspendWhenIdle(ws.id, { afterSeconds });
873
+ out(ctx, suspendWhenIdleText(ws, { suspend_request: r.suspendRequest, operation: r.operation }), { workspace: r.workspace.data, operation: r.operation, suspend_request: r.suspendRequest });
874
+ return 0;
826
875
  },
827
876
  async 'workspaces resume'(ctx, values, [ref]) {
828
877
  // With --wait the resume is held until the workspace runs (contracts §22.6); its tool token is the CLI's label.
package/dist/errors.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * stderr with the API's error code, message and request id; the process exit
4
4
  * code tells scripts what kind of failure it was (README "Exit codes").
5
5
  */
6
- import { CheckoutTimeoutError, OperationFailedError, OperationTimeoutError, ShardfluxApiError, ShardfluxProtocolError, TemplateBuildTimeoutError, TemplateFileError, TemplateUploadError } from '@shardflux/sdk';
6
+ import { CheckoutTimeoutError, ExecStartError, OperationFailedError, OperationTimeoutError, ShardfluxApiError, ShardfluxProtocolError, TemplateBuildTimeoutError, TemplateFileError, TemplateUploadError } from '@shardflux/sdk';
7
7
  export const EXIT = {
8
8
  ok: 0,
9
9
  /** Any other failure: API refusal (conflict, quota, ...), network, protocol. */
@@ -120,6 +120,10 @@ function isNamed(err, name) {
120
120
  return typeof err === 'object' && err !== null && err.name === name;
121
121
  }
122
122
  export function describeError(err) {
123
+ // A processful command that could not start (0.5.1; the SDK's ExecStartError): the workspace's reason, no HTTP status
124
+ // (the start itself was answered 201) and details.error, details.session_id.
125
+ if (err instanceof ExecStartError)
126
+ return { code: err.code, message: err.message, retryable: false, ...(err.details ? { details: err.details } : {}), source: err.source };
123
127
  if (err instanceof ShardfluxApiError) {
124
128
  return {
125
129
  code: err.code,
@@ -205,6 +209,9 @@ export function exitCodeFor(err, interrupted = false) {
205
209
  return EXIT.operationFailed;
206
210
  if (err instanceof ExecutionPendingError)
207
211
  return EXIT.timeout;
212
+ // A command that could not start exits 6 in both modes, as a failed execution does (0.5.1).
213
+ if (err instanceof ExecStartError)
214
+ return EXIT.operationFailed;
208
215
  if (err instanceof ShardfluxApiError) {
209
216
  if (AUTH_CODES.has(err.code) || err.status === 401)
210
217
  return EXIT.auth;
package/dist/format.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * `--json` output is the API's own objects (see README "JSON output"), so
4
4
  * these functions only shape what people read.
5
5
  */
6
- import type { AgentSession, ApiKey, AuditEvent, AuthSessionInfo, BillingCatalog, BillingInvoicePage, BillingSubscription, DataExport, DraftState, ExecutionChange, ExecutionResult, FileInfo, EgressPolicyVersion, Invitation, Member, Organization, Project, ProjectEgressPolicy, Volume, VolumeAttachment, WorkspaceEgressPolicy, FileList, FileSearchResponse, Operation, Secret, SecretAccessEvent, SecretVersion, TemplateBuild, TemplateDetail, TemplateDiffEntry, TemplateDraft, TemplateFileEntry, TemplateLanguages, TemplatePackagePage, TemplateSettings, TemplateSummary, WorkspaceChange, WorkspaceSecretBindings, WorkspaceView } from '@shardflux/sdk';
6
+ import type { AgentSession, ApiKey, AuditEvent, AuthSessionInfo, BillingCatalog, BillingInvoicePage, BillingSubscription, DataExport, DraftState, ExecutionChange, ExecutionResult, FileInfo, EgressPolicyVersion, Invitation, Member, Organization, Project, ProjectEgressPolicy, Volume, VolumeAttachment, WorkspaceEgressPolicy, FileList, FileSearchResponse, Operation, Secret, SecretAccessEvent, SecretVersion, SpendPolicy, SuspendRequest, TemplateBuild, TemplateDetail, TemplateDiffEntry, TemplateDraft, TemplateFileEntry, TemplateLanguages, TemplatePackagePage, TemplateSettings, TemplateSummary, WorkspaceChange, WorkspaceSecretBindings, WorkspaceView } from '@shardflux/sdk';
7
7
  import type { ErrorInfo } from './errors.js';
8
8
  export declare function json(value: unknown): string;
9
9
  export declare function table(headers: readonly string[], rows: readonly (readonly string[])[]): string;
@@ -28,6 +28,16 @@ export declare function isReady(w: WorkspaceView): boolean;
28
28
  export declare function startupText(s: WorkspaceView['startup'] | undefined): string | null;
29
29
  /** The failed step's output tail, indented, after the detail block (empty otherwise). */
30
30
  export declare function startupTail(s: WorkspaceView['startup'] | undefined): string;
31
+ /** A pending suspend-when-idle request in one line (0.5.1; contracts §20.6). */
32
+ export declare function suspendRequestText(r: SuspendRequest): string;
33
+ /** `shard ws suspend --when-idle`: the recorded request, or the suspend already in progress. */
34
+ export declare function suspendWhenIdleText(w: {
35
+ key: string;
36
+ id: string;
37
+ }, r: {
38
+ suspend_request: SuspendRequest | null;
39
+ operation: Operation | null;
40
+ }): string;
31
41
  export declare function workspaceDetail(w: WorkspaceView): string;
32
42
  export declare const OPERATION_HEADERS: readonly ["ID", "KIND", "STATE", "REASON", "CREATED", "COMPLETED"];
33
43
  export declare function operationTable(ops: readonly Operation[]): string;
@@ -44,9 +54,12 @@ export declare function secretDetail(s: Secret): string;
44
54
  export declare function secretVersionTable(rows: readonly SecretVersion[]): string;
45
55
  export declare function secretAccessTable(rows: readonly SecretAccessEvent[]): string;
46
56
  export declare function bindingsText(b: WorkspaceSecretBindings): string;
57
+ /** Minor units as money (the overage block): `$3.00` for usd, else `3.00 EUR`. A fractional minor amount (a sub-cent rate) keeps its digits. */
58
+ export declare function capMoney(minor: unknown, currency: string): string;
47
59
  /**
48
60
  * The usage summary (GET /v1/organizations/{id}/usage/summary): plan, period, measurement
49
- * freshness, then meters and allowances as tables. Columns absent from the response are omitted.
61
+ * freshness, then meters and allowances as tables (cap state `overage` past an allowance while opt-in overage is on),
62
+ * then the overage block. Columns absent from the response are omitted.
50
63
  */
51
64
  export declare function usageText(summary: unknown): string;
52
65
  /**
@@ -131,3 +144,8 @@ export declare function attachmentTable(rows: readonly VolumeAttachment[]): stri
131
144
  export declare function projectEgressText(p: ProjectEgressPolicy): string;
132
145
  export declare function workspaceEgressText(w: WorkspaceEgressPolicy): string;
133
146
  export declare function egressVersionTable(rows: readonly EgressPolicyVersion[]): string;
147
+ /**
148
+ * The spend policy's overage settings for people (GET/PUT /v1/organizations/{id}/spend-policy): the state, the spend
149
+ * cap and its bounds, the rates, and the next step. `saved` adds the version the change was saved as.
150
+ */
151
+ export declare function overagePolicyText(p: SpendPolicy, orgName: string, saved?: boolean): string;
package/dist/format.js CHANGED
@@ -107,9 +107,25 @@ export function startupTail(s) {
107
107
  const lines = s.output_tail.replace(/\n$/, '').split('\n');
108
108
  return `startup output (last ${Buffer.byteLength(s.output_tail)} bytes):\n${lines.map((l) => ` | ${l}`).join('\n')}\n`;
109
109
  }
110
+ /** A pending suspend-when-idle request in one line (0.5.1; contracts §20.6). */
111
+ export function suspendRequestText(r) {
112
+ return `after ${r.after_seconds} s idle, not before ${time(r.not_before)} (requested ${time(r.requested_at)})`;
113
+ }
114
+ /** `shard ws suspend --when-idle`: the recorded request, or the suspend already in progress. */
115
+ export function suspendWhenIdleText(w, r) {
116
+ if (r.suspend_request) {
117
+ const s = r.suspend_request;
118
+ return (`Suspend when idle: ${w.key} (${w.id}) is suspended once it has been idle for ${s.after_seconds} s, not before ${time(s.not_before)}.\n` +
119
+ 'A running command, an attached stream or a keepalive postpones it; the next tool call or a resume cancels it.\n' +
120
+ `cancel with: shard workspaces suspend ${w.key} --cancel-when-idle\n`);
121
+ }
122
+ const op = r.operation;
123
+ return `A suspend of ${w.key} (${w.id}) is already in progress (operation ${op.id} ${op.kind} ${op.state}); nothing was scheduled.\nwait with: shard operations wait ${op.id}\n`;
124
+ }
110
125
  export function workspaceDetail(w) {
111
126
  const c = w.ceilings;
112
127
  const startup = startupText(w.startup);
128
+ const suspendRequest = w.idle?.suspend_request;
113
129
  return kv([
114
130
  ['id', w.id],
115
131
  ['key', w.workspace_key],
@@ -125,6 +141,7 @@ export function workspaceDetail(w) {
125
141
  ['caps', pairs(w.caps)],
126
142
  ['operation', w.active_operation ? `${operationSummary(w.active_operation)} ${w.active_operation.id}` : '-'],
127
143
  ['pending', w.pending_reason ?? '-'],
144
+ ...(suspendRequest ? [['suspend when idle', suspendRequestText(suspendRequest)]] : []),
128
145
  ...(startup ? [['startup', startup]] : []),
129
146
  ['forked from', w.forked_from_workspace_id ?? '-'],
130
147
  ...(w.lifetime !== undefined
@@ -232,10 +249,66 @@ function objectTable(rows, columns) {
232
249
  const cols = columns.filter(([k]) => objs.some((o) => k in o));
233
250
  return table(cols.map(([, h]) => h), objs.map((o) => cols.map(([k]) => (k === 'percent_used' && typeof o[k] === 'number' ? `${o[k]}%` : scalar(o[k])))));
234
251
  }
235
- const USAGE_KNOWN = new Set(['plan', 'period', 'measurement', 'allowance_exhausted', 'meters', 'allowances', 'exhausted', 'alert_thresholds']);
252
+ const USAGE_KNOWN = new Set(['plan', 'period', 'measurement', 'allowance_exhausted', 'exhausted_reason', 'egress_override', 'meters', 'allowances', 'exhausted', 'alert_thresholds', 'spend_cap']);
253
+ /** Minor units as money (the overage block): `$3.00` for usd, else `3.00 EUR`. A fractional minor amount (a sub-cent rate) keeps its digits. */
254
+ export function capMoney(minor, currency) {
255
+ if (typeof minor !== 'number')
256
+ return '-';
257
+ const major = minor / 100;
258
+ const digits = Number.isInteger(minor) ? 2 : Math.min(6, Math.max(2, String(major).split('.')[1]?.length ?? 2));
259
+ const n = major.toFixed(digits);
260
+ return currency.toLowerCase() === 'usd' ? `$${n}` : `${n} ${currency.toUpperCase()}`;
261
+ }
262
+ /** What each spend_cap.state means for the reader (API contract: SpendCap.state). */
263
+ const OVERAGE_STATES = {
264
+ unavailable: 'not available on this plan',
265
+ off: 'starts are refused once an allowance is used up; an owner or billing member can turn overage on under Usage & billing',
266
+ paused: 'a plan payment is past due; overage resumes once it is paid',
267
+ within_allowance: 'on, no usage past the allowances yet',
268
+ accruing: 'usage past the allowances is charged on the next invoice',
269
+ warning: '80% or more of the spend cap is used',
270
+ reached: 'the spend cap is reached: new opens/resumes are refused and running workspaces are suspended',
271
+ };
272
+ /**
273
+ * The opt-in overage block (0.5.1; `spend_cap` of the summary, SDK 0.10.0): state, cap, charges of the cap, the
274
+ * projected date the cap is reached, and per allowance the units past it and their amount. Empty without spend_cap
275
+ * (an API older than overage).
276
+ */
277
+ function spendCapText(cap) {
278
+ if (!isObject(cap) || typeof cap.state !== 'string')
279
+ return '';
280
+ const cur = typeof cap.currency === 'string' ? cap.currency : 'usd';
281
+ const rows = [['overage', `${cap.state}${OVERAGE_STATES[cap.state] ? ` (${OVERAGE_STATES[cap.state]})` : ''}`]];
282
+ if (cap.state === 'unavailable')
283
+ return kv(rows);
284
+ const charges = typeof cap.charges_minor === 'number' ? cap.charges_minor : 0;
285
+ if (cap.state !== 'off') {
286
+ const plan = typeof cap.max_cap_minor === 'number' ? capMoney(cap.max_cap_minor, cur) : undefined;
287
+ if (cap.cap_minor === null || cap.cap_minor === undefined)
288
+ rows.push(['spend cap', `not set: ${capMoney(cap.effective_cap_minor, cur)}${plan ? ' (the plan price)' : ''} applies`]);
289
+ else if (typeof cap.effective_cap_minor === 'number' && typeof cap.cap_minor === 'number' && cap.effective_cap_minor < cap.cap_minor) {
290
+ rows.push(['spend cap', `${capMoney(cap.cap_minor, cur)}, lowered to ${capMoney(cap.effective_cap_minor, cur)}${plan ? ' (the plan price)' : ''}`]);
291
+ }
292
+ else
293
+ rows.push(['spend cap', `${capMoney(cap.cap_minor, cur)}${plan ? ` (at most ${plan}, the plan price)` : ''}`]);
294
+ }
295
+ if (cap.state !== 'off' || charges > 0) {
296
+ const percent = typeof cap.percent_of_cap === 'number' ? ` (${cap.percent_of_cap}%)` : '';
297
+ rows.push(['overage charges', `${capMoney(charges, cur)} of ${capMoney(cap.effective_cap_minor, cur)}${percent}, ${capMoney(cap.remaining_minor, cur)} left; billed on the next invoice`]);
298
+ }
299
+ if (typeof cap.projected_reached_at === 'string')
300
+ rows.push(['cap reached', `${time(cap.projected_reached_at)} (projected at this period's average use)`]);
301
+ let out = kv(rows);
302
+ const lines = Array.isArray(cap.lines) ? cap.lines.filter(isObject) : [];
303
+ if (lines.some((l) => (typeof l.units_over === 'number' && l.units_over > 0) || (typeof l.amount_minor === 'number' && l.amount_minor > 0))) {
304
+ out += `\n${table(['OVERAGE', 'UNIT', 'PAST ALLOWANCE', 'BILLED', 'RATE', 'AMOUNT'], lines.map((l) => [scalar(l.allowance), scalar(l.unit), scalar(l.units_over), scalar(l.billed_units), capMoney(l.rate_minor, cur), capMoney(l.amount_minor, cur)]))}`;
305
+ }
306
+ return out;
307
+ }
236
308
  /**
237
309
  * The usage summary (GET /v1/organizations/{id}/usage/summary): plan, period, measurement
238
- * freshness, then meters and allowances as tables. Columns absent from the response are omitted.
310
+ * freshness, then meters and allowances as tables (cap state `overage` past an allowance while opt-in overage is on),
311
+ * then the overage block. Columns absent from the response are omitted.
239
312
  */
240
313
  export function usageText(summary) {
241
314
  if (!isObject(summary))
@@ -251,8 +324,10 @@ export function usageText(summary) {
251
324
  head.push(['measured through', time(m.measured_through)]);
252
325
  if (isObject(s.measurement) && s.measurement.estimate_status !== undefined)
253
326
  head.push(['estimate', scalar(s.measurement.estimate_status)]);
254
- if (typeof s.allowance_exhausted === 'boolean')
255
- head.push(['allowance exhausted', s.allowance_exhausted ? 'yes (new opens/resumes are refused)' : 'no']);
327
+ if (typeof s.allowance_exhausted === 'boolean') {
328
+ const reason = typeof s.exhausted_reason === 'string' ? `${s.exhausted_reason}: ` : '';
329
+ head.push(['allowance exhausted', s.allowance_exhausted ? `yes (${reason}new opens/resumes are refused)` : 'no']);
330
+ }
256
331
  for (const [k, v] of Object.entries(s))
257
332
  if (!USAGE_KNOWN.has(k) && (typeof v !== 'object' || v === null))
258
333
  head.push([k.replace(/_/g, ' '), scalar(v)]);
@@ -280,6 +355,9 @@ export function usageText(summary) {
280
355
  ]);
281
356
  if (allowances)
282
357
  out += `\n${allowances}`;
358
+ const overage = spendCapText(s.spend_cap);
359
+ if (overage)
360
+ out += `\n${overage}`;
283
361
  return out || json(summary);
284
362
  }
285
363
  /** Request ids and error codes pasted unquoted into the suggested command must be shell-inert. */
@@ -755,3 +833,35 @@ export function workspaceEgressText(w) {
755
833
  export function egressVersionTable(rows) {
756
834
  return table(['VERSION', 'KIND', 'POLICY', 'BY', 'CREATED'], rows.map((v) => [String(v.version), v.kind, v.kind === 'cleared' ? '-' : egressPolicyLine({ mode: v.mode ?? 'allow_all', rules: v.rules, cidrs: v.cidrs }), `${v.created_by.type}:${v.created_by.id}`, time(v.created_at)]));
757
835
  }
836
+ // ---- Overage settings (0.5.1; shard billing overage) -----------------------------------------------------------
837
+ const RATE_UNITS = { ram_gib_hours: 'RAM GiB-hour', cpu_hours: 'CPU-hour' };
838
+ /**
839
+ * The spend policy's overage settings for people (GET/PUT /v1/organizations/{id}/spend-policy): the state, the spend
840
+ * cap and its bounds, the rates, and the next step. `saved` adds the version the change was saved as.
841
+ */
842
+ export function overagePolicyText(p, orgName, saved = false) {
843
+ const m = (minor) => capMoney(minor, p.currency);
844
+ const bound = p.spend_cap_max_minor === null ? '' : `${m(p.spend_cap_min_minor)} to ${m(p.spend_cap_max_minor)}, the plan price`;
845
+ const cap = p.spend_cap_minor === null ? 'no spend cap set' : `spend cap ${m(p.spend_cap_minor)} per billing period`;
846
+ const lines = [];
847
+ switch (p.overage_state) {
848
+ case 'unavailable':
849
+ lines.push(`${orgName}: overage is not available on this plan: it needs a paid plan with opt-in overage (shard billing plans).`);
850
+ break;
851
+ case 'off':
852
+ lines.push(`${orgName}: overage is off (${cap}): new opens and resumes are refused once an allowance is used up.`);
853
+ lines.push(`Turn it on${p.spend_cap_minor === null ? ` with a spend cap${bound ? ` of ${bound}` : ''}: shard billing overage on --cap <dollars>` : ': shard billing overage on'}`);
854
+ break;
855
+ case 'paused':
856
+ lines.push(`${orgName}: overage is on but paused while a plan payment is past due (update the payment method: shard billing portal --open); ${cap}.`);
857
+ break;
858
+ default:
859
+ lines.push(`${orgName}: overage is on, ${cap}${bound ? ` (${bound})` : ''}.`);
860
+ }
861
+ if (p.overage_state !== 'unavailable' && p.rates.length > 0) {
862
+ lines.push(`Rates past the allowances: ${p.rates.map((r) => `${m(r.amount_minor_per_unit)} per ${RATE_UNITS[r.allowance] ?? r.unit}`).join(', ')}, charged on the next invoice (this period: shard usage).`);
863
+ }
864
+ if (saved)
865
+ lines.push(`Saved (policy version ${p.version}).`);
866
+ return `${lines.join('\n')}\n`;
867
+ }
package/dist/index.d.ts CHANGED
@@ -8,7 +8,7 @@ export type { MainOptions, RunOptions } from './main.js';
8
8
  export { COMMANDS, GLOBAL_OPTIONS, CLI_VERSION, HELP_SECTIONS, commandHelp, findCommand, groupHelp, normalizeWords, parseCommandLine, parseDuration, parseEnvPairs, parsePositiveInt, resolveApiUrl, resolveWake, resolveWakeTimeout, topHelp } from './args.js';
9
9
  export type { CommandSpec, OptionSpec, Parsed, PositionalSpec, Values } from './args.js';
10
10
  export { HANDLERS } from './handlers.js';
11
- export { KEY_HANDLERS, execExitCode, resolveWorkspace } from './commands.js';
11
+ export { KEY_HANDLERS, execExitCode, resolveWorkspace, whenIdleSeconds } from './commands.js';
12
12
  export type { Ctx, Handler, Io, ResolvedApiKey, Writer } from './commands.js';
13
13
  export { ACCOUNT_HANDLERS, DEFAULT_API_URL, defaultOrgName, exportLine, parseTime } from './account-commands.js';
14
14
  export type { ShellKind } from './account-commands.js';
@@ -21,5 +21,5 @@ export type { Distribution, UpdateView } from './update-check.js';
21
21
  export { openUrl, openerFor } from './browser.js';
22
22
  export { EXIT, AuthConfigError, CheckoutEndedError, ExecutionFailedError, ExecutionPendingError, InterruptedError, MfaPendingError, NotFoundError, UsageError, describeError, exitCodeFor, redact } from './errors.js';
23
23
  export type { ErrorInfo } from './errors.js';
24
- export { changedText, errorText, executionJson, executionSummary, feedbackCommand, json, table, time, usageText, workspaceDetail, workspaceTable } from './format.js';
24
+ export { changedText, errorText, executionJson, executionSummary, feedbackCommand, json, suspendRequestText, suspendWhenIdleText, table, time, usageText, workspaceDetail, workspaceTable } from './format.js';
25
25
  export { TimingRecorder, timingJson, timingText } from './timing.js';
package/dist/index.js CHANGED
@@ -6,12 +6,12 @@
6
6
  export { run, main, abortableSleep } from "./main.js";
7
7
  export { COMMANDS, GLOBAL_OPTIONS, CLI_VERSION, HELP_SECTIONS, commandHelp, findCommand, groupHelp, normalizeWords, parseCommandLine, parseDuration, parseEnvPairs, parsePositiveInt, resolveApiUrl, resolveWake, resolveWakeTimeout, topHelp } from "./args.js";
8
8
  export { HANDLERS } from "./handlers.js";
9
- export { KEY_HANDLERS, execExitCode, resolveWorkspace } from "./commands.js";
9
+ export { KEY_HANDLERS, execExitCode, resolveWorkspace, whenIdleSeconds } from "./commands.js";
10
10
  export { ACCOUNT_HANDLERS, DEFAULT_API_URL, defaultOrgName, exportLine, parseTime } from "./account-commands.js";
11
11
  export { CREDENTIALS_FILE, CredentialStore, CredentialsFileError, configDir, ensurePrivateDir, parseCredentials, writePrivateFile } from "./credentials.js";
12
12
  export { Input } from "./input.js";
13
13
  export { CHECK_INTERVAL_MS, CLI_DISTRIBUTION, NOTICE_WAIT_MS, UPDATE_CHECK_FILE, UpdateCheck, noticeOf, statusFromEntry, updateCheckDisabled, updateView } from "./update-check.js";
14
14
  export { openUrl, openerFor } from "./browser.js";
15
15
  export { EXIT, AuthConfigError, CheckoutEndedError, ExecutionFailedError, ExecutionPendingError, InterruptedError, MfaPendingError, NotFoundError, UsageError, describeError, exitCodeFor, redact } from "./errors.js";
16
- export { changedText, errorText, executionJson, executionSummary, feedbackCommand, json, table, time, usageText, workspaceDetail, workspaceTable } from "./format.js";
16
+ export { changedText, errorText, executionJson, executionSummary, feedbackCommand, json, suspendRequestText, suspendWhenIdleText, table, time, usageText, workspaceDetail, workspaceTable } from "./format.js";
17
17
  export { TimingRecorder, timingJson, timingText } from "./timing.js";
package/dist/main.js CHANGED
@@ -15,7 +15,7 @@ import { SDK_VERSION, Shardflux, ShardfluxAccount, isSessionToken } from '@shard
15
15
  import { CLI_VERSION, parseCommandLine, resolveApiUrl, resolveWake, resolveWakeTimeout } from "./args.js";
16
16
  import { CredentialStore, configDir } from "./credentials.js";
17
17
  import { AuthConfigError, EXIT, ExecutionPendingError, InterruptedError, UsageError, describeError, exitCodeFor, ownHint, redact } from "./errors.js";
18
- import { errorText, feedbackCommand, json } from "./format.js";
18
+ import { errorText, feedbackCommand, json, time } from "./format.js";
19
19
  import { HANDLERS } from "./handlers.js";
20
20
  import { makeFetch } from "./http.js";
21
21
  import { Input } from "./input.js";
@@ -74,6 +74,11 @@ function hintFor(info, cred, file) {
74
74
  return 'change plans in the billing portal: shard billing portal --open';
75
75
  if (info.code === 'dependency_unavailable' && reasonOf(info) === 'billing_disabled')
76
76
  return 'this API has no billing configured: plans cannot be bought here';
77
+ if (info.code === 'allowance_exhausted')
78
+ return allowanceHint(info);
79
+ const policyHint = spendPolicyHint(info);
80
+ if (policyHint)
81
+ return policyHint;
77
82
  // File patches (contracts §26.2): what to do about each refusal. details.index is the 0-based edit (--old) index.
78
83
  const reason = info.details?.reason;
79
84
  const index = typeof info.details?.index === 'number' ? ` #${info.details.index + 1}` : '';
@@ -91,8 +96,76 @@ function hintFor(info, cred, file) {
91
96
  return 'the workspace’s host has no room to run it right now; retry in a few seconds';
92
97
  if (reason === 'host_feature_unavailable')
93
98
  return hostFeatureHint(info.details?.feature);
99
+ // A processful command that could not start (0.5.1): what to change, from the workspace's reason.
100
+ if (info.code === 'conflict' && reason === 'exec_failed_to_start')
101
+ return startFailureHint(typeof info.details?.error === 'string' ? info.details.error : info.message, false);
94
102
  return fileFirstHint(info);
95
103
  }
104
+ /**
105
+ * What to do about a command that could not start (exec_failed_to_start), from the workspace's reason: a working
106
+ * directory that is not a directory, else a program that is not on PATH. An unknown user needs no more than the reason.
107
+ */
108
+ function startFailureHint(why, fileFirst) {
109
+ if (/working directory|chdir|\bcwd\b/i.test(why)) {
110
+ return fileFirst
111
+ ? '--cwd must name a directory that exists in the workspace’s files, as an absolute path under /home/user'
112
+ : '--cwd must name a directory that exists in the workspace, as an absolute path (commands start in /home/user without --cwd)';
113
+ }
114
+ if (/unknown user/i.test(why))
115
+ return undefined;
116
+ return 'is argv[0] installed and on PATH? shell syntax needs -- bash -lc "..."';
117
+ }
118
+ /**
119
+ * 402 allowance_exhausted (0.5.1): what to do per details.reason (opt-in overage with a spend cap). An API older than
120
+ * overage sends no reason: no hint.
121
+ */
122
+ function allowanceHint(info) {
123
+ const resets = typeof info.details?.resets_at === 'string' ? ` (the period resets ${time(info.details.resets_at)})` : '';
124
+ switch (info.details?.reason) {
125
+ case 'allowance_used':
126
+ return `upgrade (shard billing plans), or turn on overage: shard billing overage on --cap <dollars>${resets}`;
127
+ case 'spend_cap_reached':
128
+ return `the spend cap is reached: raise it (shard billing overage cap <dollars>, at most the plan price) or upgrade${resets}`;
129
+ case 'overage_paused':
130
+ return 'a payment is past due: update the payment method (shard billing portal --open); overage resumes once the invoice is paid';
131
+ default:
132
+ return undefined;
133
+ }
134
+ }
135
+ /** Dollars for a hint: `9` for 900 minor units, `9.50` for 950. */
136
+ const dollars = (minor) => (typeof minor === 'number' ? (minor % 100 === 0 ? String(minor / 100) : (minor / 100).toFixed(2)) : undefined);
137
+ /**
138
+ * The spend-policy refusals (0.5.1; shard billing overage): 422 validation_failed per details.reason, with the bound the
139
+ * API names (min_minor, max_minor, charges_minor), and a 409 version_mismatch (--if-version, here or on egress set).
140
+ */
141
+ function spendPolicyHint(info) {
142
+ const d = info.details ?? {};
143
+ const reason = reasonOf(info);
144
+ if (info.code === 'conflict' && reason === 'version_mismatch') {
145
+ const now = typeof d.current_version === 'number' ? d.current_version : undefined;
146
+ return `it changed since that version${now === undefined ? '' : ` (it is at version ${now} now)`}; nothing was saved. Look at it again, then retry${now === undefined ? '' : ` with --if-version ${now}`}`;
147
+ }
148
+ if (info.code !== 'validation_failed')
149
+ return undefined;
150
+ switch (reason) {
151
+ case 'overage_unavailable':
152
+ return 'overage needs a paid plan with opt-in overage: shard billing plans, then shard billing upgrade <plan>';
153
+ case 'spend_cap_required':
154
+ return 'give a spend cap: shard billing overage on --cap <dollars> (shard billing overage shows the range)';
155
+ case 'spend_cap_below_minimum':
156
+ return `the smallest spend cap is $${dollars(d.min_minor) ?? '1'}: shard billing overage cap ${dollars(d.min_minor) ?? '1'}`;
157
+ case 'spend_cap_above_plan_price': {
158
+ const max = dollars(d.max_minor);
159
+ return `the spend cap is at most the plan price${max ? `, $${max}: shard billing overage cap ${max}` : ''}; a higher cap needs a larger plan (shard billing plans)`;
160
+ }
161
+ case 'spend_cap_below_charges': {
162
+ const charged = dollars(d.charges_minor);
163
+ return `overage already charged ${charged ? `$${charged}` : 'more than that'} this period: set a cap of at least that${charged ? ` (shard billing overage cap ${charged})` : ''}, or turn overage off (shard billing overage off)`;
164
+ }
165
+ default:
166
+ return undefined;
167
+ }
168
+ }
96
169
  /**
97
170
  * 409 conflict host_feature_unavailable (contracts §26.7; 0.5.0): the workspace runs on a host agent that predates the
98
171
  * call. Not retryable: it lasts until the workspace runs on an upgraded host (minutes to hours), so the hint names
@@ -121,7 +194,8 @@ function fileFirstHint(info) {
121
194
  const d = info.details ?? {};
122
195
  const reason = d.reason;
123
196
  if (info.code === 'execution_failed') {
124
- const cause = typeof reason === 'string' ? EXECUTION_CAUSES[reason] : undefined;
197
+ const startHint = reason === 'exec_failed_to_start' ? startFailureHint(info.message, true) : undefined;
198
+ const cause = startHint ? `the command could not start (${startHint})` : typeof reason === 'string' ? EXECUTION_CAUSES[reason] : undefined;
125
199
  return `${cause ? `${cause}; ` : ''}nothing was published (the tree is unchanged). Running it again needs a new --execution-id (omit it for a fresh one)${info.retryable ? '; a retry may succeed' : ''}.`;
126
200
  }
127
201
  switch (reason) {
@@ -166,7 +240,9 @@ const FEEDBACK_EMAIL = 'shardflux@heliosone.fi';
166
240
  * or step-up to finish, a wait that gave up while the work continues, a checkout nobody paid, and the refusals whose
167
241
  * hint names the exact next step (a stale revision, an edit that does not match, a mode the workspace lacks).
168
242
  */
169
- const NO_NUDGE_CODES = new Set(['usage', 'interrupted', 'unauthenticated', 'validation_failed', 'mfa_required', 'step_up_required', 'email_unverified', 'timeout', 'checkout_ended']);
243
+ // allowance_exhausted (0.5.1): a used-up allowance, a reached spend cap or a paused overage is a billing state whose hint
244
+ // names the next step (upgrade, overage, the cap, the payment method), not a problem to report.
245
+ const NO_NUDGE_CODES = new Set(['usage', 'interrupted', 'unauthenticated', 'validation_failed', 'mfa_required', 'step_up_required', 'email_unverified', 'timeout', 'checkout_ended', 'allowance_exhausted']);
170
246
  const NO_NUDGE_REASONS = new Set([
171
247
  'revision_mismatch',
172
248
  'tree_revision_mismatch',
@@ -182,6 +258,8 @@ const NO_NUDGE_REASONS = new Set([
182
258
  'layout_unsupported',
183
259
  'host_feature_unavailable',
184
260
  'subscription_exists',
261
+ // A stale spend policy (If-Match): the hint names the version to retry with (0.5.1).
262
+ 'version_mismatch',
185
263
  ]);
186
264
  /**
187
265
  * Whether a failure ends with the `feedback:` line (0.5.0+): every failure except the expected flow above (feedback
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shardflux/cli",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "description": "shard: the Shardflux command line. Create an account, sign in, set up keys and billing, then open, run commands in, move files to, suspend, resume and fork persistent agent workspaces.",
6
6
  "license": "Apache-2.0",
@@ -40,7 +40,7 @@
40
40
  },
41
41
  "dependencies": {
42
42
  "yaml": "2.9.1",
43
- "@shardflux/sdk": "^0.9.0"
43
+ "@shardflux/sdk": "^0.10.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@eslint/js": "10.0.1",