foldrun 2.0.0 → 2.1.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 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
- - **An unknown version** — a typo, a date that was never released — is a
36
- `400`, not a silent fallback to current.
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 `costUsd` — not their reply or trace |
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 retries, per-tool calls/errors/latency, 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) |
209
+ | `/api/workspaces/<ws>/observe?days=` | GET | the observability report the Observe page renders — per-agent failures, retries, tokens and `cacheHitRate`, per-tool calls/errors/latency, 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 |
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 reads included) and out,
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), 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 |
@@ -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, cache hit rate), 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 (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/runs.md CHANGED
@@ -16,7 +16,7 @@ Each run holds its steps in group order. A step carries:
16
16
  | `conclusion` | the final turn alone — what it concluded |
17
17
  | `data` | the parsed value, for a step with `output: json` |
18
18
  | `events` | tool calls, script runs with their timing, verify verdicts, refusals |
19
- | `costUsd`, `tokens`, `computeSecs`, `startupSecs` | what it cost and how long the sandbox lived — **summed across its attempts** |
19
+ | `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
20
  | `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
21
  | `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
22
  | `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 |
@@ -56,7 +56,7 @@ The page follows the run as it moves, gates included, without a reload: a
56
56
  reconnects), and the browser tab reads the state and the step — `● running ·
57
57
  step 3/7` — so you can switch away and still see where it is. Only the step
58
58
  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.
59
+ headline, duration, tokens (and the share the prompt cache served) and cost, and the next step opens as it starts.
60
60
  Click a step to pin it open or shut, or use **expand all** / **collapse
61
61
  all** above the column. A **json** link opens the run record exactly as the
62
62
  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;
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.0.0",
3
+ "version": "2.1.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.0.0"
14
+ "@foldrun/core": "^2.2.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
- const res = await remoteFetch(url, "/api/version", {}, { token, seconds: timeoutSeconds(flags) });
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 ?? []).reduce(
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
- console.log(` ${c.dim(`${tokens.input.toLocaleString()} in / ${tokens.output.toLocaleString()} out tokens`)}`);
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,7 @@ 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)),
6333
6408
  step.startedAt && step.finishedAt
6334
6409
  ? humanDuration(Date.parse(step.finishedAt) - Date.parse(step.startedAt))
6335
6410
  : null,
@@ -8208,7 +8283,7 @@ function printObservation(o) {
8208
8283
  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
8284
  };
8210
8285
  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)}`]));
8286
+ table("agents", ["agent", "steps", "failed", "retried", "skipped", "p95", "cached", "cost"], (o.agents ?? []).map((a) => [a.agent, a.steps, a.failed, a.retried, a.skipped, secs(a.seconds?.p95), a.cacheHitRate == null ? "—" : `${Math.round(a.cacheHitRate * 100)}%`, `$${a.costUsd.toFixed(2)}`]));
8212
8287
  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(", ")]));
8213
8288
  // Where a step's first seconds go, phase by phase (FOL-29): each phase's
8214
8289
  // own share, and when it was done counted from the step's start.
@@ -8289,7 +8364,8 @@ async function usageCmd(flags) {
8289
8364
  const t = u.totals ?? {};
8290
8365
  const usd = (n) => `$${Number(n ?? 0).toFixed(2)}`;
8291
8366
  console.log(`\n ${c.bold(u.tenant ?? "usage")} ${c.dim("what the run records hold")}`);
8292
- console.log(` ${t.runs ?? 0} runs · ${t.steps ?? 0} steps · ${Number(t.inputTokens ?? 0).toLocaleString()} in / ${Number(t.outputTokens ?? 0).toLocaleString()} out tokens · ${humanDuration((t.computeSecs ?? 0) * 1000)} compute · ${humanBytes(t.storageBytes ?? 0)} stored`);
8367
+ const hit = (b) => (b.cacheHitRate == null ? null : `${Math.round(b.cacheHitRate * 100)}% cached`);
8368
+ 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
8369
  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
8370
  const ws = (u.workspaces ?? []).slice().sort((a, b) => b.modelUsd + b.platformUsd - (a.modelUsd + a.platformUsd));
8295
8371
  if (ws.length) {
@@ -8297,7 +8373,7 @@ async function usageCmd(flags) {
8297
8373
  console.log();
8298
8374
  for (const x of ws) {
8299
8375
  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))}`);
8376
+ 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
8377
  }
8302
8378
  }
8303
8379
  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 +9454,9 @@ async function assistantCmd(positional, flags, layout) {
9378
9454
  if (d.usage) {
9379
9455
  const k = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
9380
9456
  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
- console.log(` ${c.dim(`${k(d.usage.inputTokens)} tokens in · ${k(d.usage.outputTokens)} out${paid ? ` · ${paid}` : ""}`)}\n`);
9457
+ const u = d.usage;
9458
+ const share = u.cacheReadTokens !== undefined && u.inputTokens > 0 ? ` (${Math.round((u.cacheReadTokens / u.inputTokens) * 100)}% cached)` : "";
9459
+ console.log(` ${c.dim(`${k(u.inputTokens)} tokens in${share} · ${k(u.outputTokens)} out${paid ? ` · ${paid}` : ""}`)}\n`);
9382
9460
  }
9383
9461
  if (proposedWs) console.log(` ${c.bold("new workspace")} ${proposedWs.name} ${c.dim(`— ${proposedWs.purpose}`)}\n`);
9384
9462
  await showFiles();