@shardflux/sdk 0.8.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,264 @@
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
+
64
+ ## 0.9.0 (not yet published; npm `latest` is 0.8.0)
65
+
66
+ Elastic compute (decision 0007): file tools and wake hints for parked workspaces. Additive; older APIs and cell
67
+ gateways keep working (the new calls answer 404 there).
68
+
69
+ ### File search, patches with revisions, the wake hint
70
+
71
+ Needs a cell gateway with the contracts §26 routes (`files/search`, `files/patch`, `wake-hint`, revisions); an older
72
+ gateway does not serve them and returns no revisions.
73
+
74
+ - `cell.files.search(path, pattern, opts)`: content search under a directory (literal or RE2 with `regex`,
75
+ `caseInsensitive`, `include`/`exclude` globs, `maxMatches`, `maxFileBytes`, `contextLines`). Returns the gateway's
76
+ `FileSearchResult` (`matches` with `path`, 1-based `line` and byte `column`, `text`, optional `before`/`after`;
77
+ `truncated` and `stop_reason`; `files_scanned`) plus `served_from` (`disk` when a suspended workspace was searched on its disk
78
+ without waking it). It is read-only, so the HTTP layer retries it like a GET (new `RequestOptions.idempotent`).
79
+ - `cell.files.patch({ path, edits | content, expectedRevision, createParents, mode }, { idempotencyKey })`: text
80
+ edits (`{ oldText, newText, replaceAll }`, each must match exactly once unless `replaceAll`, applied in order,
81
+ all or nothing) or a whole new `content`, atomic and durable. Every call sends an `Idempotency-Key` (like `write`),
82
+ so a retried call is applied once. `expectedRevision` (or `absent`) makes a concurrent change a 409.
83
+ - Revisions: a file's revision is the SHA-256 of its content. `cell.files.stat(path, { revision: true })` returns it
84
+ (`FileInfo.revision`), writes and patches return it, and the new `cell.files.readWithInfo(path, opts)` returns
85
+ `{ data, size, revision, servedFrom }` from the read's `X-File-Size`, `X-File-Revision` and `X-Served-From` headers.
86
+ `read()` and `readText()` keep returning bytes and text: a separate method keeps every existing caller and return
87
+ type unchanged, where an options flag would have made `read()`'s return type depend on an argument. A read
88
+ continued over several requests reports a revision only when every part carried the same one.
89
+ - `workspace.hint(opts)` sends `POST /wake-hint`: a hibernated workspace is restored ahead of the tool call that
90
+ follows. It returns `{ residency, wake }`; for a workspace that is not running (409 `workspace_not_running`, also
91
+ when its tool token cannot be issued) it starts `wake()` in the background and returns at once (`wake` is that
92
+ promise, shared by concurrent hints; `wake: null` in the options only reports it). It is never retried and never
93
+ waits out `workspace_busy`. `cell.wakeHint()` is the bare request.
94
+ - Agent tools: `search_files` and `edit_file` (permission `files`), after `list_files`. `edit_file` patches with
95
+ `edits`; when the model gives no `expected_revision` it reads the file's revision first
96
+ (`stat?revision=true`) and pins the patch to it, so a change made in between is refused (`revision_mismatch`)
97
+ instead of edited blindly. `search_files` returns whole matches up to `maxOutputBytes` and counts the rest in
98
+ `omitted_matches`. The existing tools are unchanged. The tool runner sends `workspace.hint()` when a tool call
99
+ starts, without waiting for it; `workspaceTools(ws, { hint: false })` turns that off.
100
+ - Errors: `KnownErrorReason` adds `revision_mismatch` (409, `details.current_revision`), `edit_not_found`,
101
+ `edit_ambiguous` (422, `details.index`), `edit_not_text`, `patch_invalid`, `host_capacity` and `wake_failed` (503,
102
+ retryable, `Retry-After`: retried by the HTTP layer for GETs, searches and requests with an Idempotency-Key, like
103
+ any retryable 503), `workspace_fenced` (409 `workspace_busy`, waited out like any `workspace_busy`), and for reads
104
+ of a sleeping workspace `offline_unavailable` and `offline_budget` (409 `workspace_not_running`: woken and retried
105
+ like any) and `offline_changed` (503, retried; served by the running workspace).
106
+ - Reads of a suspended workspace without a held token: the API now issues tool tokens for a suspended workspace
107
+ (contracts §26.4), so `read`, `readText`, `readWithInfo`, `stat`, `list` and `search` of a suspended workspace are
108
+ served from its disk without waking it also for a handle that fetches its first token after the suspend (before,
109
+ the token was refused and the call woke the workspace). Any other call still wakes it: the cell refuses it with 409
110
+ `workspace_not_running`. Needs that API; with an older one the token is refused and the call wakes it as before.
111
+ - The agent-tool runner sends no wake hint for `read_file`, `list_files` and `search_files`: a sleeping workspace
112
+ serves them from its disk, and the hint would wake a suspended one (or restore a hibernated one) for nothing.
113
+ - Hosts without the features (contracts §26.7): `KnownErrorReason` adds `host_feature_unavailable` (409 `conflict`,
114
+ not retryable, `details.feature` `file_search` or `file_patch`): the workspace runs on a host agent that predates
115
+ the call, until it runs on an upgraded host. It is neither retried nor answered with a wake. Such a host also
116
+ returns no revisions (`FileInfo.revision`, `readWithInfo().revision` are absent).
117
+ - `JsonSchema.pattern`, checked by `validateArgs()`.
118
+ - Types: `FileSearchOptions`, `FileSearchResponse`, `FileSearchRequest`, `FileSearchResult`, `FileSearchMatch`,
119
+ `FilePatchParams`, `FilePatchEdit`, `FilePatchRequest`, `FilePatchResult`, `FileEdit`, `FileRevision`,
120
+ `FileReadResult`, `ServedFrom`, `Residency`, `WakeHintResult`, `HintOptions`, `HintResult`.
121
+
122
+ ### File-first workspaces (contracts §29)
123
+
124
+ Needs an API with `FILE_FIRST_WORKSPACES` on and a cell that serves file-first workspaces (contracts §29.8). A
125
+ file-first workspace has no VM between executions: its state is a versioned file tree under /home/user, and each
126
+ command runs in a fresh VM whose changed files become the next tree revision. Processful workspaces are unchanged.
127
+
128
+ - `workspaces.open({ mode: 'file_first' })` (`mode: 'processful' | 'file_first'`; omitted: the API's default). A
129
+ file-first open is ready at once (200 with a tool token, no operation). `workspace.mode` and `workspace.treeRevision`
130
+ (null for processful): the handle follows every `X-Tree-Revision` its cell clients see, execution results and
131
+ `refresh()`, and never moves backwards.
132
+ - `workspace.executions.run(argv, opts)` / `cell.executions.run()`: runs one command as an execution and resolves when
133
+ it ended with an `ExecutionResult`: `stdout` / `stderr` bytes plus `text(stream)`, `stdoutText`, `stderrText`;
134
+ `exitCode`, `termSignal`, `timedOut`, `state` (`succeeded`, `failed`, `lost`), `baseRevision`, `treeRevision`,
135
+ `changed` (`{ path, change: added | modified | deleted, type }`), `changedTruncated`, the truncation flags,
136
+ `timings`, `error` / `errorReason`, `replayed`, `ok`, `raw`. Options: `executionId`, `cwd`, `env`, `user`, `stdin`,
137
+ `timeoutMs`, `killGraceMs`, `secretRefs`, `outputLimitBytes`, `maxRetries`, `attemptTimeoutMs`, `signal`.
138
+ - Idempotent by execution id: `executionId` defaults to a fresh `ex-<uuid>` (`newExecutionId()`, checked against
139
+ `EXECUTION_ID` before any request). Network failures and retryable 429/5xx answers (503 `no_execution_host`, with
140
+ `Retry-After`, honoured up to 30 s) are retried with the same id and body at most `maxRetries` (5) times; a wait
141
+ longer than `attemptTimeoutMs` (300 s) re-attaches with the same id without counting as a failure. The cell answers
142
+ 201 for the call that ran the command and 200 (`replayed`) with the recorded result for any other, so the command
143
+ runs at most once. `workspace_busy` `execution_in_progress` is waited out like any `workspace_busy`;
144
+ `execution_id_reused` is thrown at once. A `failed` or `lost` result is returned, never retried with a new id.
145
+ - `executions.get(id, { waitMs })`: the result (`GET /executions/{id}`), or a `pending` one (202, state `queued` /
146
+ `running`) while it runs; `waitMs` polls (250 ms doubling to 2 s) until it ended or the wait elapsed.
147
+ - `ifTreeRevision` on `files.write`, `remove`, `mkdir`, `move` and `patch` sends `If-Match`: the call applies only at
148
+ that tree revision, else `TreeRevisionMismatchError` (409 `conflict` `tree_revision_mismatch`, with
149
+ `currentTreeRevision`) and nothing changed.
150
+ - Calls the mode lacks fail with `NotSupportedForModeError` (409 `conflict` `not_supported_for_mode`, `mode`,
151
+ `operation`, `local`): locally, without a request, when the handle knows the mode (file-first: exec sessions, PTY,
152
+ processes, git, browser, `changes()`, `suspend`, `resume`, `snapshot`, `fork`, `reset`, `saveAsTemplate`;
153
+ processful: `executions.*` and `ifTreeRevision`, which a processful cell would ignore), otherwise as the server's
154
+ refusal, typed the same way. On a file-first workspace `wake()` resolves `false` and `hint()` / `cell.wakeHint()`
155
+ answer `resident` without a request (nothing sleeps).
156
+ - Agent tools: `workspaceTools()` of a file-first workspace offers `exec` and the files tools only; its `exec` runs an
157
+ execution and returns `execution_id`, `state`, `tree_revision`, `changed` (up to 200, `changed_truncated`) and
158
+ `error` besides the output. New options: `mode` (build definitions without touching the workspace) and
159
+ `onExecution(id)` (called with the execution id before it is sent, so a caller that gives up can fetch the result).
160
+ **Breaking:** `workspaceTools()` now reads `workspace.mode` while building the definitions unless `mode` is given; a
161
+ caller that builds definitions from a stand-in workspace (as the MCP server does) passes `mode`.
162
+ - Errors: every error is built by reason, so `NotSupportedForModeError` and `TreeRevisionMismatchError` (both
163
+ `ShardfluxApiError`) come from any call; `ShardfluxApiError.treeRevision` is the refusal's `X-Tree-Revision`. Their
164
+ `name` is the subclass's (code that compared `err.name` with `'ShardfluxApiError'` should use `instanceof`).
165
+ `KnownErrorReason` adds `not_supported_for_mode`, `mode_mismatch`, `mode_not_available`, `layout_unsupported`,
166
+ `tree_revision_mismatch`, `outside_tree_root`, `execution_in_progress`, `execution_id_reused`,
167
+ `operation_id_reused`, `no_execution_host`, and the execution error reasons `lease_expired`, `host_unreachable`,
168
+ `host_restarted`, `tree_moved`, `blob_missing`, `blob_corrupt`, `exec_failed_to_start`. Retry progress causes name the
169
+ reason (`HTTP 503 service_unavailable no_execution_host`).
170
+ - Types: `WorkspaceMode`, `ExecutionResult`, `ExecutionResultBody`, `ExecutionChange`, `ExecutionState`,
171
+ `ExecutionError`, `ExecutionRunOptions`, `ExecutionGetOptions`, `TreeRevisionOptions`.
172
+
173
+ ### Waking a suspended workspace is one request (contracts §22.6)
174
+
175
+ Needs an API with the held resume for the single request; against an older API every call below works as in 0.7.0.
176
+
177
+ - The wake of a tool call refused with `workspace_not_running` (and `hint()`'s background wake, and `wake()`) sends
178
+ one `POST /v1/workspaces/{id}/resume` with `Prefer: wait` and the calling client's agent label and tools. The API
179
+ answers once the workspace runs, with the view and a tool token minted during the restore: the handle keeps both
180
+ and the refused call is retried at once. On staging the automatic wake made five requests before the command
181
+ (refused call, resume, wait, view, token: 1,118 ms median, against 838 ms for a held reopen); it is now two.
182
+ - Without `Preference-Applied`, or on a 202 (not finished within the hold), the wake waits for the operation and reads
183
+ the view as before; the retried call then fetches its token.
184
+ - `resume({ wait })` (handle and `workspaces.resume(id)`) is the same single held request; a handle takes the view and
185
+ the token. `serverWait: false` keeps the polled path. A workspace that is already running still throws `conflict`
186
+ `already_running` (the held answer is 200 without an operation; the SDK reports it as before).
187
+ - Timing: a held resume or wake is one `request` phase with reason `held`; `server` comes from the returned operation.
188
+ - New options: `WakeOptions.agentLabel` / `tools` and `ResumeOptions` (`agentLabel`, `tools`; `WaitedResumeOptions`)
189
+ choose the token that comes back; `cell()`'s own wake passes its label and tools. `WorkspacesApi.requestResume()`
190
+ is the bare request (`ResumeAnswer`, `ResumeRequestOptions`, `ResumeResponse`).
191
+ - `CellClient`: after a wake that put a new token into the client's manager, the retry uses it instead of fetching one.
192
+ ### The account plane and the version check (contracts §30)
193
+
194
+ Needs an API with CLI sessions and client versions (contracts §30). Every API is additive; the one change in
195
+ behavior is the automatic version check (below), which makes one background request per process.
196
+
197
+ ### Account plane: `ShardfluxAccount`
198
+
199
+ - `ShardfluxAccount`: what a person does in the web app, from code, with a CLI session (`sfu_<token>`, sent as
200
+ `Authorization: Bearer` on `/v1`). Options: `sessionToken?`, `baseUrl?`, `fetch?`, `userAgent?`, `timeoutMs?`,
201
+ `maxRetries?`, `sleep?`, `onSessionToken?`, `versionCheck?`, `onProgress?`. A malformed token throws (shape
202
+ `^sfu_[A-Za-z0-9_-]{43}$`, also `isSessionToken()` and `SESSION_TOKEN_PATTERN`).
203
+ - Before a session exists (static, no token): `register({ email, password, displayName? })`,
204
+ `verifyEmail(linkOrToken)`, `requestPasswordReset(email)`, `confirmPasswordReset({ token, newPassword })`,
205
+ `confirmEmailChange(linkOrToken)` and `login({ email, password, onSessionToken? })` → `{ account, result }`
206
+ (`result.status` `authenticated` or `mfa_required`). Each takes the client options.
207
+ - Token rotation: login, `auth.completeMfa()`, `auth.stepUp()`, `auth.changePassword()`, `auth.totp.confirm()` and
208
+ `auth.totp.disable()` return a new token (the previous one is revoked). `account.sessionToken` always holds the
209
+ current one, every later call (the reused namespaces included) sends it, and `onSessionToken({ token, expiresAt })`
210
+ is called (and awaited) with each.
211
+ - Namespaces: `auth` (`session`, `completeMfa`, `logout`, `logoutAll`, `sessions`, `revokeSession`, `stepUp`,
212
+ `changePassword`, `changeEmail`, `resendVerification`, `totp.enroll|confirm|disable|regenerateRecoveryCodes`),
213
+ `organizations` (`list`, `listAll`, `create`, `get`, `entitlements`, `deletion`, `delete`, `exports.create|get|download`,
214
+ `workspaces`), `projects` (`list`, `listAll`, `create`, `get`), `apiKeys` (`list`, `create`, `revoke`), `members`
215
+ (`list`, `update`, `remove`), `invitations` (`list`, `create`, `revoke`, `accept`), `billing` (`catalog`,
216
+ `subscription`, `checkout`, `checkoutStatus`, `waitForCheckout`, `portal`, `invoices`, `spendPolicy`,
217
+ `setSpendPolicy`), `user` (`deletion`, `scheduleDeletion`, `cancelDeletion`, `exports.create|get|download`),
218
+ `templates` (every `TemplatesApi` method plus `publishVersion` and `archiveVersion`), `audit` (`list`, `listAll`,
219
+ `export` → the CSV or NDJSON text), `usage`, `secrets`, `egress`, `volumes`, `workspaces`, `me()` and `request()`.
220
+ List methods return the API's page `{ data, next_cursor }`.
221
+ - `apiKeys.create(projectId, { name, toolPermissions?, expiresAt?, idempotencyKey? })` sends an Idempotency-Key (a
222
+ fresh one unless given), so a retried request returns the same key and secret instead of creating a second key.
223
+ `toolPermissions` defaults to `[]`; `API_KEY_TOOL_PERMISSIONS` lists every tool.
224
+ - `billing.waitForCheckout(orgId, checkoutId, { timeoutMs?, intervalMs?, signal? })` polls until the subscription is
225
+ active or the checkout expired, was canceled or failed, and returns that status; after `timeoutMs` (default 15 min)
226
+ it throws `CheckoutTimeoutError` (`checkout`, `waitedMs`), on abort the signal's reason.
227
+ - `parseEmailToken(input)`: the `token` of an emailed link's `#token=` fragment or `?token=` query (percent-decoded),
228
+ else the trimmed input; throws for an empty input or a link without a token. Every method that takes an emailed
229
+ token accepts the whole link.
230
+ - Errors are `ShardfluxApiError` as elsewhere; the session adds the codes `step_up_required` (call `auth.stepUp()` and
231
+ retry), `mfa_required` and `email_unverified` (403).
232
+
233
+ ### Version check
234
+
235
+ - `checkClientVersion({ baseUrl?, fetch?, package?, version?, ecosystem?, timeoutMs?, signal? })` →
236
+ `ClientVersionStatus` `{ status, package, ecosystem, current, latest, minimumSupported, upgradeCommand,
237
+ releaseNotesUrl, message? }` from `GET /v1/client-versions`. `status`: `unsupported` (below `minimum_supported`),
238
+ `outdated` (below `latest`), `current`, or `unknown` (no entry, `latest` null, a version that does not parse, or the
239
+ request failed; it never throws). `compareVersions(a, b)` → -1, 0, 1, or null for a string that is not
240
+ `major.minor.patch` (a pre-release sorts before its release).
241
+ - Automatic: after the first successful API response of a `Shardflux` or `ShardfluxAccount` client, once per process
242
+ per package identity, a background request (3 s timeout, every error swallowed; it never delays a call) emits
243
+ `process.emitWarning(message, { type: 'ShardfluxUpdateWarning', code: 'SHARDFLUX_UPDATE_AVAILABLE' })` when the
244
+ package is outdated or unsupported. Option `versionCheck?: boolean | { package, version, ecosystem? }` on both
245
+ clients (default `@shardflux/sdk` at `SDK_VERSION`; tools built on the SDK pass their own identity or `false`).
246
+ Off with `SHARDFLUX_NO_UPDATE_CHECK=1` (also `true`, `yes`, `on`) or `NO_UPDATE_NOTIFIER=1`. `fetchBillingCatalog()`
247
+ does not check.
248
+
249
+ ### Feedback straight to the founder (POST /v1/feedback)
250
+
251
+ - `cloud.sendFeedback({ message, category?, context? })` sends feedback to the Shardflux founder by email and returns
252
+ `{ id, receivedAt, duplicate }`. `category`: `bug`, `confusing`, `missing`, `idea`, `praise` or `other` (default).
253
+ `context`: `agent`, `client`, `workspace`, `requestId`, `errorCode`, `command`, `page` (sent in snake_case);
254
+ `client` defaults to `shardflux-sdk-ts/<SDK_VERSION>`.
255
+ - `duplicate: true`: the same message from the same key within 24 hours; the original is returned and no second email
256
+ is sent. 429 `rate_limited` (with `retryAfterSeconds`) and 422 `validation_failed` are `ShardfluxApiError`s. The call
257
+ is a POST without an idempotency key, so it is never retried.
258
+ - `ShardfluxAccount.sendFeedback({ ..., organizationId? })`: the same with a CLI session, as the signed-in user,
259
+ optionally about one of their organizations (404 `not_found` for another).
260
+ - New exports: `FeedbackCategory`, `FeedbackContext`, `FeedbackReceipt`, `SendFeedbackParams`, `AccountFeedbackParams`,
261
+ `FEEDBACK_CATEGORIES`, `FEEDBACK_MESSAGE_MAX_LENGTH`.
262
+
263
+
6
264
  ## 0.8.0 (2026-09-28)
7
265
 
8
266
  Types only; nothing changes at run time and the API is unchanged.