@shardflux/sdk 0.7.0 → 0.9.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,7 +3,226 @@
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.7.0 (not yet published; npm `latest` is 0.6.2)
6
+ ## 0.9.0 (not yet published; npm `latest` is 0.8.0)
7
+
8
+ Elastic compute (decision 0007): file tools and wake hints for parked workspaces. Additive; older APIs and cell
9
+ gateways keep working (the new calls answer 404 there).
10
+
11
+ ### File search, patches with revisions, the wake hint
12
+
13
+ Needs a cell gateway with the contracts §26 routes (`files/search`, `files/patch`, `wake-hint`, revisions); an older
14
+ gateway does not serve them and returns no revisions.
15
+
16
+ - `cell.files.search(path, pattern, opts)`: content search under a directory (literal or RE2 with `regex`,
17
+ `caseInsensitive`, `include`/`exclude` globs, `maxMatches`, `maxFileBytes`, `contextLines`). Returns the gateway's
18
+ `FileSearchResult` (`matches` with `path`, 1-based `line` and byte `column`, `text`, optional `before`/`after`;
19
+ `truncated` and `stop_reason`; `files_scanned`) plus `served_from` (`disk` when a suspended workspace was searched on its disk
20
+ without waking it). It is read-only, so the HTTP layer retries it like a GET (new `RequestOptions.idempotent`).
21
+ - `cell.files.patch({ path, edits | content, expectedRevision, createParents, mode }, { idempotencyKey })`: text
22
+ edits (`{ oldText, newText, replaceAll }`, each must match exactly once unless `replaceAll`, applied in order,
23
+ all or nothing) or a whole new `content`, atomic and durable. Every call sends an `Idempotency-Key` (like `write`),
24
+ so a retried call is applied once. `expectedRevision` (or `absent`) makes a concurrent change a 409.
25
+ - Revisions: a file's revision is the SHA-256 of its content. `cell.files.stat(path, { revision: true })` returns it
26
+ (`FileInfo.revision`), writes and patches return it, and the new `cell.files.readWithInfo(path, opts)` returns
27
+ `{ data, size, revision, servedFrom }` from the read's `X-File-Size`, `X-File-Revision` and `X-Served-From` headers.
28
+ `read()` and `readText()` keep returning bytes and text: a separate method keeps every existing caller and return
29
+ type unchanged, where an options flag would have made `read()`'s return type depend on an argument. A read
30
+ continued over several requests reports a revision only when every part carried the same one.
31
+ - `workspace.hint(opts)` sends `POST /wake-hint`: a hibernated workspace is restored ahead of the tool call that
32
+ follows. It returns `{ residency, wake }`; for a workspace that is not running (409 `workspace_not_running`, also
33
+ when its tool token cannot be issued) it starts `wake()` in the background and returns at once (`wake` is that
34
+ promise, shared by concurrent hints; `wake: null` in the options only reports it). It is never retried and never
35
+ waits out `workspace_busy`. `cell.wakeHint()` is the bare request.
36
+ - Agent tools: `search_files` and `edit_file` (permission `files`), after `list_files`. `edit_file` patches with
37
+ `edits`; when the model gives no `expected_revision` it reads the file's revision first
38
+ (`stat?revision=true`) and pins the patch to it, so a change made in between is refused (`revision_mismatch`)
39
+ instead of edited blindly. `search_files` returns whole matches up to `maxOutputBytes` and counts the rest in
40
+ `omitted_matches`. The existing tools are unchanged. The tool runner sends `workspace.hint()` when a tool call
41
+ starts, without waiting for it; `workspaceTools(ws, { hint: false })` turns that off.
42
+ - Errors: `KnownErrorReason` adds `revision_mismatch` (409, `details.current_revision`), `edit_not_found`,
43
+ `edit_ambiguous` (422, `details.index`), `edit_not_text`, `patch_invalid`, `host_capacity` and `wake_failed` (503,
44
+ retryable, `Retry-After`: retried by the HTTP layer for GETs, searches and requests with an Idempotency-Key, like
45
+ any retryable 503), `workspace_fenced` (409 `workspace_busy`, waited out like any `workspace_busy`), and for reads
46
+ of a sleeping workspace `offline_unavailable` and `offline_budget` (409 `workspace_not_running`: woken and retried
47
+ like any) and `offline_changed` (503, retried; served by the running workspace).
48
+ - Reads of a suspended workspace without a held token: the API now issues tool tokens for a suspended workspace
49
+ (contracts §26.4), so `read`, `readText`, `readWithInfo`, `stat`, `list` and `search` of a suspended workspace are
50
+ served from its disk without waking it also for a handle that fetches its first token after the suspend (before,
51
+ the token was refused and the call woke the workspace). Any other call still wakes it: the cell refuses it with 409
52
+ `workspace_not_running`. Needs that API; with an older one the token is refused and the call wakes it as before.
53
+ - The agent-tool runner sends no wake hint for `read_file`, `list_files` and `search_files`: a sleeping workspace
54
+ serves them from its disk, and the hint would wake a suspended one (or restore a hibernated one) for nothing.
55
+ - Hosts without the features (contracts §26.7): `KnownErrorReason` adds `host_feature_unavailable` (409 `conflict`,
56
+ not retryable, `details.feature` `file_search` or `file_patch`): the workspace runs on a host agent that predates
57
+ the call, until it runs on an upgraded host. It is neither retried nor answered with a wake. Such a host also
58
+ returns no revisions (`FileInfo.revision`, `readWithInfo().revision` are absent).
59
+ - `JsonSchema.pattern`, checked by `validateArgs()`.
60
+ - Types: `FileSearchOptions`, `FileSearchResponse`, `FileSearchRequest`, `FileSearchResult`, `FileSearchMatch`,
61
+ `FilePatchParams`, `FilePatchEdit`, `FilePatchRequest`, `FilePatchResult`, `FileEdit`, `FileRevision`,
62
+ `FileReadResult`, `ServedFrom`, `Residency`, `WakeHintResult`, `HintOptions`, `HintResult`.
63
+
64
+ ### File-first workspaces (contracts §29)
65
+
66
+ Needs an API with `FILE_FIRST_WORKSPACES` on and a cell that serves file-first workspaces (contracts §29.8). A
67
+ file-first workspace has no VM between executions: its state is a versioned file tree under /home/user, and each
68
+ command runs in a fresh VM whose changed files become the next tree revision. Processful workspaces are unchanged.
69
+
70
+ - `workspaces.open({ mode: 'file_first' })` (`mode: 'processful' | 'file_first'`; omitted: the API's default). A
71
+ file-first open is ready at once (200 with a tool token, no operation). `workspace.mode` and `workspace.treeRevision`
72
+ (null for processful): the handle follows every `X-Tree-Revision` its cell clients see, execution results and
73
+ `refresh()`, and never moves backwards.
74
+ - `workspace.executions.run(argv, opts)` / `cell.executions.run()`: runs one command as an execution and resolves when
75
+ it ended with an `ExecutionResult`: `stdout` / `stderr` bytes plus `text(stream)`, `stdoutText`, `stderrText`;
76
+ `exitCode`, `termSignal`, `timedOut`, `state` (`succeeded`, `failed`, `lost`), `baseRevision`, `treeRevision`,
77
+ `changed` (`{ path, change: added | modified | deleted, type }`), `changedTruncated`, the truncation flags,
78
+ `timings`, `error` / `errorReason`, `replayed`, `ok`, `raw`. Options: `executionId`, `cwd`, `env`, `user`, `stdin`,
79
+ `timeoutMs`, `killGraceMs`, `secretRefs`, `outputLimitBytes`, `maxRetries`, `attemptTimeoutMs`, `signal`.
80
+ - Idempotent by execution id: `executionId` defaults to a fresh `ex-<uuid>` (`newExecutionId()`, checked against
81
+ `EXECUTION_ID` before any request). Network failures and retryable 429/5xx answers (503 `no_execution_host`, with
82
+ `Retry-After`, honoured up to 30 s) are retried with the same id and body at most `maxRetries` (5) times; a wait
83
+ longer than `attemptTimeoutMs` (300 s) re-attaches with the same id without counting as a failure. The cell answers
84
+ 201 for the call that ran the command and 200 (`replayed`) with the recorded result for any other, so the command
85
+ runs at most once. `workspace_busy` `execution_in_progress` is waited out like any `workspace_busy`;
86
+ `execution_id_reused` is thrown at once. A `failed` or `lost` result is returned, never retried with a new id.
87
+ - `executions.get(id, { waitMs })`: the result (`GET /executions/{id}`), or a `pending` one (202, state `queued` /
88
+ `running`) while it runs; `waitMs` polls (250 ms doubling to 2 s) until it ended or the wait elapsed.
89
+ - `ifTreeRevision` on `files.write`, `remove`, `mkdir`, `move` and `patch` sends `If-Match`: the call applies only at
90
+ that tree revision, else `TreeRevisionMismatchError` (409 `conflict` `tree_revision_mismatch`, with
91
+ `currentTreeRevision`) and nothing changed.
92
+ - Calls the mode lacks fail with `NotSupportedForModeError` (409 `conflict` `not_supported_for_mode`, `mode`,
93
+ `operation`, `local`): locally, without a request, when the handle knows the mode (file-first: exec sessions, PTY,
94
+ processes, git, browser, `changes()`, `suspend`, `resume`, `snapshot`, `fork`, `reset`, `saveAsTemplate`;
95
+ processful: `executions.*` and `ifTreeRevision`, which a processful cell would ignore), otherwise as the server's
96
+ refusal, typed the same way. On a file-first workspace `wake()` resolves `false` and `hint()` / `cell.wakeHint()`
97
+ answer `resident` without a request (nothing sleeps).
98
+ - Agent tools: `workspaceTools()` of a file-first workspace offers `exec` and the files tools only; its `exec` runs an
99
+ execution and returns `execution_id`, `state`, `tree_revision`, `changed` (up to 200, `changed_truncated`) and
100
+ `error` besides the output. New options: `mode` (build definitions without touching the workspace) and
101
+ `onExecution(id)` (called with the execution id before it is sent, so a caller that gives up can fetch the result).
102
+ **Breaking:** `workspaceTools()` now reads `workspace.mode` while building the definitions unless `mode` is given; a
103
+ caller that builds definitions from a stand-in workspace (as the MCP server does) passes `mode`.
104
+ - Errors: every error is built by reason, so `NotSupportedForModeError` and `TreeRevisionMismatchError` (both
105
+ `ShardfluxApiError`) come from any call; `ShardfluxApiError.treeRevision` is the refusal's `X-Tree-Revision`. Their
106
+ `name` is the subclass's (code that compared `err.name` with `'ShardfluxApiError'` should use `instanceof`).
107
+ `KnownErrorReason` adds `not_supported_for_mode`, `mode_mismatch`, `mode_not_available`, `layout_unsupported`,
108
+ `tree_revision_mismatch`, `outside_tree_root`, `execution_in_progress`, `execution_id_reused`,
109
+ `operation_id_reused`, `no_execution_host`, and the execution error reasons `lease_expired`, `host_unreachable`,
110
+ `host_restarted`, `tree_moved`, `blob_missing`, `blob_corrupt`, `exec_failed_to_start`. Retry progress causes name the
111
+ reason (`HTTP 503 service_unavailable no_execution_host`).
112
+ - Types: `WorkspaceMode`, `ExecutionResult`, `ExecutionResultBody`, `ExecutionChange`, `ExecutionState`,
113
+ `ExecutionError`, `ExecutionRunOptions`, `ExecutionGetOptions`, `TreeRevisionOptions`.
114
+
115
+ ### Waking a suspended workspace is one request (contracts §22.6)
116
+
117
+ Needs an API with the held resume for the single request; against an older API every call below works as in 0.7.0.
118
+
119
+ - The wake of a tool call refused with `workspace_not_running` (and `hint()`'s background wake, and `wake()`) sends
120
+ one `POST /v1/workspaces/{id}/resume` with `Prefer: wait` and the calling client's agent label and tools. The API
121
+ answers once the workspace runs, with the view and a tool token minted during the restore: the handle keeps both
122
+ and the refused call is retried at once. On staging the automatic wake made five requests before the command
123
+ (refused call, resume, wait, view, token: 1,118 ms median, against 838 ms for a held reopen); it is now two.
124
+ - Without `Preference-Applied`, or on a 202 (not finished within the hold), the wake waits for the operation and reads
125
+ the view as before; the retried call then fetches its token.
126
+ - `resume({ wait })` (handle and `workspaces.resume(id)`) is the same single held request; a handle takes the view and
127
+ the token. `serverWait: false` keeps the polled path. A workspace that is already running still throws `conflict`
128
+ `already_running` (the held answer is 200 without an operation; the SDK reports it as before).
129
+ - Timing: a held resume or wake is one `request` phase with reason `held`; `server` comes from the returned operation.
130
+ - New options: `WakeOptions.agentLabel` / `tools` and `ResumeOptions` (`agentLabel`, `tools`; `WaitedResumeOptions`)
131
+ choose the token that comes back; `cell()`'s own wake passes its label and tools. `WorkspacesApi.requestResume()`
132
+ is the bare request (`ResumeAnswer`, `ResumeRequestOptions`, `ResumeResponse`).
133
+ - `CellClient`: after a wake that put a new token into the client's manager, the retry uses it instead of fetching one.
134
+ ### The account plane and the version check (contracts §30)
135
+
136
+ Needs an API with CLI sessions and client versions (contracts §30). Every API is additive; the one change in
137
+ behavior is the automatic version check (below), which makes one background request per process.
138
+
139
+ ### Account plane: `ShardfluxAccount`
140
+
141
+ - `ShardfluxAccount`: what a person does in the web app, from code, with a CLI session (`sfu_<token>`, sent as
142
+ `Authorization: Bearer` on `/v1`). Options: `sessionToken?`, `baseUrl?`, `fetch?`, `userAgent?`, `timeoutMs?`,
143
+ `maxRetries?`, `sleep?`, `onSessionToken?`, `versionCheck?`, `onProgress?`. A malformed token throws (shape
144
+ `^sfu_[A-Za-z0-9_-]{43}$`, also `isSessionToken()` and `SESSION_TOKEN_PATTERN`).
145
+ - Before a session exists (static, no token): `register({ email, password, displayName? })`,
146
+ `verifyEmail(linkOrToken)`, `requestPasswordReset(email)`, `confirmPasswordReset({ token, newPassword })`,
147
+ `confirmEmailChange(linkOrToken)` and `login({ email, password, onSessionToken? })` → `{ account, result }`
148
+ (`result.status` `authenticated` or `mfa_required`). Each takes the client options.
149
+ - Token rotation: login, `auth.completeMfa()`, `auth.stepUp()`, `auth.changePassword()`, `auth.totp.confirm()` and
150
+ `auth.totp.disable()` return a new token (the previous one is revoked). `account.sessionToken` always holds the
151
+ current one, every later call (the reused namespaces included) sends it, and `onSessionToken({ token, expiresAt })`
152
+ is called (and awaited) with each.
153
+ - Namespaces: `auth` (`session`, `completeMfa`, `logout`, `logoutAll`, `sessions`, `revokeSession`, `stepUp`,
154
+ `changePassword`, `changeEmail`, `resendVerification`, `totp.enroll|confirm|disable|regenerateRecoveryCodes`),
155
+ `organizations` (`list`, `listAll`, `create`, `get`, `entitlements`, `deletion`, `delete`, `exports.create|get|download`,
156
+ `workspaces`), `projects` (`list`, `listAll`, `create`, `get`), `apiKeys` (`list`, `create`, `revoke`), `members`
157
+ (`list`, `update`, `remove`), `invitations` (`list`, `create`, `revoke`, `accept`), `billing` (`catalog`,
158
+ `subscription`, `checkout`, `checkoutStatus`, `waitForCheckout`, `portal`, `invoices`, `spendPolicy`,
159
+ `setSpendPolicy`), `user` (`deletion`, `scheduleDeletion`, `cancelDeletion`, `exports.create|get|download`),
160
+ `templates` (every `TemplatesApi` method plus `publishVersion` and `archiveVersion`), `audit` (`list`, `listAll`,
161
+ `export` → the CSV or NDJSON text), `usage`, `secrets`, `egress`, `volumes`, `workspaces`, `me()` and `request()`.
162
+ List methods return the API's page `{ data, next_cursor }`.
163
+ - `apiKeys.create(projectId, { name, toolPermissions?, expiresAt?, idempotencyKey? })` sends an Idempotency-Key (a
164
+ fresh one unless given), so a retried request returns the same key and secret instead of creating a second key.
165
+ `toolPermissions` defaults to `[]`; `API_KEY_TOOL_PERMISSIONS` lists every tool.
166
+ - `billing.waitForCheckout(orgId, checkoutId, { timeoutMs?, intervalMs?, signal? })` polls until the subscription is
167
+ active or the checkout expired, was canceled or failed, and returns that status; after `timeoutMs` (default 15 min)
168
+ it throws `CheckoutTimeoutError` (`checkout`, `waitedMs`), on abort the signal's reason.
169
+ - `parseEmailToken(input)`: the `token` of an emailed link's `#token=` fragment or `?token=` query (percent-decoded),
170
+ else the trimmed input; throws for an empty input or a link without a token. Every method that takes an emailed
171
+ token accepts the whole link.
172
+ - Errors are `ShardfluxApiError` as elsewhere; the session adds the codes `step_up_required` (call `auth.stepUp()` and
173
+ retry), `mfa_required` and `email_unverified` (403).
174
+
175
+ ### Version check
176
+
177
+ - `checkClientVersion({ baseUrl?, fetch?, package?, version?, ecosystem?, timeoutMs?, signal? })` →
178
+ `ClientVersionStatus` `{ status, package, ecosystem, current, latest, minimumSupported, upgradeCommand,
179
+ releaseNotesUrl, message? }` from `GET /v1/client-versions`. `status`: `unsupported` (below `minimum_supported`),
180
+ `outdated` (below `latest`), `current`, or `unknown` (no entry, `latest` null, a version that does not parse, or the
181
+ request failed; it never throws). `compareVersions(a, b)` → -1, 0, 1, or null for a string that is not
182
+ `major.minor.patch` (a pre-release sorts before its release).
183
+ - Automatic: after the first successful API response of a `Shardflux` or `ShardfluxAccount` client, once per process
184
+ per package identity, a background request (3 s timeout, every error swallowed; it never delays a call) emits
185
+ `process.emitWarning(message, { type: 'ShardfluxUpdateWarning', code: 'SHARDFLUX_UPDATE_AVAILABLE' })` when the
186
+ package is outdated or unsupported. Option `versionCheck?: boolean | { package, version, ecosystem? }` on both
187
+ clients (default `@shardflux/sdk` at `SDK_VERSION`; tools built on the SDK pass their own identity or `false`).
188
+ Off with `SHARDFLUX_NO_UPDATE_CHECK=1` (also `true`, `yes`, `on`) or `NO_UPDATE_NOTIFIER=1`. `fetchBillingCatalog()`
189
+ does not check.
190
+
191
+ ### Feedback straight to the founder (POST /v1/feedback)
192
+
193
+ - `cloud.sendFeedback({ message, category?, context? })` sends feedback to the Shardflux founder by email and returns
194
+ `{ id, receivedAt, duplicate }`. `category`: `bug`, `confusing`, `missing`, `idea`, `praise` or `other` (default).
195
+ `context`: `agent`, `client`, `workspace`, `requestId`, `errorCode`, `command`, `page` (sent in snake_case);
196
+ `client` defaults to `shardflux-sdk-ts/<SDK_VERSION>`.
197
+ - `duplicate: true`: the same message from the same key within 24 hours; the original is returned and no second email
198
+ is sent. 429 `rate_limited` (with `retryAfterSeconds`) and 422 `validation_failed` are `ShardfluxApiError`s. The call
199
+ is a POST without an idempotency key, so it is never retried.
200
+ - `ShardfluxAccount.sendFeedback({ ..., organizationId? })`: the same with a CLI session, as the signed-in user,
201
+ optionally about one of their organizations (404 `not_found` for another).
202
+ - New exports: `FeedbackCategory`, `FeedbackContext`, `FeedbackReceipt`, `SendFeedbackParams`, `AccountFeedbackParams`,
203
+ `FEEDBACK_CATEGORIES`, `FEEDBACK_MESSAGE_MAX_LENGTH`.
204
+
205
+ ## 0.8.0 (2026-09-28)
206
+
207
+ Types only; nothing changes at run time and the API is unchanged.
208
+
209
+ ### Provider tool exports type-check without casts
210
+
211
+ - `toAnthropicTools(tools)` is assignable to `Anthropic.Tool[]` (`@anthropic-ai/sdk`) and `toOpenAITools(tools, { api:
212
+ 'responses' })` to `OpenAI.Responses.FunctionTool[]` (`openai`) under TypeScript's `strict` checks, with no `as`
213
+ casts. `toOpenAITools(tools)` (and `{ api: 'chat' }`) is assignable to `OpenAI.Chat.ChatCompletionTool[]`.
214
+ - `toOpenAITools` has one return type per format (overloads): an `api` known only at run time still returns either
215
+ array, as before.
216
+ - `JsonSchema` is a type alias instead of an interface, so a schema is assignable to the providers' open schema types
217
+ (`{ [key: string]: unknown }`). An object literal typed as `JsonSchema` still rejects a misspelt keyword.
218
+ - `executeToolCall(tools, call)` takes `input` as `unknown`, as the Anthropic SDK's `ToolUseBlock` types it, so a
219
+ `tool_use` block is passed as it is (no `block.input as Record<string, unknown>`); `execute` validates it as before.
220
+ - New exported types for the three formats: `AnthropicToolDefinition`, `OpenAIChatToolDefinition`,
221
+ `OpenAIResponsesToolDefinition`.
222
+ - Checked at compile time against `@anthropic-ai/sdk` 0.128.0 and `openai` 7.23.0 (devDependencies only; the SDK still
223
+ has no runtime dependencies).
224
+
225
+ ## 0.7.0 (2026-09-28)
7
226
 
8
227
  Needs an API with the template editor (contracts §24); every new field is additive and older fields are unchanged.
9
228
 
package/README.md CHANGED
@@ -10,8 +10,8 @@ tool calls into the workspace ([tool-call capture](#tool-call-capture-070)).
10
10
  > **Early access.** Shardflux is in early access. The API is versioned (`/v1`), but this SDK is
11
11
  > below 1.0: a minor release may contain breaking changes (see [Compatibility](#compatibility)).
12
12
 
13
- > **Versions.** This README describes 0.7.0. Anything marked **(0.7.0+)** is not in 0.6.x and **(0.6.0+)** not in
14
- > 0.5.0; [CHANGELOG.md](./CHANGELOG.md) lists what each version added. Check yours with
13
+ > **Versions.** This README describes 0.9.0. Anything marked **(0.9.0+)** is not in 0.8.x, **(0.8.0+)** not in 0.7.x,
14
+ > **(0.7.0+)** not in 0.6.x and **(0.6.0+)** not in 0.5.0; [CHANGELOG.md](./CHANGELOG.md) lists what each version added. Check yours with
15
15
  > `npm ls @shardflux/sdk` or the exported `SDK_VERSION`.
16
16
 
17
17
  - ESM only, no runtime dependencies, Node.js 24 or later. Reading a YAML template file uses the optional peer
@@ -67,11 +67,12 @@ const cloud = new Shardflux({
67
67
  baseUrl: process.env.SHARDFLUX_API_URL, // optional: default https://api.shardflux.dev
68
68
  timeoutMs: 30_000, // optional: per-request timeout
69
69
  maxRetries: 2, // optional: retries of safe or idempotent requests
70
+ versionCheck: true, // optional (0.9.0+): see Version check
70
71
  });
71
72
  ```
72
73
 
73
- The SDK reads nothing from the environment by itself. `SHARDFLUX_API_KEY` and `SHARDFLUX_API_URL`
74
- are the conventional names (the `shard` CLI reads them); pass them in as shown.
74
+ The SDK reads nothing from the environment by itself (except the version check's opt-out). `SHARDFLUX_API_KEY` and
75
+ `SHARDFLUX_API_URL` are the conventional names (the `shard` CLI reads them); pass them in as shown.
75
76
 
76
77
  ## Commands and files
77
78
 
@@ -100,6 +101,53 @@ await cell.files.remove('/home/user/data.bin');
100
101
  twice. Aborting its `signal` also cancels the command in the workspace. File writes are atomic
101
102
  and durable (acknowledged after fsync).
102
103
 
104
+ ### Search, patch and revisions (0.9.0+)
105
+
106
+ ```ts
107
+ const hits = await cell.files.search('/home/user/project', 'TODO', { include: ['**/*.py'], contextLines: 1 });
108
+ for (const m of hits.matches) console.log(`${m.path}:${m.line}:${m.column}: ${m.text}`);
109
+
110
+ // A file's revision is the SHA-256 of its content.
111
+ const { revision } = await cell.files.stat('/home/user/project/app.py', { revision: true });
112
+ const patched = await cell.files.patch({
113
+ path: '/home/user/project/app.py',
114
+ edits: [{ oldText: 'DEBUG = True', newText: 'DEBUG = False' }], // must occur exactly once (or replaceAll)
115
+ expectedRevision: revision, // refused if the file changed meanwhile
116
+ });
117
+ patched.revision; // the next expectedRevision
118
+
119
+ const { data, revision: current, servedFrom } = await cell.files.readWithInfo('/home/user/project/app.py');
120
+ ```
121
+
122
+ - `search()` searches a directory (or one file) and returns matching lines in path order (`path`, 1-based `line` and
123
+ byte `column`, `text`), stopping at `maxMatches` (default 200), a 10 s budget or 4 MiB of results (`truncated`,
124
+ `stop_reason`). `include`/`exclude` globs are gitignore-style: `*.py` matches a name at any depth, `src/**/*.ts`
125
+ a path relative to the searched directory, `build/` directories only. Binary files, symbolic links, files above
126
+ `maxFileBytes` and `.git`/`node_modules` (unless `exclude` is given) are skipped.
127
+ - `patch()` requests are limited to 7 MiB (413 `payload_too_large`; write larger files with `write()`) and files to
128
+ 64 MiB.
129
+ - `patch()` applies all edits or none, atomically and durably, and always sends an `Idempotency-Key`. `content`
130
+ replaces the whole file instead; `expectedRevision: 'absent'` requires that the file does not exist yet. A changed
131
+ file is 409 `conflict` with `reason` `revision_mismatch` and `details.current_revision`; an edit that does not match
132
+ exactly once is 422 `edit_not_found` or `edit_ambiguous` with `details.index`.
133
+ - A suspended workspace whose disk is still on a host is read, listed and searched there without waking it; such
134
+ results say `servedFrom: 'disk'` (`served_from` on search results). Everything else wakes it as usual. This also
135
+ works for a handle without a tool token from before the suspend: the API issues tokens for suspended workspaces.
136
+ - While the fleet is being upgraded, a workspace may run on a host that predates search and patches: they are 409
137
+ `conflict` with `reason` `host_feature_unavailable` and `details.feature` (`file_search`, `file_patch`), not
138
+ retryable (read and write the file, or run `grep` with `exec`, instead), and revisions are omitted.
139
+
140
+ ### Wake hint (0.9.0+)
141
+
142
+ An idle running workspace may be parked by its host (frozen or hibernated) and is restored by the next tool call.
143
+ `workspace.hint()` tells the host a tool call is coming so the restore starts earlier: call it when your model starts
144
+ emitting a tool call, before its arguments are complete. It is cheap and returns at once; a suspended workspace is
145
+ resumed in the background (`result.wake`). The agent tools below send it when each call starts.
146
+
147
+ ```ts
148
+ void workspace.hint().catch(() => {}); // fire and forget
149
+ ```
150
+
103
151
  The same client has `exec.start/get/output/signal/cancel`, `pty`, `processes`, `git` and
104
152
  `browser` (screenshot and page content).
105
153
 
@@ -153,8 +201,13 @@ never runs twice: the cell executes nothing it refused.
153
201
  - The wait is bounded per call by `transitionTimeoutMs` (default 120 000 ms), shared by the transition waits and at
154
202
  most 3 wakes. A resume or open still pending at the end throws `OperationTimeoutError`, naming the operation, its
155
203
  state and reason. A failed resume or open throws `OperationFailedError` at once.
204
+ - The wake is one request (0.9.0+): the API holds the resume until the workspace runs and answers with the view and a
205
+ tool token for the calling client, so the refused call is retried at once (refused call, resume, the call). Its
206
+ timing is one `request` phase with reason `held`. An API without the held resume answers at once; the SDK then waits
207
+ for the operation, reads the view and fetches a token, as before.
156
208
  - Following an exec's output never wakes a workspace, so an explicit `suspend()` is respected.
157
- - `workspace.wake({ timeoutMs })` does the same on demand.
209
+ - `workspace.wake({ timeoutMs })` does the same on demand (`agentLabel` / `tools` pick the token it brings back).
210
+ `workspace.resume({ wait: true })` is the same single held request: the handle keeps the view and the token.
158
211
  - `workspace.cell({ wake: null })` returns `workspace_not_running` instead.
159
212
 
160
213
  ```ts
@@ -273,6 +326,50 @@ const page = await ws.changes({ pathPrefix: '/home/user', summary: true }); //
273
326
  `legacy_disk_layout`. `changes()` is served by the cell from the running workspace and needs the `files` tool.
274
327
  `cell().changesAll()` follows the pages.
275
328
 
329
+ ## File-first workspaces (0.9.0+)
330
+
331
+ A file-first workspace has no VM between commands. Its state is a versioned file tree under /home/user (tree
332
+ revisions 0, 1, 2, ...). Each command runs as an execution: a fresh VM on the latest revision, whose changed files
333
+ become the next revision. Nothing else survives an execution (processes, memory, files outside /home/user), so install
334
+ dependencies into /home/user (for example a virtualenv) and start servers within the command that uses them. It is
335
+ ready as soon as it is opened and is never suspended.
336
+
337
+ ```ts
338
+ const ws = await cloud.workspaces.open({ key: 'customer-42/repo', template: 'python-node-browser', mode: 'file_first' });
339
+ const cell = ws.cell();
340
+ await cell.files.write('/home/user/app/main.py', 'print("hi")\n', { createParents: true });
341
+
342
+ const r = await ws.executions.run(['bash', '-lc', 'cd app && python3 main.py > out.txt && cat out.txt'], { timeoutMs: 600_000 });
343
+ r.state; // 'succeeded' (any exit code), 'failed' or 'lost' (nothing published; r.errorReason says why)
344
+ r.exitCode; // 0
345
+ r.stdoutText; // 'hi\n' (r.stdout holds the bytes)
346
+ r.changed; // [{ path: '/home/user/app/out.txt', change: 'added', type: 'file' }]
347
+ r.treeRevision; // 2: the revision the execution published (= baseRevision when it changed nothing)
348
+ ws.treeRevision; // 2
349
+
350
+ // Conditional writes: applied only if the tree is still at that revision.
351
+ await cell.files.write('/home/user/app/main.py', 'print("bye")\n', { ifTreeRevision: ws.treeRevision! });
352
+ // ... else TreeRevisionMismatchError (err.currentTreeRevision): read what changed and try again.
353
+ ```
354
+
355
+ - **Executions are idempotent by id.** `executionId` defaults to a fresh `ex-<uuid>`; pass your own to make a call safe
356
+ to repeat across processes. Network failures and retryable 5xx answers (503 `no_execution_host`: no host has room;
357
+ the SDK waits `Retry-After`) are retried with the same id, at most `maxRetries` (5) times, and an answer that takes
358
+ longer than `attemptTimeoutMs` (300 s) is awaited again with the same id. The cell runs a command once per id: a
359
+ repeated call gets the recorded result (`r.replayed`). The SDK never retries with a new id: a `failed` or `lost`
360
+ result is returned, and running it again is your decision.
361
+ - `ws.executions.get(id, { waitMs })` reads an execution: `pending` while it runs, its result when it ended (kept 7
362
+ days). Use it after a call that stopped waiting (an aborted `signal`): an execution cannot be canceled.
363
+ - While an execution runs, other executions and file writes are refused with 409 `workspace_busy`
364
+ (`execution_in_progress`); the SDK waits them out within `transitionTimeoutMs` (120 s).
365
+ - Only paths under /home/user exist (`outside_tree_root` otherwise). Reads, writes, `search()` and `patch()` work as on
366
+ a processful workspace, on the latest revision.
367
+ - Calls a file-first workspace does not have fail with `NotSupportedForModeError` before any request: exec sessions
368
+ (`cell.exec.*`), PTY, processes, git, browser, `changes()`, `suspend`, `resume`, `snapshot`, `fork`, `reset` and
369
+ `saveAsTemplate`. On a processful workspace `executions` and `ifTreeRevision` are refused the same way.
370
+ - Reopening a key with another `mode` is 409 `mode_mismatch`; a deployment without file-first workspaces answers 422
371
+ `mode_not_available`; a legacy template 409 `layout_unsupported`.
372
+
276
373
  ## Templates: file tree, diff and dev mode
277
374
 
278
375
  ```ts
@@ -383,18 +480,38 @@ parameters and an `execute` function. Export them for your model provider and di
383
480
  calls:
384
481
 
385
482
  ```ts
483
+ import Anthropic from '@anthropic-ai/sdk';
386
484
  import { executeToolCall, toAnthropicTools, toOpenAITools, workspaceTools } from '@shardflux/sdk';
387
485
 
388
486
  const tools = workspaceTools(workspace);
389
- const anthropicTools = toAnthropicTools(tools); // or toOpenAITools(tools)
487
+ const anthropicTools: Anthropic.Tool[] = toAnthropicTools(tools);
488
+ const chatTools = toOpenAITools(tools); // OpenAI Chat Completions
489
+ const responsesTools = toOpenAITools(tools, { api: 'responses' }); // OpenAI Responses API
390
490
 
391
- // For each tool call the model makes:
392
- const output = await executeToolCall(tools, { name: call.name, input: call.input });
491
+ // For each tool call the model makes (an Anthropic tool_use block, or an OpenAI function_call item, as it is):
492
+ const output = await executeToolCall(tools, block);
393
493
  ```
394
494
 
495
+ **(0.8.0+)** The exports type-check as the provider SDKs' own types under `strict`, with no casts:
496
+ `Anthropic.Tool[]`, `OpenAI.Chat.ChatCompletionTool[]` and, with `{ api: 'responses' }`,
497
+ `OpenAI.Responses.FunctionTool[]`. `executeToolCall` takes a `tool_use` block's `unknown` input as it is and validates
498
+ it.
499
+
395
500
  Your agent loop and model calls stay in your application; the workspace is the computer the tools
396
501
  act on.
397
502
 
503
+ The tools are `exec`, `read_file`, `write_file`, `list_files`, `search_files` and `edit_file` (0.9.0+), the process,
504
+ terminal, git and browser tools, filtered by the tools your key grants. `edit_file` replaces exact text; when the
505
+ model passes no `expected_revision` it reads the file's revision first, so a change made in between fails the edit
506
+ instead of being overwritten. Each call first sends `workspace.hint()` without waiting for it (`hint: false` turns
507
+ that off, e.g. when you send the hint yourself as the model starts a tool call), except `read_file`, `list_files` and
508
+ `search_files`: a sleeping workspace answers them from its disk without waking.
509
+
510
+ For a file-first workspace (0.9.0+) the tools are `exec` and the files tools only: `exec` runs each command as an
511
+ execution and adds `execution_id`, `state`, `tree_revision` and `changed` to its result, and the process, terminal,
512
+ git and browser tools are not offered. `workspaceTools(ws, { mode })` builds the definitions without touching the
513
+ workspace; `onExecution(id)` is called with each execution id before it is sent.
514
+
398
515
  ## Tool-call capture (0.7.0+)
399
516
 
400
517
  Your harness's tools (web search, SQL, HTTP APIs, MCP servers) run in your application, so their results reach the
@@ -534,6 +651,58 @@ await workspace.cell().exec.run(['python3', 'agent.py']); // sees $OPEN
534
651
  `accessEvents`, `createOrganization` and `listOrganization`. Organization-wide secrets and access
535
652
  logs belong to organization owners and admins, so a project API key gets 403 for those.
536
653
 
654
+ ## Account (ShardfluxAccount) (0.9.0+)
655
+
656
+ `ShardfluxAccount` does what a person does in the web app, with a user session instead of an API key: sign up, sign
657
+ in (with MFA), organizations, projects, API keys, members, invitations, billing, audit, data export and account
658
+ deletion. Two steps stay human: opening the verification email, and paying in Stripe Checkout.
659
+
660
+ ```ts
661
+ import { Shardflux, ShardfluxAccount } from '@shardflux/sdk';
662
+
663
+ // Before a session exists (static; no token needed). Emailed links can be passed whole.
664
+ await ShardfluxAccount.register({ email, password, displayName: 'Ada' }); // { status: 'accepted' }
665
+ await ShardfluxAccount.verifyEmail('https://app.shardflux.dev/auth/verify-email#token=...');
666
+
667
+ const { account, result } = await ShardfluxAccount.login({ email, password, onSessionToken: (s) => save(s.token) });
668
+ if (result.status === 'mfa_required') await account.auth.completeMfa({ code: '123456' }); // or { recoveryCode }
669
+
670
+ // Later, with the saved token (sfu_<43 characters>; a malformed one throws).
671
+ const again = new ShardfluxAccount({ sessionToken: saved, onSessionToken: (s) => save(s.token) });
672
+ const org = await again.organizations.create({ name: 'Acme' });
673
+ const project = await again.projects.create(org.id, { name: 'Default' });
674
+ const { secret } = await again.apiKeys.create(project.id, { name: 'agent', toolPermissions: ['exec', 'files'] });
675
+ const cloud = new Shardflux({ apiKey: secret }); // the sfk_ key, shown once
676
+ ```
677
+
678
+ - The session token is sent as `Authorization: Bearer sfu_...` on `/v1`. Login completion, `auth.stepUp()`,
679
+ `auth.changePassword()`, `auth.totp.confirm()` and `auth.totp.disable()` rotate it (the old token stops working):
680
+ `account.sessionToken` always holds the current token, every later call uses it, and `onSessionToken({ token,
681
+ expiresAt })` is called (and awaited) with each new one. Save it there. Sessions idle out after 30 days.
682
+ - Sensitive calls (exports, deletions, TOTP changes, …) answer 403 `step_up_required` without a recent password
683
+ check: `await account.auth.stepUp({ password, code })`, then retry. A session still waiting for its second factor
684
+ gets 403 `mfa_required`; an unverified email 403 `email_unverified`.
685
+ - Namespaces: `auth` (session, MFA, logout, sessions, step-up, password and email change, `totp`), `organizations`
686
+ (`list`, `listAll`, `create`, `get`, `entitlements`, `deletion`, `delete`, `exports`, `workspaces`), `projects`,
687
+ `apiKeys` (`create` sends an Idempotency-Key, so a retry never makes a second key; `toolPermissions` defaults to
688
+ `[]`), `members`, `invitations` (`accept(linkOrToken)`), `billing`, `user` (account `deletion`, `scheduleDeletion`,
689
+ `cancelDeletion`, `exports`), `templates` (plus `publishVersion` and `archiveVersion`), `audit` (plus `export(orgId,
690
+ { format: 'csv' | 'ndjson', ...filters })`, the text), and `usage`, `secrets`, `egress`, `volumes` with explicit
691
+ organization and project ids; `me()` and `request()`. List methods return the API's page `{ data, next_cursor }`;
692
+ `listAll` iterates every page.
693
+ - `parseEmailToken(input)` returns the `token` of a link's `#token=` fragment or `?token=` query (else the trimmed
694
+ input), and throws for a link without one.
695
+
696
+ Upgrading a plan: a person pays at the Checkout `url`; the code waits for the subscription.
697
+
698
+ ```ts
699
+ const checkout = await account.billing.checkout(org.id, { planKey: 'pro' }); // 409 subscription_exists: use billing.portal()
700
+ console.log(`Pay here: ${checkout.url}`);
701
+ const done = await account.billing.waitForCheckout(org.id, checkout.id, { timeoutMs: 15 * 60_000 });
702
+ if (!done.subscription_active) console.log(`checkout ${done.status}`); // expired, canceled or failed
703
+ // After timeoutMs: CheckoutTimeoutError (err.checkout is the last status); on abort: the signal's reason.
704
+ ```
705
+
537
706
  ## Errors
538
707
 
539
708
  - `ShardfluxApiError`: the API or the workspace refused the request. Fields: `status`, `code`,
@@ -546,22 +715,90 @@ await workspace.cell().exec.run(['python3', 'agent.py']); // sees $OPEN
546
715
  - `OperationTimeoutError`: waiting gave up; the operation continues (`operationId`, `lastState`, `lastReason`,
547
716
  `deadlineAt` **(0.6.2+)** while it waits for capacity, `timing`).
548
717
  - `ShardfluxProtocolError`: a response was not the documented shape.
718
+ - `NotSupportedForModeError` **(0.9.0+)**, a `ShardfluxApiError` (409 `conflict`, `reason` `not_supported_for_mode`):
719
+ the call does not exist for the workspace's `mode`; `local` is true when the SDK refused it without a request.
720
+ - `TreeRevisionMismatchError` **(0.9.0+)**, a `ShardfluxApiError` (409 `conflict`, `reason` `tree_revision_mismatch`):
721
+ an `ifTreeRevision` call found the tree at `currentTreeRevision`; nothing changed.
722
+ - `CheckoutTimeoutError` **(0.9.0+)**: `billing.waitForCheckout()` gave up; the checkout stays open (`checkout`,
723
+ `waitedMs`).
549
724
 
550
725
  Treat unknown error codes and reasons as generic errors: show `message`, and use `retryable`.
551
726
 
727
+ Retryable 429/502/503/504 refusals (for example 503 `host_capacity`, when the workspace's host has no room to restore
728
+ it right now, or `wake_failed`) are retried after `Retry-After` for reads, searches and calls that carry an
729
+ Idempotency-Key (writes and patches); other calls surface them with `retryable: true` and `retryAfterSeconds`.
730
+ A read of a sleeping workspace that its disk cannot answer (409 `workspace_not_running` with `reason`
731
+ `offline_unavailable` or `offline_budget`) wakes the workspace and is retried like any `workspace_not_running`; 503
732
+ `offline_changed` (the disk changed during the read) is retried and served by the running workspace. 409 `conflict`
733
+ `host_feature_unavailable` (the workspace's host predates the call, `details.feature`) is neither retried nor
734
+ woken: it lasts until the workspace runs on an upgraded host.
735
+
736
+ ## Feedback (0.9.0+)
737
+
738
+ `cloud.sendFeedback()` sends a message straight to the Shardflux founder, who reads every one. If you or your coding
739
+ agent hit something while building with Shardflux, send it the moment it happens: a call that failed unexpectedly, an
740
+ error or doc that was confusing, something missing or slow, a workaround you needed. Short and specific beats polished;
741
+ the request id and error code let the founder find the logs.
742
+
743
+ ```ts
744
+ try {
745
+ await cloud.workspaces.open({ key: 'acme/demo', template: 'python-node-browser' });
746
+ } catch (err) {
747
+ if (err instanceof ShardfluxApiError) {
748
+ await cloud.sendFeedback({
749
+ message: 'open failed with capacity_unavailable twice in 10 minutes; expected a start within a minute',
750
+ category: 'bug',
751
+ context: { requestId: err.requestId, errorCode: err.code, workspace: 'acme/demo', agent: 'claude-code' },
752
+ });
753
+ }
754
+ throw err;
755
+ }
756
+ ```
757
+
758
+ - `message`: 1-8000 characters. `category`: `bug` (something failed or behaved wrongly), `confusing` (an error, doc,
759
+ name or output was unclear), `missing` (a capability, option or template you needed), `idea`, `praise` or `other`
760
+ (the default).
761
+ - `context` (all optional): `agent` (who is reporting, e.g. `claude-code`), `workspace`, `requestId`, `errorCode`,
762
+ `command` (the call that led to it), and `client`, which defaults to `shardflux-sdk-ts/<version>`.
763
+ - Returns `{ id, receivedAt, duplicate }`. The same message from the same key within 24 hours returns the original
764
+ with `duplicate: true` and sends no second email.
765
+ - Any API key may send feedback. Signed in with a CLI session instead, `account.sendFeedback({ message, category?,
766
+ context?, organizationId? })` sends it as the user (`ShardfluxAccount`; `organizationId`: one of yours). It is rate
767
+ limited per key or user: a `ShardfluxApiError` with `code: 'rate_limited'` and `retryAfterSeconds`. An empty or too-long message is `validation_failed`. The SDK never retries the call.
768
+ - Anything shaped like an API key is redacted before the message is stored or emailed; still, leave secrets out.
769
+
552
770
  ## More of the API
553
771
 
554
772
  The `Shardflux` object also has `templates` (including custom template builds and the template editor), `volumes`
555
773
  (shared persistent storage attached to workspaces), `secrets` (see above), `egress` (outbound allowlists),
556
- `usage`, `billing`, `me()`, `entitlements(orgId)` and `request(method, path)` for any `/v1`
774
+ `usage`, `billing`, `me()`, `entitlements(orgId)`, `sendFeedback()` and `request(method, path)` for any `/v1`
557
775
  route. The package exports the OpenAPI-generated types as well (`paths`, `components`,
558
776
  `WorkspaceView`, `Operation` and more).
559
777
 
778
+ ## Version check (0.9.0+)
779
+
780
+ After the first successful API response of the process, the SDK asks `GET /v1/client-versions` in the background
781
+ (once per process, 3 s timeout, every error ignored; it never delays or fails a call). When this version is outdated
782
+ or no longer supported, it emits one warning:
783
+
784
+ ```
785
+ (node:1234) [SHARDFLUX_UPDATE_AVAILABLE] ShardfluxUpdateWarning: @shardflux/sdk 0.9.0 is outdated: 0.10.0 is available. Update: npm install @shardflux/sdk@latest
786
+ ```
787
+
788
+ - Turn it off with `versionCheck: false` (on `Shardflux` or `ShardfluxAccount`), `SHARDFLUX_NO_UPDATE_CHECK=1` (also
789
+ `true`, `yes`, `on`) or `NO_UPDATE_NOTIFIER=1`. Handle it with `process.on('warning', (w) => ...)`
790
+ (`w.name === 'ShardfluxUpdateWarning'`).
791
+ - A tool built on the SDK checks its own package instead: `versionCheck: { package: '@acme/tool', version: '1.2.3' }`.
792
+ - On demand: `await checkClientVersion()` returns `{ status, package, ecosystem, current, latest, minimumSupported,
793
+ upgradeCommand, releaseNotesUrl, message? }` with `status` `current`, `outdated`, `unsupported` or `unknown` (the
794
+ request failed, or the package is not listed yet). `compareVersions(a, b)` compares two `major.minor.patch` versions
795
+ (a pre-release sorts first; `null` when one does not parse).
796
+
560
797
  ## Compatibility
561
798
 
562
799
  - The SDK follows the API's `/v1` contract. New fields, enum values and error codes can appear in
563
800
  any release; ignore unknown fields.
564
- - While below 1.0, a breaking change bumps the minor version (0.6 to 0.7).
801
+ - While below 1.0, a breaking change bumps the minor version (0.7 to 0.8).
565
802
  - `SDK_VERSION` is exported; requests send `User-Agent: shardflux-sdk-ts/<version>`.
566
803
  - Examples in this README, in `examples/` and on shardflux.dev name the version they need. The examples on the
567
804
  website and in the console are checked against the version published on npm before they ship.