foldrun 2.0.0 → 2.2.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/docs/api-usage.md +14 -5
- package/docs/api.md +5 -5
- package/docs/assistant.md +1 -1
- package/docs/budgets-and-billing.md +29 -0
- package/docs/cli.md +4 -4
- package/docs/getting-started.md +5 -2
- package/docs/providers.md +8 -2
- package/docs/runs.md +5 -4
- package/docs/tools.md +34 -1
- package/docs/versions.md +4 -0
- package/package.json +3 -3
- package/src/commands.mjs +99 -11
package/docs/api-usage.md
CHANGED
|
@@ -29,11 +29,15 @@ Foldrun-Version: 2026-10-01
|
|
|
29
29
|
|
|
30
30
|
Every response says which version answered it, in `Foldrun-Version`.
|
|
31
31
|
|
|
32
|
-
- **No header**: your account's pin (**Settings → API**, or
|
|
32
|
+
- **No header**: for an API key, your account's pin (**Settings → API**, or
|
|
33
33
|
`PATCH /api/account/api {"version": "2026-10-01"}`), and with no pin, the
|
|
34
|
-
current version.
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
current version. The dashboard always uses the current version: it ships
|
|
35
|
+
with the platform, so a pin never applies to it.
|
|
36
|
+
- **An unknown version** — a typo, a date that was never released, one
|
|
37
|
+
retired after its support window — is a `400`, not a silent fallback to
|
|
38
|
+
current. The body says why in a form code can act on:
|
|
39
|
+
`{ "error": "…", "code": "unknown_api_version", "known": ["2026-10-01"] }`
|
|
40
|
+
(`code` is `bad_api_version` when the header is not a `YYYY-MM-DD` date).
|
|
37
41
|
- **What moves the version**: only a change an existing caller would break
|
|
38
42
|
on — a field removed or renamed, a type changed, a status code changed. A
|
|
39
43
|
new route, a new optional field or a new value in a list does not; write
|
|
@@ -42,7 +46,12 @@ Every response says which version answered it, in `Foldrun-Version`.
|
|
|
42
46
|
pinned before it gets the response transformed back to the shape its
|
|
43
47
|
version promised.
|
|
44
48
|
|
|
45
|
-
The CLI sends the version it was built for on every call.
|
|
49
|
+
The CLI sends the version it was built for on every call. When a platform
|
|
50
|
+
refuses it, every command stops with exit 1 and says which side is older:
|
|
51
|
+
a platform older than the CLI names the CLI released with it
|
|
52
|
+
(`npm i -g foldrun@<that version>`); a platform that has retired the CLI's
|
|
53
|
+
version says to update (`npm i -g foldrun@latest`). `foldrun version` still
|
|
54
|
+
shows the platform, and warns when its commands would be refused.
|
|
46
55
|
|
|
47
56
|
### Deprecation policy
|
|
48
57
|
|
package/docs/api.md
CHANGED
|
@@ -190,7 +190,7 @@ paying for the steps around it.
|
|
|
190
190
|
|
|
191
191
|
| Route | Methods | |
|
|
192
192
|
|---|---|---|
|
|
193
|
-
| `/api/workspaces/<ws>/runs?limit=` | GET | `{ runs: [{ id, flow, status, startedAt, finishedAt, ... }] }` — off the run index (see [Runs](runs#the-runs-list)); a finished run's steps carry `agent`, `status` and `
|
|
193
|
+
| `/api/workspaces/<ws>/runs?limit=` | GET | `{ runs: [{ id, flow, status, startedAt, finishedAt, ... }] }` — off the run index (see [Runs](runs#the-runs-list)); a finished run's steps carry `agent`, `status`, `costUsd` and `tokens` (`{ input, output, cacheRead, cacheWrite }`, see [Runs](runs#the-record)), and the run its summed `tokens` — not their reply or trace |
|
|
194
194
|
| `/api/workspaces/<ws>/runs/<id>` | GET, DELETE | the whole run: every step, its output, cost, timing, the runner `image` it ran on and — for a step that browsed through the account's pod from slim — its `browserPod` reconnects and fallback ([runs](runs#the-record)). `?wait=true&timeout=<s>` instead blocks until the run stops (or `timeout`, default 300, max 900) and answers the verdict the run routes give — `200` completed, `500` failed with the record, `202` still going (`timedOut`) or parked on a person (`awaitingApproval`). Ask in short pieces: whatever sits in front of the platform cuts a request that stays silent too long (Cloudflare at 100 s, an AWS ALB at 60 s), and a flow can run for many minutes — the CLI's `--wait` asks 25 s at a time and comes back here on every `202` |
|
|
195
195
|
| `/api/workspaces/<ws>/runs/<id>/stream` | GET | server-sent events, live |
|
|
196
196
|
| `/api/workspaces/<ws>/runs/<id>/stop` | POST | destroys the sandbox, not just the record |
|
|
@@ -206,7 +206,7 @@ paying for the steps around it.
|
|
|
206
206
|
| `/api/workspaces/<ws>/notify/deliveries` | GET | the outbound webhook log: every `notify:` url delivery from this workspace, newest first — `{ deliveries, total, limit, offset, endpoint }`. A delivery is `{ id, event, status, destination, attempts, attemptLog, lastStatusCode, lastError, nextAttemptAt, createdAt, updatedAt, body }`: `id` is the `X-Foldrun-Delivery` header; `status` is `pending` (a retry is due at `nextAttemptAt`), `delivered`, `failed` (gave up after six attempts) or `skipped` (the endpoint was switched off); each attempt is `{ n, at, statusCode, durationMs, response (first 500 characters), error, manual? }`. `body` is what was sent, except that a gate's `approveUrl`/`rejectUrl` (top level and in `links`) read `[redacted]` — they decide without a login, and this log is readable by every role; the receiver still gets them, on a redelivery too. `?status=`, `?event=`, `?limit=` (50, at most 200), `?offset=`. `endpoint` is `{ failingSince, lastSuccessAt, lastFailureAt, disabledAt, disabledReason }` or null. Kept 30 days |
|
|
207
207
|
| `/api/workspaces/<ws>/notify/deliveries/<id>/redeliver` | POST | send one delivery again now — the same id and body, signed afresh, to the URL `notify:` names today. `{ ok, attempt, delivery }`, always `200` when the delivery exists (whether the receiver took it is the answer); `404` when it does not. An accepted redelivery switches a disabled endpoint back on. Needs the permission that runs |
|
|
208
208
|
| `/api/workspaces/<ws>/notify/test` | POST | sends one real notification to whatever this workspace's `notify:` resolves to, and answers `{ ok, destination, detail }` — including the provider's own words when it failed, which are what name the problem. Always `200`: the request was handled, and the result is the answer. Needs the permission that runs, because it sends real mail |
|
|
209
|
-
| `/api/workspaces/<ws>/observe?days=` | GET | the observability report the Observe page renders — per-agent failures and
|
|
209
|
+
| `/api/workspaces/<ws>/observe?days=` | GET | the observability report the Observe page renders — per-agent failures, retries, tokens, `cacheHitRate`, `turns` and `systemPromptChars` (each `{ n, p50, p95, max }`), per-tool calls/errors/latency and `resultSize` (characters per result, same shape), `resultChars` (their sum) and `heavyCalls` (results over 10,000 characters), per-flow duration, day series, recent failures, and `startup` — one row per start-up phase in order, `{ phase, took, at }`, each `{ n, p50, p95, max }` in seconds (`took` is the phase's own share, `at` when it ended counted from the step's start; percentiles need five samples). `days` 1–365, default 30 (`since=`, its older name, still works — deprecated) |
|
|
210
210
|
| `/api/workspaces/<ws>/history?path=&id=&limit=` | GET | every change to the workspace, newest first — `path` narrows to one file, `id` returns one revision with its before/after and a line diff, `limit` 1–500, default 100. A deploy's revision id is its commit |
|
|
211
211
|
| `/api/workspaces/<ws>/restore` | POST | put the workspace's source back as it was at a point in its history. `to`: a revision id (`r-…`), the commit a deploy named (or its prefix), an ISO date/time (the last revision at or before it) or an age (`90m`, `12h`, `3d`, `2w`) — every revision since 17 Oct 2026 holds a snapshot of the whole source. `{ to, dryRun: true }` answers `{ to: { id, sha, at, by, message }, from, added, updated, removed, files: [{ path, change, diff }], blockedBy, noop, issues, warnings }` and writes nothing; `issues` are the deploy checks that would refuse that tree, `warnings` the ones it would deploy with. The restore needs `confirm` set to the workspace's name (400 without) and takes the dry run's `expectRemoved`; it deploys that revision's snapshot as a new revision (undo: restore to `from`; `to.sha` is the same id as `to.id`, kept from when revisions were git commits), keeps runs, `state/`, storage, secrets and agent-written memory, and is refused 409 while runs are in flight or when it would remove something not in `expectRemoved`. `workspace:write`; audited as `workspace.restored` |
|
|
212
212
|
|
|
@@ -332,13 +332,13 @@ Reads return which secrets exist, never their values.
|
|
|
332
332
|
| `/api/usage/history?days=` | GET | charged spend by flow, by agent and by workspace over time, read from the ledger rather than the run records — so it survives runs being pruned and reports what was billed rather than what was consumed. Buckets by week over three weeks, by day under. Each series carries its periods, its total, and the change between the last two COMPLETE periods, so a partial week never reads as a collapse. `unattributedUsd` is charges from before a line recorded its flow. A caller scoped to some workspaces sees only the charges in those; `days` 1–365, default 56 |
|
|
333
333
|
| `/api/account/model` | GET, PUT, DELETE | the account's own model key — what every step runs on unless an agent, workspace or the account's AGENTS.md names a `provider:`. GET: `{ set, provider, baseUrl, format, keyHint, runsOn, platformKeyAllowed }` — never the key, only its last four characters; `runsOn` is `account` (its own key), `platform` (the platform's credential, for the operator's accounts) or `none` (runs are refused until a key is set). PUT `{ provider, apiKey, baseUrl?, format? }` (needs `secrets:write`): `provider` is a preset name (anthropic, openrouter, openai, groq, … — see providers) or `custom` with an https `baseUrl` and a `format` (anthropic or openai); a Claude login token (sk-ant-oat…) is refused for any account not allowed the platform's credential. Stored as the account secrets `FOLDRUN_MODEL_PROVIDER`, `FOLDRUN_MODEL_API_KEY`, `FOLDRUN_MODEL_BASE_URL`, `FOLDRUN_MODEL_FORMAT`. DELETE removes them. Honours Idempotency-Key on PUT; audited as model.key_set / model.key_removed |
|
|
334
334
|
| `/api/account/providers` | GET, POST | GET: the model providers this account's files actually use — each one's preset name, where it is declared, the model a check would send — plus the last daily health check. POST forces one now, which spends one minimal request at each distinct endpoint, and needs the permission that runs |
|
|
335
|
-
| `/api/usage` | GET | what has been spent, by meter. A key or member scoped to some workspaces sees those workspaces, and totals over them; the ledger figures are the account's, as the wallet's are |
|
|
335
|
+
| `/api/usage` | GET | what has been spent, by meter; each bucket's tokens are `inputTokens` (all input), `outputTokens`, `cacheReadTokens` and `cacheWriteTokens` (the part of the input the prompt cache served and stored), and `cacheHitRate` — reads over `measuredInputTokens`, the input of steps that report the split, `null` when none do. A key or member scoped to some workspaces sees those workspaces, and totals over them; the ledger figures are the account's, as the wallet's are |
|
|
336
336
|
| `/api/approvals` | GET | every gate waiting on a person, across workspaces — one entry per waiting **step** (`workspace`, `runId`, `flow`, `step`, `agent`, `question`, `asked`, `waitingSince`), oldest first. By step status, not run status: a decided gate leaves the list at once, before the worker resumes the run. `wait: event` steps are not listed; no button answers them. Beside it, `questions`: every question an agent is asking mid-step in a running run (`kind: "question"`, `id`, `workspace`, `runId`, `flow`, `agent`, `question`, `options?`, `askedAt`) — answered with `…/runs/<id>/answer` |
|
|
337
337
|
| `/api/account/export` | GET | the owner's copy of what the platform holds about the account, as one JSON file (`foldrun-<account>-export.json`): `workspaces` (with their flows), `members` (email, role, scope, since), `runs` (an index — id, workspace, flow, status, times, headline) and the whole `ledger` (each line's charge; the platform's provider cost is left out). `billing:manage`, so the owner; audited as `account.exported`. The markdown itself comes from `foldrun pull` or the source API |
|
|
338
338
|
| `/api/account/close` | POST | the owner asks to close the account — in person, never a key, with `confirm` set to the account's name and an optional `reason`. Not a delete: the account is suspended at once (runs in flight finish, sign-in and the export still work), `closeRequested` is set on its admin state, and the super admin is mailed to settle the last invoice and any refund. Returns `{ ok, suspended, notified }`; 409 when already asked. Hosted installs only |
|
|
339
339
|
| `/api/account/backups` | GET | what is backed up for this account: `policy` (`schedule`, `keepLocal`, `keepOffsiteDays`, `encrypted`, `covers`, `excludes`), `last`, `backups` (each nightly archive that holds this account, newest first: `id`, `at`, `bytes` (the encrypted object), `sha256` (the archive inside the encryption — what a decrypt must reproduce), `kinds` — files, database, keys — `verified`, `offsite`, `keepDays`, `encrypted` (sealed with age before it left the server; false for archives taken before October 2026), `recipient` (the key's fingerprint, `sha256:<16 hex>`, or null)), `requests` (earlier restore requests) and `workspaces` (`name`, `head`, `history`, `lastChange`) — what a restore can reach. See [Backups and restore](backups-and-restore.md) |
|
|
340
340
|
| `/api/account/backups/requests` | GET, POST | ask the platform team to restore what an account cannot restore itself. POST `{ what, target, workspace?, backupId?, note? }` (`what` is runs, state, storage or everything) records the request, mails the operator and audits it as `backup.restore_requested`; answers 201 `{ ok, request }` with `notified` saying whether the mail went. Restores nothing. `workspace:manage`, and a member scoped to some workspaces must name one. GET lists this account's requests |
|
|
341
|
-
| `/api/account/api` | GET, PATCH | how this account meets the API — GET `{ version: { current, pinned, effective, supported, supportMonths }, limits: { plan, windowSeconds, perCredential: { read, write }, perAccount: { read, write } }, usage }`. `usage` is the last 30 days — `{ days, versions: [{ version, requests, lastSeen }], deprecated: [{ route, requests, lastSeen, deprecatedAt, sunsetAt, link, callers: [{ credential, requests, lastSeen }] }] }`, `credential` naming a key by its label and prefix — or `null` when it could not be read; PATCH `{ version: "YYYY-MM-DD" \| null }` pins the version requests without a `Foldrun-Version` header get (null: always current). `keys:manage`. See [api-usage](api-usage.md) |
|
|
341
|
+
| `/api/account/api` | GET, PATCH | how this account meets the API — GET `{ version: { current, pinned, effective, supported, supportMonths }, limits: { plan, windowSeconds, perCredential: { read, write }, perAccount: { read, write } }, usage }`. `usage` is the last 30 days — `{ days, versions: [{ version, requests, lastSeen }], deprecated: [{ route, requests, lastSeen, deprecatedAt, sunsetAt, link, callers: [{ credential, requests, lastSeen }] }] }`, `credential` naming a key by its label and prefix — or `null` when it could not be read; PATCH `{ version: "YYYY-MM-DD" \| null }` pins the version API-key requests without a `Foldrun-Version` header get (null: always current); the dashboard always gets current. `keys:manage`. See [api-usage](api-usage.md) |
|
|
342
342
|
| `/api/account/flags` | GET | the feature flags as they stand for this account — `{ flags: [{ name, description, owner, default, on, source, rollout }] }`, `source` being what decided it: `account` (an override for this account), `global` (the install-wide setting), `rollout` (a percentage of accounts, by a stable hash of account and flag; `rollout` is the percentage) or `default`. Read-only, every role; only the super admin changes a flag. See [Feature flags](feature-flags.md) |
|
|
343
343
|
| `/api/openapi.json` | GET | this API as an OpenAPI 3.1 document — every route and method, bearer auth, the rate-limit, version and Idempotency-Key headers, and request/response schemas for runs, flow runs, deploy, source, secrets, keys and billing. No key needed. Generated from the route files by `scripts/gen-openapi.mjs`; the dashboard renders it at Docs → API reference, `foldrun api spec` saves it |
|
|
344
344
|
| `/api/account` | GET, PATCH | the account's defaults from its AGENTS.md frontmatter — `timezone`, `budget` (a USD cap — a number for a month, `"60/day"`, `"60/week"`, or `{ usd, period }`; null is no limit), `notify` (`{ url?, email?, events }`); PATCH sets the keys sent, null clears one, and edits the file in place |
|
|
@@ -443,7 +443,7 @@ signed-in person, never by a key — mints an ordinary API key at their role.
|
|
|
443
443
|
| `/api/cli/login/<id>` | GET | `{ status }` — `pending`, `denied`, `expired`, or `approved` with `token`, `account`, `email`, `role`. The key is returned once; the request is gone after |
|
|
444
444
|
| `/api/cli/authorize` | GET, POST | `?code=` describes the request (machine, expiry). POST `{ code, decision }` from a session approves (mints the key, labelled `cli · <hostname>`) or denies — `decision` is `approve` or `deny`, and absent approves; any other word is a `400`. Every role may sign its own CLI in; an owner's key is an admin's |
|
|
445
445
|
| `/api/me` | GET | who is calling: `account`, `owner`, `role`, `workspaces` (null: all), and the `actor` — a person's email, or a key's id, label, prefix and who minted it |
|
|
446
|
-
| `/api/me/onboarding` | GET, PATCH | the account's getting-started steps, each derived from what is really there — `{ steps: [{ id, title, why, href, cli?, done }], done, total, complete, next, dismissed, dismissedAt }`. Steps: `workspace`, `agent` (an agent written or deployed after the workspace was made), `run`, `success` (a completed run), `secret`, `trigger` (a schedule or webhook), `teammate` (a second member), `billing` (hosted, not fully waived: a saved card or a plan), `cli` (any API key or CLI login). PATCH `{ dismissed: true or false }` hides or brings back the dashboard card for the signed-in person (kept in their prefs); a key reads `dismissed: false` and cannot PATCH |
|
|
446
|
+
| `/api/me/onboarding` | GET, PATCH | the account's getting-started steps, each derived from what is really there — `{ steps: [{ id, title, why, href, cli?, done }], done, total, complete, next, dismissed, dismissedAt }`. Steps: `model` (first; only for an account that must bring its own key — done once it has one, or a `provider:` block in the account's AGENTS.md), `workspace`, `agent` (an agent written or deployed after the workspace was made), `run`, `success` (a completed run), `secret`, `trigger` (a schedule or webhook), `teammate` (a second member), `billing` (hosted, not fully waived: a saved card or a plan), `cli` (any API key or CLI login). PATCH `{ dismissed: true or false }` hides or brings back the dashboard card for the signed-in person (kept in their prefs); a key reads `dismissed: false` and cannot PATCH |
|
|
447
447
|
| `/api/me/notifications` | GET, PATCH | what mail you get. GET `{ email, categories: [{ category, label, description, required, why?, perWorkspace, enabled, workspaces: { <ws>: bool } }] }` for `security`, `invites`, `billing`, `account` (required — always sent) and `run-alerts`, `approvals` (per workspace), `low-balance`, `product-updates`. PATCH `{ category, enabled, workspace? }` or `{ preferences: [ … ] }`; answers the same as GET. Turning a required category off is a `409` with the reason; `workspace` on an account-wide category is a `400`; `enabled: true` account-wide clears that category's per-workspace offs. A key acts for the person who minted it |
|
|
448
448
|
| `/api/me/preferences` | GET, PATCH | your own display preferences: `{ theme }` — `system` (the default: follow the computer's light or dark setting), `light` or `dark`. PATCH `{ theme }` answers the same; anything else is a `400`. Kept in your prefs, so it follows you to every browser; the dashboard applies it on the server, before the first paint. A key acts for the person who minted it; a key minted by nobody is a `400` |
|
|
449
449
|
| `/api/unsubscribe?token=` | POST | one-click unsubscribe (RFC 8058) — no login, the signed token is the credential. It is the `List-Unsubscribe` URL on every optional mail, beside `List-Unsubscribe-Post: List-Unsubscribe=One-Click`; a mail client POSTs `List-Unsubscribe=One-Click` to it. Turns off the one category (for the one workspace) the token was minted for, for its address. The token may also come as a form field (`token`, and `from=page` to be sent back 303 to the `/unsubscribe` confirmation page) or JSON. `200 { ok, category, workspace }`; `400` no token, `401` forged, `410` expired (180 days; `FOLDRUN_UNSUBSCRIBE_TTL_DAYS`). Never on a GET — `/unsubscribe?token=` is the page that asks first |
|
package/docs/assistant.md
CHANGED
|
@@ -121,7 +121,7 @@ a run's tokens, as one line on [usage](budgets-and-billing), flow
|
|
|
121
121
|
`ai-assistant`. On your own provider or key, that provider bills you and
|
|
122
122
|
foldrun charges nothing.
|
|
123
123
|
|
|
124
|
-
Under every reply is what it used: tokens in (cache
|
|
124
|
+
Under every reply is what it used: tokens in (with the share the prompt cache served) and out,
|
|
125
125
|
and what it was charged, or "on your own model key". One reply is capped at
|
|
126
126
|
$2, 30 tool calls and five minutes. Each person or key can send 60 replies an
|
|
127
127
|
hour, and when the platform's models answer, an account can send 300 a day.
|
|
@@ -143,6 +143,35 @@ having more, smaller steps. The Usage page shows every table in credits at
|
|
|
143
143
|
today's rates beside the meters, so a customer can read what a run will
|
|
144
144
|
cost from what the last one consumed.
|
|
145
145
|
|
|
146
|
+
## Prompt caching
|
|
147
|
+
|
|
148
|
+
Every step's model calls are cached by prefix, on their own: the agent's
|
|
149
|
+
tools and system prompt, then the conversation so far. Each turn of a step
|
|
150
|
+
re-reads what the turn before sent, and steps of the same agent in an
|
|
151
|
+
`each:` fan-out or a retry share the same opening. Nothing has to be
|
|
152
|
+
switched on.
|
|
153
|
+
|
|
154
|
+
A read from the cache is priced at a tenth of fresh input, and a write at a
|
|
155
|
+
quarter more; a step's cost uses those rates, both against its `budget:` and
|
|
156
|
+
on the ledger. The record keeps the counts apart (`tokens.cacheRead`,
|
|
157
|
+
`tokens.cacheWrite`, see [Runs](runs#the-record)), and the run page, the
|
|
158
|
+
Usage and Observe pages, `foldrun report`, `foldrun usage` and
|
|
159
|
+
`foldrun observe` show the **cache hit rate**: cache reads over all input,
|
|
160
|
+
counted over the steps that report the split (from 18 Oct 2026).
|
|
161
|
+
|
|
162
|
+
The cache keeps a prefix for an hour on a Claude subscription and five
|
|
163
|
+
minutes on an API key. A low rate usually means one of:
|
|
164
|
+
|
|
165
|
+
- the agent's prose changes from run to run — keep per-item data in the
|
|
166
|
+
step's instruction, not in `agent.md` or `AGENTS.md`;
|
|
167
|
+
- re-reads further apart than the cache lives — a `wait:` or a slow
|
|
168
|
+
fan-out on an API key;
|
|
169
|
+
- a short agent with few turns, where there is little to re-read.
|
|
170
|
+
|
|
171
|
+
Providers on the OpenAI format (`provider:` with `format: openai` or
|
|
172
|
+
`responses`) cache on their own side; the share they report comes back as
|
|
173
|
+
`cacheRead` too.
|
|
174
|
+
|
|
146
175
|
## What each desk costs
|
|
147
176
|
|
|
148
177
|
The Usage page's per-workspace tables are rolled up from the run records,
|
package/docs/cli.md
CHANGED
|
@@ -77,7 +77,7 @@ claude.ai or Claude Code there, nor any MCP configuration on disk.
|
|
|
77
77
|
| `foldrun logs [run-id]` | recent runs in one workspace, or one run's whole event trail. `--local` reads this machine's store even when signed in; `foldrun runs --local` is the same command and prints the same thing |
|
|
78
78
|
| `foldrun runs` | what has run lately across the whole account: when, workspace, flow, status, steps done, how long, what it cost and the run's own one-line summary. A completed run shows its verdict beside the status — `GOOD`, `BAD`, `QUIET`, or `BLOCKED` in red with a ⛔ mark. `--status failed`, `--verdict blocked`, `--since 24h`, `--to <workspace>`, `--limit <n>`. `--local` prints exactly what `logs --local` prints — this machine's runs, one workspace |
|
|
79
79
|
| `foldrun runs rm <run-id>` | erase a run from history: its record and its archived outputs. A run still in flight is stopped first. The ledger line stays — money is not deleted because a trace was. Asks first; `--yes` with no terminal |
|
|
80
|
-
| `foldrun report <run-id>` | one run, whole, without opening the dashboard: the header, then every step with its group, agent, attempts, the runner image it ran on (`slim · browser pod` when it browsed through the account's pod from slim), cost, duration, what happened to the browser pod under it (reconnects, `browser pod lost; re-ran on full` with its tries — `slim · lost` then `full` — or why it was not re-run), whether its `verify:` held and the first line of what it said — and for a failed step, the error and the last events before it. Then what it wrote, what it published and what is still waiting on a person. `--json` prints the run record instead |
|
|
80
|
+
| `foldrun report <run-id>` | one run, whole, without opening the dashboard: the header, then every step with its group, agent, attempts, the runner image it ran on (`slim · browser pod` when it browsed through the account's pod from slim), cost, how much of its input the prompt cache served (`90% cached`; nothing for a step recorded before 18 Oct 2026), how many model turns it took, duration, what happened to the browser pod under it (reconnects, `browser pod lost; re-ran on full` with its tries — `slim · lost` then `full` — or why it was not re-run), whether its `verify:` held and the first line of what it said — and for a failed step, the error and the last events before it. Then what it wrote, what it published and what is still waiting on a person. `--json` prints the run record instead |
|
|
81
81
|
| `foldrun report <run-id> get <agent>/<path>` | download one file the run archived — whatever that agent left in `outputs/`, copied under the run when it ended. Writes it beside you under its own name; `--file <path>` names somewhere else (`-` for stdout), `--force` overwrites |
|
|
82
82
|
| `foldrun report <run-id> live` | the run page's live browser view: which agents' browsers have posted a frame, and when; `--agent <name>` saves the newest frame as a JPEG (`--file <path>`, `-` for stdout) and prints the page's address, the engine and whether the browser has closed (`GET …/runs/<id>/live`) |
|
|
83
83
|
| `foldrun approvals` | every gate waiting on a person, anywhere in the account: which workspace, flow and run, which step and agent, the question, how long it has been waiting and which files under `storage/` the gate previews — and, first, every question an agent is asking mid-step, with its choices. `--to <workspace>` narrows it |
|
|
@@ -114,7 +114,7 @@ claude.ai or Claude Code there, nor any MCP configuration on disk.
|
|
|
114
114
|
| `foldrun logout` | forget this machine's key, and revoke it if `login` made it |
|
|
115
115
|
| `foldrun whoami` | who you are on the platform: account, role, which workspaces |
|
|
116
116
|
| `foldrun team` | who is in the account (`GET /api/team`): each member's email, role (the owner marked), the workspaces they may open — a scoped key sees only the ones it shares — and when they joined. `--json`. Read-only on purpose: inviting, changing a role, removing someone and transferring the account are a signed-in person's, in Settings → Team, and the platform refuses them from a key |
|
|
117
|
-
| `foldrun onboarding` | the account's getting-started steps — workspace, agent, a run, a run that completes, a secret, a schedule or webhook, a teammate, billing where the plan asks for it, the CLI — each worked out from what is really there, with the next one first and where to do it (`--json`). The dashboard card is hidden per person from the dashboard |
|
|
117
|
+
| `foldrun onboarding` | the account's getting-started steps — the model key (where the account brings its own), workspace, agent, a run, a run that completes, a secret, a schedule or webhook, a teammate, billing where the plan asks for it, the CLI — each worked out from what is really there, with the next one first and where to do it (`--json`). The dashboard card is hidden per person from the dashboard |
|
|
118
118
|
| `foldrun preferences` | your own settings on the platform — today the theme (`system`, `light`, `dark`), kept with you on every browser; `preferences set theme dark` changes it (`/api/me/preferences`; a key acts for whoever minted it) |
|
|
119
119
|
| `foldrun api spec` | the platform's OpenAPI 3.1 document, from `GET /api/openapi.json` (no key needed) — to stdout, or `--out <file>`. Every other command sends `Foldrun-Version` (the API version this CLI was built for), an `Idempotency-Key` on each write (reused on its own retry), and waits out one `429` for `Retry-After` before saying so. See [Using the API](api-usage) |
|
|
120
120
|
| `foldrun api version` | Settings → API: the API version this account's requests get when they send no `Foldrun-Version`, whether it is pinned, every version still served, the rate limits, and the last 30 days: requests by version, and any deprecated route the account still calls — by which key or person, and when it stops answering. `api version pin <YYYY-MM-DD>` pins it, `unpin` goes back to current (`PATCH /api/account/api`; an admin's) |
|
|
@@ -127,8 +127,8 @@ claude.ai or Claude Code there, nor any MCP configuration on disk.
|
|
|
127
127
|
| `foldrun schedule tick` | Settings' **Run scheduler now**: fire whatever is due this minute and print which flows went (`POST /api/schedule`). They were due anyway; this only makes them go now |
|
|
128
128
|
| `foldrun rerun <run-id>` | the same flow again, from a step: `--from <n>` as the flow file numbers them, or `--agent <name>` for the first step that agent runs. Earlier steps are recorded as skipped. This is the debug loop from the terminal: fix the file, `deploy`, `rerun --from` the step that failed. `--wait` follows the new run like `invoke --wait`; `--to <workspace>` from anywhere. With no run id and a filter (`--status failed`, `--since 24h`, `--until 2h`, `--flow <name>`) it reruns every match from `--from <n>` (default 1), listing and asking first as a bulk `stop` does; `--dry-run` lists only |
|
|
129
129
|
| `foldrun triggers` | why nothing ran. One row per flow that fired in the window: its trigger, how many fires became runs, when it last ran, and every reason for the difference with a count — a duplicate delivery, a throttled or debounced burst, a fire the platform slept through, an `overlap: skip`, a flow switched off by `disable_after:`. A switched-off flow is a non-zero exit; one successful run clears it. `--since <days>` (default 7), `--to <workspace>` |
|
|
130
|
-
| `foldrun observe` | where the account fails, retries and spends: one row per workspace — runs, failed and what share, steps, cost — then the recent failures in their own words. `--to <workspace>` prints that workspace in full, as the Observe page does: per flow (runs, failed, p50/p95 duration, cost), per agent (failed, retried, skipped, p95), per tool (calls, errors, p95 latency, which agents use it), and start-up — each phase of a step's first seconds, its own share and when it was done, p50/p95. `--since <days>` (default 30), `--json` for the document |
|
|
131
|
-
| `foldrun usage` | what the account consumed — runs, steps, tokens, compute, storage, per workspace — and what it was charged over `--days <n>` (default 56) from the ledger, per workspace with the change against the period before. `--json` for both documents |
|
|
130
|
+
| `foldrun observe` | where the account fails, retries and spends: one row per workspace — runs, failed and what share, steps, cost — then the recent failures in their own words. `--to <workspace>` prints that workspace in full, as the Observe page does: per flow (runs, failed, p50/p95 duration, cost), per agent (failed, retried, skipped, p95, turns, cache hit rate), per tool (calls, errors, p95 latency, result size p50/p95 and how many results were over 10k characters, which agents use it), and start-up — each phase of a step's first seconds, its own share and when it was done, p50/p95. `--since <days>` (default 30), `--json` for the document |
|
|
131
|
+
| `foldrun usage` | what the account consumed — runs, steps, tokens (with the share the prompt cache served), compute, storage, per workspace — and what it was charged over `--days <n>` (default 56) from the ledger, per workspace with the change against the period before. `--json` for both documents |
|
|
132
132
|
| `foldrun billing` | the account's balance, whether billing is on at all, what the platform is currently waiving, and the recent ledger entries with what each was for — a run names its workspace, flow and run id; an adjustment carries its own note. `--limit <n>` shows more. `statement [YYYY-MM]` is a month of the ledger (this month by default) — `--csv` for the file an accountant wants, to stdout or `--file <path>`. `wallet` is the burn, the runway and auto top-up; `wallet set auto-top-up --threshold <usd> --amount <usd>` turns it on (a refill of 5–500), `set auto-top-up off` turns it off, `set email <address>` is where receipts go. `details` is who invoices are made out to; `details set --name "…" --abn … --address "line1, city, state, postcode, AU" --email …` changes it, merged with what is there. `portal` prints a link to Stripe's billing portal. Changing the wallet, the details, and the portal are the account owner's; anyone else is told so |
|
|
133
133
|
| `foldrun billing plans` | the Plans page: every plan with its monthly fee, credits, runs at once and workspaces, which one the account is on and this cycle's credits left (`GET /api/billing/plans`). `billing plan <id>` chooses one — a first plan answers with Stripe's checkout page, a change on a live plan is prorated at Stripe at once (asks first; `--yes`); `plan cancel` stops it at the end of the cycle, `plan resume` undoes that until then (`POST`/`DELETE /api/billing/subscribe`) |
|
|
134
134
|
| `foldrun billing top-up <usd>` | the Payment page's top-up: Stripe's checkout page for that amount, opened in the browser (`--no-browser`, or no terminal, prints it). The credit lands when Stripe confirms the payment. `billing card` is the same for saving a card for auto top-up. Both need `billing:manage` |
|
package/docs/getting-started.md
CHANGED
|
@@ -118,14 +118,17 @@ written into the workspace, so a workspace is always safe to commit.
|
|
|
118
118
|
|
|
119
119
|
## The checklist on the dashboard
|
|
120
120
|
|
|
121
|
-
A new account's home page shows a **Getting started** card:
|
|
121
|
+
A new account's home page shows a **Getting started** card: add your model
|
|
122
|
+
key (on a hosted platform, where the account brings its own), create a
|
|
122
123
|
workspace, write or deploy an agent, run a flow, get a run to finish, add a
|
|
123
124
|
secret, schedule a flow or give it a webhook, invite a teammate, set up
|
|
124
125
|
billing (where the plan asks for it) and install the CLI — "4 of 8", with the
|
|
125
126
|
next step first and a link to where to do it. Each step is worked out from
|
|
126
127
|
the account as it is (a run on record, a secret in the vault, a key minted
|
|
127
128
|
for the CLI), so it ticks itself whichever way you did the thing; there is
|
|
128
|
-
nothing to mark.
|
|
129
|
+
nothing to mark. While an account has no model key every run is refused, so
|
|
130
|
+
every dashboard page also carries a banner saying so, with a link to
|
|
131
|
+
Settings → Model provider (`foldrun account model set anthropic --key …`). **Dismiss** hides it for you alone; the line at the foot of
|
|
129
132
|
the page brings it back, and it goes away by itself once every step is done.
|
|
130
133
|
|
|
131
134
|
```sh
|
package/docs/providers.md
CHANGED
|
@@ -18,7 +18,10 @@ and the API show only its last four characters.
|
|
|
18
18
|
own model credential is for the operator's accounts
|
|
19
19
|
(`FOLDRUN_PLATFORM_KEY_ACCOUNTS`); a step in any other account with no key
|
|
20
20
|
of its own is refused before it starts — "no model key: …" on the run — and
|
|
21
|
-
an eval's judge or "draft with AI" says the same.
|
|
21
|
+
an eval's judge or "draft with AI" says the same. Until it has one, every
|
|
22
|
+
dashboard page says so in a banner, and **Add your model key** is the first
|
|
23
|
+
step of [getting started](getting-started.md#the-checklist-on-the-dashboard).
|
|
24
|
+
A `provider:` block in the account's AGENTS.md counts as its key. A **Claude login token**
|
|
22
25
|
(`sk-ant-oat…`, from `claude setup-token`) is refused as a customer's key:
|
|
23
26
|
Anthropic's terms do not let a third-party product run its customers on
|
|
24
27
|
claude.ai logins. Use an API key from the provider.
|
|
@@ -35,7 +38,10 @@ checked against each provider's own documentation on 2026-09-02 and again on
|
|
|
35
38
|
endpoints (Chat Completions) and `responses` endpoints (OpenAI's newer
|
|
36
39
|
Responses API) are reached through the runtime's own translator, a loopback
|
|
37
40
|
server inside the run sandbox that lives for one step — the same server,
|
|
38
|
-
a second pair of mappings.
|
|
41
|
+
a second pair of mappings. An eval's `judge:` and "draft with AI" go through
|
|
42
|
+
the same translator, started for that one call, so they work on any key a
|
|
43
|
+
run works on. The dashboard's assistant does not: it needs an
|
|
44
|
+
`anthropic`-format provider or the platform's models. No preset defaults to `responses`; a block asks
|
|
39
45
|
for it by name (`name: openai` + `format: responses`). **Verified**
|
|
40
46
|
means a tool loop was actually driven through the endpoint from this
|
|
41
47
|
runtime. A name without it is **documented, not proven**: the address, wire
|
package/docs/runs.md
CHANGED
|
@@ -15,12 +15,13 @@ Each run holds its steps in group order. A step carries:
|
|
|
15
15
|
| `result` | everything the agent wrote, every turn joined |
|
|
16
16
|
| `conclusion` | the final turn alone — what it concluded |
|
|
17
17
|
| `data` | the parsed value, for a step with `output: json` |
|
|
18
|
-
| `events` | tool calls, script runs with their timing, verify verdicts, refusals |
|
|
19
|
-
| `
|
|
18
|
+
| `events` | tool calls, script runs with their timing, verify verdicts, refusals. A tool call is a pair of events, the call and its completion; the completion carries `ms` and, from 18 Oct 2026, `bytes` — the result's length in characters, which every later turn sends again |
|
|
19
|
+
| `turns`, `systemPromptChars`, `stopReason` | model turns, summed across attempts; the system prompt's length in characters (it rides on every turn); why the last turn ended. With the events' `bytes`, these say where a step's input tokens went. From 18 Oct 2026 |
|
|
20
|
+
| `costUsd`, `tokens`, `computeSecs`, `startupSecs` | what it cost and how long the sandbox lived — **summed across its attempts**. `tokens` is `{ input, output, cacheRead, cacheWrite }`: `input` is all input, and `cacheRead` and `cacheWrite` the part of it the [prompt cache](budgets-and-billing#prompt-caching) served and stored. Steps recorded before 18 Oct 2026 have only `input` and `output` |
|
|
20
21
|
| `startup` | where the step's first seconds went: when each start-up phase ended, in seconds since the step started — `services` (the account's search/browser pods up), `room` (room on the cluster to start — see [Budgets and billing](budgets-and-billing)), `applied` (sandbox created), `ready`, `files` (workspace copied in), `go` (step released), `shim`, `system` (system packages restored), `driver`, `imports`, `tools` (runtime and tools built) and `model` (the model's first text or tool call). Only the phases the step went through; `null` for a step that ran in-process, absent before 17 Oct 2026. The last attempt's |
|
|
21
22
|
| `image` | which runner image the step's sandbox ran (isolated runs on a cluster): `variant` `full` or `slim`, `why`, and `pod: true` when a browsing step ran slim and browsed through the account's browser pod ([slim steps and a lost pod](tools#slim-steps-and-a-lost-pod)) |
|
|
22
23
|
| `browserPod` | on a step that browsed through the pod from slim: `reconnects` (tries), `reconnected` (how many got through: the call went on after it) and `closedAgain` (how many reached the pod only for the call to drop again at once, when any did); when the pod was lost, `lost` (`cause` `lost` or `needs-full`, and `detail`) and `writes` (what the step had written by then, worded); then `fallback: "browser pod lost; re-ran on full"` when it had only read and ran again on the full image, or `failure` — `browser pod died after the step had written <what>` — when it had written and was not re-run |
|
|
23
|
-
| `tries` | one row per attempt, oldest first (and a `status: "lost"` row, `image: "slim"`, before the full re-run when the browser pod was lost under a slim step that had only read — see [tools](tools#slim-steps-and-a-lost-pod)): `n` (the try, numbered in the order they ran), `attempt` (the step's attempt it belongs to, only when that is not `n` — the full re-run after a lost slim try 1 is `n: 2, attempt: 1`), `status`, `costUsd`, `tokens`, `computeSecs`, `startedAt`, `finishedAt`, the attempt's `image` and its `browserPod` line when it had one, and the attempt's last `error` when it failed. A `retry: 2` step that failed twice and then passed has three rows, and its `costUsd` is all three — the run's spend, the flow's `budget:` and the bill read the step's figure, so the figure is the total. A retry is handed what is left of the budget after the failed attempt spent, and nothing left means no retry, said on the record |
|
|
24
|
+
| `tries` | one row per attempt, oldest first (and a `status: "lost"` row, `image: "slim"`, before the full re-run when the browser pod was lost under a slim step that had only read — see [tools](tools#slim-steps-and-a-lost-pod)): `n` (the try, numbered in the order they ran), `attempt` (the step's attempt it belongs to, only when that is not `n` — the full re-run after a lost slim try 1 is `n: 2, attempt: 1`), `status`, `costUsd`, `tokens`, `turns`, `computeSecs`, `startedAt`, `finishedAt`, the attempt's `image` and its `browserPod` line when it had one, and the attempt's last `error` when it failed. A `retry: 2` step that failed twice and then passed has three rows, and its `costUsd` is all three — the run's spend, the flow's `budget:` and the bill read the step's figure, so the figure is the total. A retry is handed what is left of the budget after the failed attempt spent, and nothing left means no retry, said on the record |
|
|
24
25
|
| `item` | for a fan-out instance, which item this one handled |
|
|
25
26
|
|
|
26
27
|
The run's `summary` is the **first meaningful line of the last step's
|
|
@@ -56,7 +57,7 @@ The page follows the run as it moves, gates included, without a reload: a
|
|
|
56
57
|
reconnects), and the browser tab reads the state and the step — `● running ·
|
|
57
58
|
step 3/7` — so you can switch away and still see where it is. Only the step
|
|
58
59
|
that is happening is open; a finished step folds to one line with its
|
|
59
|
-
headline, duration, tokens and cost, and the next step opens as it starts.
|
|
60
|
+
headline, duration, tokens (and the share the prompt cache served) and cost, and the next step opens as it starts.
|
|
60
61
|
Click a step to pin it open or shut, or use **expand all** / **collapse
|
|
61
62
|
all** above the column. A **json** link opens the run record exactly as the
|
|
62
63
|
runner wrote it.
|
package/docs/tools.md
CHANGED
|
@@ -312,6 +312,13 @@ the tool:
|
|
|
312
312
|
| a list of pages | a flow step with **`each:`** — the step runs once per URL, in parallel |
|
|
313
313
|
| a whole site | `web action=map` (every URL) or `web action=crawl` (page by page) over plain HTTP; `web action=browse mode=map` / `mode=crawl` when the site needs JavaScript |
|
|
314
314
|
|
|
315
|
+
**How much of a page comes back.** Each page is cut at 8,000 characters, with a
|
|
316
|
+
closing line saying how many more there were. A page an agent has read stays in
|
|
317
|
+
its conversation and is sent to the model again on every turn after, so a whole
|
|
318
|
+
article read for one fact is paid for many times over. `max_output=` sets the
|
|
319
|
+
cut per page, from 200 to 20,000. `save=` writes every character to a file the
|
|
320
|
+
agent can read in parts.
|
|
321
|
+
|
|
315
322
|
So a search that returns twenty links becomes twenty reads by adding one line
|
|
316
323
|
to the flow, not by passing twenty URLs to one call:
|
|
317
324
|
|
|
@@ -849,7 +856,7 @@ exactly as it always did.
|
|
|
849
856
|
| `ignore_https_errors` | `true` opens a page whose certificate does not check out |
|
|
850
857
|
| `cpu_profile` | a `.cpuprofile` path — where the page's JavaScript spent its time, for DevTools |
|
|
851
858
|
| `state_key` | the NAME of a secret whose value encrypts the `state` login at rest (AES-256-GCM) |
|
|
852
|
-
| `max_output` | return at most this many characters, 200 to 18,000 — never more than the tool's own cap |
|
|
859
|
+
| `max_output` | return at most this many characters, 200 to 18,000 — never more than the tool's own cap. `fetch` takes it too, per page: 200 to 20,000, default 8,000 |
|
|
853
860
|
| `against` | with `mode=diff`: another page's URL to compare this one with now — staging and production, a page and its translation — as markdown, or pixels with `visual=true` |
|
|
854
861
|
|
|
855
862
|
**Modes** — `text` (default), `markdown`, `html`, `aria` read the page;
|
|
@@ -1713,6 +1720,32 @@ real is in the sandbox for it. The gallery's `web action=search` does, because i
|
|
|
1713
1720
|
needs no secret at all; the `web action=browse` does not, because a cookie has to be
|
|
1714
1721
|
seeded into a real browser. See [Secrets](secrets#where-the-value-actually-is).
|
|
1715
1722
|
|
|
1723
|
+
## Long results
|
|
1724
|
+
|
|
1725
|
+
A tool's result stays in the step's conversation and is sent to the model
|
|
1726
|
+
again on every later turn, so a long one is paid for many times over. A
|
|
1727
|
+
script or HTTP tool's result longer than 12,000 characters (about 3,000
|
|
1728
|
+
tokens) is therefore kept whole in a file under the agent's
|
|
1729
|
+
`.cache/results/`, and the model gets its first 12,000 characters, cut at a
|
|
1730
|
+
line break, and the file's path:
|
|
1731
|
+
|
|
1732
|
+
```text
|
|
1733
|
+
…(41800 more characters not shown — the whole result, 53800 characters, is in `.cache/results/crm-get-mgx1k2-a1b2c3.txt`. Read it with offset/limit, or Grep it, if you need what is past this point.)
|
|
1734
|
+
```
|
|
1735
|
+
|
|
1736
|
+
This applies to an agent that can read the file back — one granted Read or
|
|
1737
|
+
Grep (the `read` group, or `web`, which brings Read). An agent granted only
|
|
1738
|
+
`apis:` or `scripts:` has no tool to open it, so it gets the whole result up
|
|
1739
|
+
to 20,000 characters, as before.
|
|
1740
|
+
|
|
1741
|
+
Nothing is lost: until 18 Oct 2026 these results were cut at 20,000
|
|
1742
|
+
characters and the rest dropped. The file lives for the step — `.cache/` is
|
|
1743
|
+
never copied back from a run's sandbox and never deployed. The built-in
|
|
1744
|
+
tools (Read, Grep, Bash, WebFetch) keep the limits Claude Code gives them,
|
|
1745
|
+
and the web tool's own `max_output=` still decides how much of a page it
|
|
1746
|
+
returns. The Observe page's **Result size** column says which tools return
|
|
1747
|
+
the most, so a tool can be made to return less.
|
|
1748
|
+
|
|
1716
1749
|
## Limits — how many calls a step may make
|
|
1717
1750
|
|
|
1718
1751
|
`limits:` on an agent caps how many times one step may call a tool. A call
|
package/docs/versions.md
CHANGED
|
@@ -137,5 +137,9 @@ newest commands. Each platform version records the CLI released with it
|
|
|
137
137
|
platform's CLI is newer — at most once a day, only in a terminal, and
|
|
138
138
|
never in a script or CI. `FOLDRUN_NO_UPDATE_NOTICE=1` turns it off.
|
|
139
139
|
- `foldrun --version` prints the CLI's version and nothing else, offline.
|
|
140
|
+
- A CLI newer than its platform — one built for an API version the platform
|
|
141
|
+
does not answer yet — is refused, and says so: every command stops with
|
|
142
|
+
the CLI version to install instead. See
|
|
143
|
+
[API versions](api-usage#versions).
|
|
140
144
|
|
|
141
145
|
To update: `npm i -g foldrun@latest`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "foldrun",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Agents are just folders. Write them, check them, run them — on your machine.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"type": "module",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"node": ">=22"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@foldrun/core": "^2.
|
|
14
|
+
"@foldrun/core": "^2.3.0"
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
17
|
"bin",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
],
|
|
22
22
|
"scripts": {
|
|
23
23
|
"e2e": "FOLDRUN_E2E=1 FOLDRUN_DATA=$(mktemp -d /tmp/foldrun-e2e-XXXXXX) node --test --test-timeout=300000 tests/e2e.test.ts",
|
|
24
|
-
"test": "FOLDRUN_DATA=$(mktemp -d /tmp/foldrun-test-XXXXXX) node --test tests/cli-account.test.ts tests/model-credential.test.ts tests/cli-approvals.test.ts tests/cli-stop.test.ts tests/cli-account-settings.test.ts tests/cli-schedule.test.ts tests/cli-billing.test.ts tests/cli-storage.test.ts tests/cli-scaffold.test.ts tests/cli-secrets.test.ts tests/cli-site-login.test.ts tests/cli-check-library.test.ts tests/cli-check-timezone.test.ts tests/cli-check-frontmatter.test.ts tests/cli-agent-import.test.ts tests/cli-check-subagents.test.ts tests/cli-connect.test.ts tests/cli-accounts.test.ts tests/cli-remote.test.ts tests/cli-stream.test.ts tests/cli-doctor.test.ts tests/cli-tool-test.test.ts tests/cli-agent-run.test.ts tests/cli-check-remote.test.ts tests/cli-shares.test.ts tests/cli-triggers.test.ts tests/cli-providers.test.ts tests/cli-rerun-put.test.ts tests/cli-guide.test.ts tests/cli-runtimes.test.ts tests/cli-deploy-safety.test.ts tests/cli-operator.test.ts tests/cli-flow-patterns.test.ts tests/cli-flow-rm-step.test.ts tests/cli-flow-dup-step.test.ts tests/cli-flow-write-stale.test.ts tests/cli-agent-link-stale.test.ts tests/cli-parity.test.ts tests/cli-invoke-inputs.test.ts tests/cli-audit-keys.test.ts tests/cli-version.test.ts tests/cli-check-web-warnings.test.ts tests/cli-report-image.test.ts tests/cli-check-workspace-paths.test.ts tests/cli-parity-dashboard.test.ts tests/cli-api-standards.test.ts tests/cli-backups-onboarding.test.ts tests/cli-check-limits.test.ts tests/cli-gallery.test.ts tests/cli-status-platform.test.ts tests/cli-webhooks-notifications.test.ts tests/cli-flags.test.ts tests/cli-api-coverage.test.ts tests/cli-hooks.test.ts tests/cli-release.test.ts tests/cli-terminal.test.ts tests/cli-network.test.ts tests/cli-assistant.test.ts tests/cli-check-scan.test.ts tests/cli-published.test.ts",
|
|
24
|
+
"test": "FOLDRUN_DATA=$(mktemp -d /tmp/foldrun-test-XXXXXX) node --test tests/cli-account.test.ts tests/model-credential.test.ts tests/cli-approvals.test.ts tests/cli-stop.test.ts tests/cli-account-settings.test.ts tests/cli-schedule.test.ts tests/cli-billing.test.ts tests/cli-storage.test.ts tests/cli-scaffold.test.ts tests/cli-secrets.test.ts tests/cli-site-login.test.ts tests/cli-check-library.test.ts tests/cli-check-timezone.test.ts tests/cli-check-frontmatter.test.ts tests/cli-agent-import.test.ts tests/cli-check-subagents.test.ts tests/cli-connect.test.ts tests/cli-accounts.test.ts tests/cli-remote.test.ts tests/cli-stream.test.ts tests/cli-doctor.test.ts tests/cli-tool-test.test.ts tests/cli-agent-run.test.ts tests/cli-check-remote.test.ts tests/cli-shares.test.ts tests/cli-triggers.test.ts tests/cli-providers.test.ts tests/cli-rerun-put.test.ts tests/cli-guide.test.ts tests/cli-runtimes.test.ts tests/cli-deploy-safety.test.ts tests/cli-operator.test.ts tests/cli-flow-patterns.test.ts tests/cli-flow-rm-step.test.ts tests/cli-flow-dup-step.test.ts tests/cli-flow-write-stale.test.ts tests/cli-agent-link-stale.test.ts tests/cli-parity.test.ts tests/cli-invoke-inputs.test.ts tests/cli-audit-keys.test.ts tests/cli-version.test.ts tests/cli-check-web-warnings.test.ts tests/cli-report-image.test.ts tests/cli-report-cache.test.ts tests/cli-check-workspace-paths.test.ts tests/cli-parity-dashboard.test.ts tests/cli-api-standards.test.ts tests/cli-backups-onboarding.test.ts tests/cli-check-limits.test.ts tests/cli-gallery.test.ts tests/cli-status-platform.test.ts tests/cli-webhooks-notifications.test.ts tests/cli-flags.test.ts tests/cli-api-coverage.test.ts tests/cli-hooks.test.ts tests/cli-release.test.ts tests/cli-terminal.test.ts tests/cli-network.test.ts tests/cli-assistant.test.ts tests/cli-check-scan.test.ts tests/cli-published.test.ts",
|
|
25
25
|
"release": "node scripts/release.mjs",
|
|
26
26
|
"typecheck": "tsc -p tsconfig.json",
|
|
27
27
|
"docs:sync": "node scripts/sync-docs.mjs",
|
package/src/commands.mjs
CHANGED
|
@@ -2270,9 +2270,44 @@ async function remoteFetch(url, apiPath, init = {}, { token, seconds: given } =
|
|
|
2270
2270
|
res = await attempt();
|
|
2271
2271
|
}
|
|
2272
2272
|
updateNotice(res);
|
|
2273
|
+
const refused = await versionRefusal(res, target);
|
|
2274
|
+
if (refused) throw new Error(refused);
|
|
2273
2275
|
return res;
|
|
2274
2276
|
}
|
|
2275
2277
|
|
|
2278
|
+
/**
|
|
2279
|
+
* A 400 that refuses this CLI's Foldrun-Version, as what to do about it —
|
|
2280
|
+
* null for any other answer. Left to each command, the refusal read as an
|
|
2281
|
+
* answer: `foldrun workspaces` printed "no workspaces" against a platform
|
|
2282
|
+
* older than the CLI (FOL-41, 18 Oct 2026). Platforms from before then send
|
|
2283
|
+
* no `code`, so their sentence is read instead.
|
|
2284
|
+
* @param {Response} res @param {URL} target
|
|
2285
|
+
*/
|
|
2286
|
+
export async function versionRefusal(res, target) {
|
|
2287
|
+
if (res.status !== 400) return null;
|
|
2288
|
+
/** @type {any} */
|
|
2289
|
+
let body;
|
|
2290
|
+
try {
|
|
2291
|
+
body = await res.clone().json();
|
|
2292
|
+
} catch {
|
|
2293
|
+
return null;
|
|
2294
|
+
}
|
|
2295
|
+
const text = typeof body?.error === "string" ? body.error : "";
|
|
2296
|
+
const code =
|
|
2297
|
+
body?.code ?? (/^unknown API version /.test(text) ? "unknown_api_version" : /^Foldrun-Version must be a date/.test(text) ? "bad_api_version" : null);
|
|
2298
|
+
if (code !== "unknown_api_version" && code !== "bad_api_version") return null;
|
|
2299
|
+
const known = (Array.isArray(body.known) ? body.known.map(String) : (/this server knows (.+)$/.exec(text)?.[1] ?? "").split(/,\s*/))
|
|
2300
|
+
.filter((v) => /^\d{4}-\d{2}-\d{2}$/.test(v))
|
|
2301
|
+
.sort();
|
|
2302
|
+
const newest = known.at(-1);
|
|
2303
|
+
const theirs = String(res.headers.get("x-foldrun-cli") ?? "").trim();
|
|
2304
|
+
const cli = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(theirs) ? theirs : null;
|
|
2305
|
+
if (newest && API_VERSION > newest) {
|
|
2306
|
+
return `${target.host} answers API versions up to ${newest}, and this CLI (foldrun ${CLI_VERSION}) speaks ${API_VERSION} — the platform is older than the CLI. Use the CLI released with it: npm i -g foldrun@${cli ?? "<the platform's version>"}`;
|
|
2307
|
+
}
|
|
2308
|
+
return `${target.host} no longer answers API ${API_VERSION}, which this CLI (foldrun ${CLI_VERSION}) speaks${newest ? ` (it answers ${known.join(", ")})` : ""} — update it: npm i -g foldrun@latest`;
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2276
2311
|
// ---------------------------------------------------------------- versions
|
|
2277
2312
|
|
|
2278
2313
|
/**
|
|
@@ -2385,7 +2420,9 @@ async function versionCmd(flags) {
|
|
|
2385
2420
|
if (url) {
|
|
2386
2421
|
try {
|
|
2387
2422
|
const token = flags.token ?? env("FOLDRUN_TOKEN") ?? chosenProfile(flags)?.token ?? undefined;
|
|
2388
|
-
|
|
2423
|
+
// No Foldrun-Version: /api/version describes the platform, and a CLI the
|
|
2424
|
+
// platform would refuse still needs to see it — the mismatch is said below.
|
|
2425
|
+
const res = await remoteFetch(url, "/api/version", { headers: { "foldrun-version": "" } }, { token, seconds: timeoutSeconds(flags) });
|
|
2389
2426
|
if (!res.ok) throw new Error(`/api/version → HTTP ${res.status}`);
|
|
2390
2427
|
platform = { url, .../** @type {Record<string, any>} */ (await res.json()) };
|
|
2391
2428
|
} catch (err) {
|
|
@@ -2413,6 +2450,11 @@ async function versionCmd(flags) {
|
|
|
2413
2450
|
console.log(` platform ${c.bold(String(platform.version ?? "?"))} ${c.dim(url)}`);
|
|
2414
2451
|
if (platform.released_at) console.log(` released ${platform.released_at}`);
|
|
2415
2452
|
if (platform.api) console.log(` api ${platform.api}`);
|
|
2453
|
+
// A platform whose newest API is older than the one this CLI speaks refuses
|
|
2454
|
+
// every other command; say so here, where someone looks first.
|
|
2455
|
+
if (typeof platform.api === "string" && /^\d{4}-\d{2}-\d{2}$/.test(platform.api) && platform.api < API_VERSION) {
|
|
2456
|
+
console.log(` ${c.amber(`this CLI speaks API ${API_VERSION}, newer than the platform's — its commands will be refused. npm i -g foldrun@${latestCli ?? "<the platform's version>"}`)}`);
|
|
2457
|
+
}
|
|
2416
2458
|
if (platform.build) console.log(` build ${platform.build}`);
|
|
2417
2459
|
// The engines its runner image was built with (Chrome and Lightpanda are
|
|
2418
2460
|
// best-effort on some arches), so `engine: chrome` is not a guess.
|
|
@@ -4881,6 +4923,10 @@ async function workspacesCmd(positional, flags, layout) {
|
|
|
4881
4923
|
);
|
|
4882
4924
|
return reach ? 1 : 0;
|
|
4883
4925
|
}
|
|
4926
|
+
// Nothing here and the platform could not be read is not "no workspaces":
|
|
4927
|
+
// that once answered a refused API version, a bad key and a down platform
|
|
4928
|
+
// alike, with exit 0 (FOL-41).
|
|
4929
|
+
if (!all.length && reach) throw new Error(`could not read the platform's workspaces: ${reach}`);
|
|
4884
4930
|
if (!all.length) {
|
|
4885
4931
|
console.log(`\n ${c.dim("no workspaces — `foldrun new <name>` makes one")}\n`);
|
|
4886
4932
|
return 0;
|
|
@@ -6292,6 +6338,36 @@ const stepError = (step) => step.error ?? [...(step.tries ?? [])].reverse().find
|
|
|
6292
6338
|
* before it. Then the three things a person goes looking for afterwards:
|
|
6293
6339
|
* what it wrote, what it published, and what is still waiting on them.
|
|
6294
6340
|
*/
|
|
6341
|
+
/** The share of a step's input the prompt cache served. `input` is all of
|
|
6342
|
+
* it; records from before 18 Oct 2026 have no cache fields, so the share
|
|
6343
|
+
* is unknown (null), not zero. */
|
|
6344
|
+
const cachedShare = (t) => (t && t.cacheRead !== undefined && t.input > 0 ? t.cacheRead / t.input : null);
|
|
6345
|
+
/** Over the steps that report the split only, as the Usage page counts it. */
|
|
6346
|
+
function measuredShare(list) {
|
|
6347
|
+
let read = 0;
|
|
6348
|
+
let all = 0;
|
|
6349
|
+
for (const t of list) {
|
|
6350
|
+
if (!t || t.cacheRead === undefined) continue;
|
|
6351
|
+
read += t.cacheRead;
|
|
6352
|
+
all += t.input ?? 0;
|
|
6353
|
+
}
|
|
6354
|
+
return all > 0 ? read / all : null;
|
|
6355
|
+
}
|
|
6356
|
+
const cachedLabel = (rate) => (rate == null ? null : `${Math.round(rate * 100)}% cached`);
|
|
6357
|
+
function sumTokens(list) {
|
|
6358
|
+
const out = { input: 0, output: 0 };
|
|
6359
|
+
for (const t of list) {
|
|
6360
|
+
if (!t) continue;
|
|
6361
|
+
out.input += t.input ?? 0;
|
|
6362
|
+
out.output += t.output ?? 0;
|
|
6363
|
+
if (t.cacheRead !== undefined || t.cacheWrite !== undefined) {
|
|
6364
|
+
out.cacheRead = (out.cacheRead ?? 0) + (t.cacheRead ?? 0);
|
|
6365
|
+
out.cacheWrite = (out.cacheWrite ?? 0) + (t.cacheWrite ?? 0);
|
|
6366
|
+
}
|
|
6367
|
+
}
|
|
6368
|
+
return out;
|
|
6369
|
+
}
|
|
6370
|
+
|
|
6295
6371
|
async function reportCmd(runId, flags, layout) {
|
|
6296
6372
|
const url = platformFor(flags, "report");
|
|
6297
6373
|
if (!runId) throw new Error("which run? `foldrun report <run-id>` — `foldrun runs` lists them");
|
|
@@ -6303,10 +6379,7 @@ async function reportCmd(runId, flags, layout) {
|
|
|
6303
6379
|
return run.status === "failed" ? 1 : 0;
|
|
6304
6380
|
}
|
|
6305
6381
|
|
|
6306
|
-
const tokens = (run.steps ?? []).
|
|
6307
|
-
(t, s) => ({ input: t.input + (s.tokens?.input ?? 0), output: t.output + (s.tokens?.output ?? 0) }),
|
|
6308
|
-
{ input: 0, output: 0 },
|
|
6309
|
-
);
|
|
6382
|
+
const tokens = sumTokens((run.steps ?? []).map((s) => s.tokens));
|
|
6310
6383
|
const cost = runCost(run);
|
|
6311
6384
|
|
|
6312
6385
|
const blocked = run.status === "completed" && verdictOf(run) === "BLOCKED";
|
|
@@ -6314,7 +6387,8 @@ async function reportCmd(runId, flags, layout) {
|
|
|
6314
6387
|
console.log(` ${c.dim(`${ws} · ${run.status}`)}${verdictTag(run) ? ` ${verdictTag(run)}` : ""}${c.dim(run.test ? " · test run" : "")}`);
|
|
6315
6388
|
console.log(` ${c.dim(`started ${when(run.startedAt)} (${ago(run.startedAt)} ago) · ${runDuration(run)} · $${cost.toFixed(4)}`)}`);
|
|
6316
6389
|
if (tokens.input || tokens.output) {
|
|
6317
|
-
|
|
6390
|
+
const cached = cachedLabel(measuredShare((run.steps ?? []).map((s) => s.tokens)));
|
|
6391
|
+
console.log(` ${c.dim(`${tokens.input.toLocaleString()} in / ${tokens.output.toLocaleString()} out tokens${cached ? ` · ${cached}` : ""}`)}`);
|
|
6318
6392
|
}
|
|
6319
6393
|
if (run.budgetUsd) console.log(` ${c.dim(`budget $${Number(run.budgetUsd).toFixed(2)}`)}`);
|
|
6320
6394
|
if (run.summary) {
|
|
@@ -6330,6 +6404,8 @@ async function reportCmd(runId, flags, layout) {
|
|
|
6330
6404
|
imageLabel(step),
|
|
6331
6405
|
startupLabel(step),
|
|
6332
6406
|
step.costUsd ? `$${step.costUsd.toFixed(4)}` : null,
|
|
6407
|
+
cachedLabel(cachedShare(step.tokens)),
|
|
6408
|
+
typeof step.turns === "number" ? `${step.turns} turn${step.turns === 1 ? "" : "s"}` : null,
|
|
6333
6409
|
step.startedAt && step.finishedAt
|
|
6334
6410
|
? humanDuration(Date.parse(step.finishedAt) - Date.parse(step.startedAt))
|
|
6335
6411
|
: null,
|
|
@@ -8196,6 +8272,15 @@ const secs = (n) => (n == null ? "—" : humanDuration(n * 1000));
|
|
|
8196
8272
|
const fine = (n) => (n == null ? "—" : n < 1 ? `${Math.round(n * 1000)}ms` : `${n.toFixed(2)}s`);
|
|
8197
8273
|
const pct = (a, b) => (b ? `${Math.round((a / b) * 100)}%` : "—");
|
|
8198
8274
|
|
|
8275
|
+
/** Characters as a reader sizes them: 52k, 1.2M. */
|
|
8276
|
+
const kchars = (v) => (v >= 1_000_000 ? `${(v / 1_000_000).toFixed(1)}M` : v >= 1000 ? `${Math.round(v / 1000)}k` : String(v));
|
|
8277
|
+
/** p50/p95, or the worst case when there are too few samples, as the Observe page shows it. */
|
|
8278
|
+
const spreadOf = (s, unit) => {
|
|
8279
|
+
if (!s || !s.n) return "—";
|
|
8280
|
+
if (s.p50 == null) return `≤${unit(s.max)} (n=${s.n})`;
|
|
8281
|
+
return `${unit(s.p50)}/${unit(s.p95)}`;
|
|
8282
|
+
};
|
|
8283
|
+
|
|
8199
8284
|
/** One workspace's observability report, as the Observe page reads it. */
|
|
8200
8285
|
function printObservation(o) {
|
|
8201
8286
|
console.log(`\n ${c.bold(o.workspace)} ${c.dim(`last ${o.sinceDays} days`)}`);
|
|
@@ -8208,8 +8293,8 @@ function printObservation(o) {
|
|
|
8208
8293
|
for (const r of rows) console.log(` ${r.map((x, i) => (i === 0 ? c.bold(pad(x, w[i])) : pad(x, w[i]))).join(" ")}`);
|
|
8209
8294
|
};
|
|
8210
8295
|
table("flows", ["flow", "runs", "failed", "p50", "p95", "cost"], (o.flows ?? []).map((f) => [f.flow, f.runs, f.failed, secs(f.seconds?.p50), secs(f.seconds?.p95), `$${f.costUsd.toFixed(2)}`]));
|
|
8211
|
-
table("agents", ["agent", "steps", "failed", "retried", "skipped", "p95", "cost"], (o.agents ?? []).map((a) => [a.agent, a.steps, a.failed, a.retried, a.skipped, secs(a.seconds?.p95), `$${a.costUsd.toFixed(2)}`]));
|
|
8212
|
-
table("tools", ["tool", "calls", "errors", "p95", "used by"], (o.tools ?? []).map((t) => [t.name, t.calls, t.errors, t.ms?.p95 == null ? "—" : `${Math.round(t.ms.p95)}ms`, (t.agents ?? []).join(", ")]));
|
|
8296
|
+
table("agents", ["agent", "steps", "failed", "retried", "skipped", "p95", "turns", "cached", "cost"], (o.agents ?? []).map((a) => [a.agent, a.steps, a.failed, a.retried, a.skipped, secs(a.seconds?.p95), spreadOf(a.turns, String), a.cacheHitRate == null ? "—" : `${Math.round(a.cacheHitRate * 100)}%`, `$${a.costUsd.toFixed(2)}`]));
|
|
8297
|
+
table("tools", ["tool", "calls", "errors", "p95", "result size", "heavy", "used by"], (o.tools ?? []).map((t) => [t.name, t.calls, t.errors, t.ms?.p95 == null ? "—" : `${Math.round(t.ms.p95)}ms`, spreadOf(t.resultSize, kchars), t.heavyCalls || "—", (t.agents ?? []).join(", ")]));
|
|
8213
8298
|
// Where a step's first seconds go, phase by phase (FOL-29): each phase's
|
|
8214
8299
|
// own share, and when it was done counted from the step's start.
|
|
8215
8300
|
table("start-up", ["start-up", "n", "took p50", "p95", "done by p50", "p95"], (o.startup ?? []).map((p) => [p.phase, p.took?.n ?? 0, fine(p.took?.p50), fine(p.took?.p95), fine(p.at?.p50), fine(p.at?.p95)]));
|
|
@@ -8289,7 +8374,8 @@ async function usageCmd(flags) {
|
|
|
8289
8374
|
const t = u.totals ?? {};
|
|
8290
8375
|
const usd = (n) => `$${Number(n ?? 0).toFixed(2)}`;
|
|
8291
8376
|
console.log(`\n ${c.bold(u.tenant ?? "usage")} ${c.dim("what the run records hold")}`);
|
|
8292
|
-
|
|
8377
|
+
const hit = (b) => (b.cacheHitRate == null ? null : `${Math.round(b.cacheHitRate * 100)}% cached`);
|
|
8378
|
+
console.log(` ${t.runs ?? 0} runs · ${t.steps ?? 0} steps · ${Number(t.inputTokens ?? 0).toLocaleString()} in / ${Number(t.outputTokens ?? 0).toLocaleString()} out tokens${hit(t) ? ` (${hit(t)})` : ""} · ${humanDuration((t.computeSecs ?? 0) * 1000)} compute · ${humanBytes(t.storageBytes ?? 0)} stored`);
|
|
8293
8379
|
console.log(` ${c.dim(`models ${usd(t.modelUsd)} · platform ${usd(t.platformUsd)}${u.waived?.length ? ` · waived: ${u.waived.join(", ")}` : ""}${u.ledger ? ` · balance ${usd(u.ledger.balanceUsd)}` : ""}`)}`);
|
|
8294
8380
|
const ws = (u.workspaces ?? []).slice().sort((a, b) => b.modelUsd + b.platformUsd - (a.modelUsd + a.platformUsd));
|
|
8295
8381
|
if (ws.length) {
|
|
@@ -8297,7 +8383,7 @@ async function usageCmd(flags) {
|
|
|
8297
8383
|
console.log();
|
|
8298
8384
|
for (const x of ws) {
|
|
8299
8385
|
const stored = (x.storage?.sourceBytes ?? 0) + (x.storage?.filesBytes ?? 0) + (x.storage?.runsBytes ?? 0);
|
|
8300
|
-
console.log(` ${c.bold(pad(x.workspace, w))} ${pad(`${x.runs} runs`, 9)} ${pad(usd(x.modelUsd + x.platformUsd), 9)} ${c.dim(humanBytes(stored))}`);
|
|
8386
|
+
console.log(` ${c.bold(pad(x.workspace, w))} ${pad(`${x.runs} runs`, 9)} ${pad(usd(x.modelUsd + x.platformUsd), 9)} ${pad(hit(x) ?? "—", 11)} ${c.dim(humanBytes(stored))}`);
|
|
8301
8387
|
}
|
|
8302
8388
|
}
|
|
8303
8389
|
console.log(`\n ${c.bold("charged")} ${c.dim(`${h.from?.slice(0, 10)} → ${h.to?.slice(0, 10)}, by ${h.bucket} · the ledger`)}`);
|
|
@@ -9378,7 +9464,9 @@ async function assistantCmd(positional, flags, layout) {
|
|
|
9378
9464
|
if (d.usage) {
|
|
9379
9465
|
const k = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
|
|
9380
9466
|
const paid = d.supply === "provider" ? "on your own model key" : typeof d.chargedUsd === "number" ? `$${d.chargedUsd.toFixed(d.chargedUsd > 0 && d.chargedUsd < 0.01 ? 4 : 2)} charged` : "";
|
|
9381
|
-
|
|
9467
|
+
const u = d.usage;
|
|
9468
|
+
const share = u.cacheReadTokens !== undefined && u.inputTokens > 0 ? ` (${Math.round((u.cacheReadTokens / u.inputTokens) * 100)}% cached)` : "";
|
|
9469
|
+
console.log(` ${c.dim(`${k(u.inputTokens)} tokens in${share} · ${k(u.outputTokens)} out${paid ? ` · ${paid}` : ""}`)}\n`);
|
|
9382
9470
|
}
|
|
9383
9471
|
if (proposedWs) console.log(` ${c.bold("new workspace")} ${proposedWs.name} ${c.dim(`— ${proposedWs.purpose}`)}\n`);
|
|
9384
9472
|
await showFiles();
|