foldrun 1.3.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/bin/foldrun.mjs CHANGED
@@ -185,8 +185,7 @@ const HELP = `foldrun — agents are just folders
185
185
  foldrun flags the feature flags for this account: each on or off, and why — set for this account, for every account, a staged rollout, or the default (--json). Read-only: the platform's super admin changes them
186
186
  foldrun history [path] every change to a deployed workspace, newest first (--id <revision> for its diff, --limit, --to)
187
187
  foldrun history restore <path> --id <revision> put one file back as that revision left it, as a new revision — diff, asks (--yes, --dry-run, --to)
188
- foldrun repo ls a workspace's branches and tags — also diff <branch>, deploy <ref>, merge <branch> (both ask first), rm-branch <branch>, mirror <git-url|off>, mirror-now (--to)
189
- foldrun restore <ws> --to <commit|time|age> put a workspace's source back as it was then — shows the change, asks for the name typed back (--dry-run: the diff only, --yes)
188
+ foldrun restore <ws> --to <revision|time|age> put a workspace's source back as it was then — shows the change, asks for the name typed back (--dry-run: the diff only, --yes)
190
189
  foldrun backups how the account is backed up (encrypted?), when it last was, the snapshots it is in — also request --what runs|state|storage|everything --at "<when>" [--to <ws>] [--note]
191
190
  foldrun backups requests every restore asked of the platform team, and where each stands (--json)
192
191
  foldrun secrets set NAME store a secret (prompted, never echoed) — also ls, rm, status; --kind file|ssh|api|service-account|m2m for the other shapes; --site <domain> (repeatable) binds it as it is stored
@@ -294,7 +293,7 @@ Options
294
293
  --step <n> approve, reject: decide only that step; default is every step that is waiting
295
294
  --payload <json|text|@f> approve: release a step waiting on wait: event with this body, recorded as you
296
295
  --note "<text>" approve, reject: guidance the agent reads — or the reason for a refusal; billing credit: the ledger line's note
297
- --yes approve, stop, rerun (bulk), flow rotate-hook, flow draft, assistant, history restore, restore <ws>, import, billing plan, repo, workspace set name, and every rm/revoke/unshare: skip the confirmation, deliberately (required with no terminal)
296
+ --yes approve, stop, rerun (bulk), flow rotate-hook, flow draft, assistant, history restore, restore <ws>, import, billing plan, workspace set name, and every rm/revoke/unshare: skip the confirmation, deliberately (required with no terminal)
298
297
  --json report: the raw run record instead of the report; observe, usage, api version, changelog, find, preferences, workspace vocabulary, flags, workspaces, workspaces show, agent ls, flow ls, agent import --list, backups requests, team, hook ls: the raw document; version: {cli, core, platform}
299
298
  --events <a,b> account set notify: failed, awaiting-approval, completed
300
299
  --failed webhooks deliveries: only the ones that gave up
@@ -324,7 +323,6 @@ Options
324
323
  --port <n> connect: loopback port for the redirect (default 8642 — register http://localhost:8642/callback on the app)
325
324
  --authorize-url / --token-url connect: a provider with no preset
326
325
  --role <r> keys create: viewer, editor (default) or admin
327
- --for <workspace> keys create: a deploy key for one workspace (--access read|write)
328
326
  --expires <span> keys create: when it stops working — 30d, 90d, 365d or never (default never)
329
327
  --grace <span> keys rotate: keep the old key working for 1h or 24h while you swap the new one in (default 0: revoked now)
330
328
 
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
@@ -14,7 +14,7 @@ in common — versions (`Foldrun-Version`), rate limits (`X-RateLimit-*`, `429`)
14
14
  One funnel for every caller. A route either takes an authenticated tenant or
15
15
  refuses — there is no separate "browser API". The exceptions carry their own
16
16
  proof instead of a key: a token or signature (`/s/<token>`, `/api/hooks`,
17
- `/api/inbox`, `/api/events`, `/api/approve`, `/api/unsubscribe`, `/api/git`, `/api/billing/stripe`,
17
+ `/api/inbox`, `/api/events`, `/api/approve`, `/api/unsubscribe`, `/api/billing/stripe`,
18
18
  `/api/billing/confirm`), the sign-in and recovery steps (`/api/auth/signup`,
19
19
  `login`, `forgot`, `reset`, `verify`, `mfa/verify`, `session`, `/api/cli/login`,
20
20
  `/api/cli/login/<id>`, `/api/cli/authorize` GET, `/api/oauth/callback`), `/api/healthz`, `/api/status`, `/api/status/history`, `/api/version` and `/api/openapi.json`.
@@ -142,9 +142,7 @@ nothing outward, state untouched, receipts on the run). Returns
142
142
  `{ ok, runId, steps, test }` (`from` is added when a start step was given); `409 { error, runId }` when the flow has `overlap: skip` and a run is live; with `?wait=true&timeout=<s>` it holds
143
143
  on and returns the verdict instead, exactly as `GET .../runs/<id>?wait=true`
144
144
  does below, which is where a caller goes on to wait when a piece runs out.
145
- Left out, `test` is decided by the platform:
146
- a preview workspace's runs are tests unless the flow says `live: true`;
147
- everywhere else a run is live.
145
+ Left out, a run is live.
148
146
 
149
147
  ## Assistant
150
148
 
@@ -192,7 +190,7 @@ paying for the steps around it.
192
190
 
193
191
  | Route | Methods | |
194
192
  |---|---|---|
195
- | `/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 |
196
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` |
197
195
  | `/api/workspaces/<ws>/runs/<id>/stream` | GET | server-sent events, live |
198
196
  | `/api/workspaces/<ws>/runs/<id>/stop` | POST | destroys the sandbox, not just the record |
@@ -208,12 +206,9 @@ paying for the steps around it.
208
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 |
209
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 |
210
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 |
211
- | `/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. `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) |
212
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 |
213
- | `/git/<tenant>/<ws>.git` | git | the workspace as a git remote — `git clone`, `git push` (a push to main deploys and runs the evals; a push to any other branch deploys a **preview** workspace named `<ws>-preview-<branch>` from that branch's tree and runs its evals there — the scheduler never fires a preview's flows, a preview reads its source's secrets, and deleting the branch deletes the preview). A push while runs are in flight is accepted but **not** applied yet: git warns at push time, and the scheduler applies it when those runs finish. HTTP Basic: any username, an API key as the password |
214
- | `/git/<tenant>/_library.git` | git | the account library as a git remote — a push replaces the shelf |
215
- | `/api/workspaces/<ws>/repo?branch=` | GET, POST | GET: branches, tags, mirror settings; `?branch=` a branch's net change against main with diffs. POST `{action}`: `deploy` a `ref` (a rollback is a new commit on main), `merge` a `branch` into main and deploy, `delete-branch`, `mirror` (set `url`, https, credentials via the MIRROR_TOKEN secret), `mirror-now` |
216
- | `/api/workspaces/<ws>/restore` | POST | put the workspace's source back as it was at a point in its history. `to`: a commit or its prefix, a branch or tag, an ISO date/time (the last commit on main at or before it) or an age (`90m`, `12h`, `3d`, `2w`). `{ to, dryRun: true }` answers `{ to: { 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 commit's tree as a new commit on main (undo: restore to `from`), 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` |
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` |
217
212
 
218
213
  `status` is one of `queued`, `running`, `awaiting-approval`, `completed`,
219
214
  `failed`.
@@ -329,7 +324,7 @@ Reads return which secrets exist, never their values.
329
324
 
330
325
  | Route | Methods | |
331
326
  |---|---|---|
332
- | `/api/keys` | GET, POST, DELETE | API keys — an admin's (`keys:manage`) to list, mint and revoke. POST `label`, `role` (viewer \| editor \| admin, never above your own, default editor), `workspaces` (names, or `null` for all — never wider than your own scope); `expires` (`30d`, `90d`, `365d`, any number of days up to 3650, or `never` — the default; anything else is a 400, never a key that lives forever); returns `{ ok, key, id, prefix, role, workspaces, expiresAt }`, and the key is shown once. GET lists each key with `createdAt`, `createdBy`, `expiresAt`, `lastUsedAt`, `lastIp` and `replacedBy` (the id of the key a rotation replaced it with; `null` until then) — the last-use pair written at most once a minute per key, the address cut to its /24 (IPv4) or /48 (IPv6). A key past its `expiresAt` is refused with 401 `API key expired on <date>`; the owner is emailed seven days before. Mint, revoke and rotate are written to the audit log. POST with `workspace` and `access: read\|write` mints a deploy key — git clone/push for that workspace only, refused everywhere else. DELETE `id`. `?tenant=` / body `tenant` is read only on an install with no users yet (first-run bootstrap) |
327
+ | `/api/keys` | GET, POST, DELETE | API keys — an admin's (`keys:manage`) to list, mint and revoke. POST `label`, `role` (viewer \| editor \| admin, never above your own, default editor), `workspaces` (names, or `null` for all — never wider than your own scope); `expires` (`30d`, `90d`, `365d`, any number of days up to 3650, or `never` — the default; anything else is a 400, never a key that lives forever); returns `{ ok, key, id, prefix, role, workspaces, expiresAt }`, and the key is shown once. GET lists each key with `createdAt`, `createdBy`, `expiresAt`, `lastUsedAt`, `lastIp` and `replacedBy` (the id of the key a rotation replaced it with; `null` until then) — the last-use pair written at most once a minute per key, the address cut to its /24 (IPv4) or /48 (IPv6). A key past its `expiresAt` is refused with 401 `API key expired on <date>`; the owner is emailed seven days before. Mint, revoke and rotate are written to the audit log. POST with `workspace` or `access` is refused (400): those minted deploy keys for the platform's own git, which went on 17 Oct 2026 — a deploy key minted before is refused everywhere (403). A key for one workspace is `workspaces: ["<ws>"]`. DELETE `id`. `?tenant=` / body `tenant` is read only on an install with no users yet (first-run bootstrap) |
333
328
  | `/api/keys/<id>/rotate` | POST | a new key with the old one's label, role, workspaces and deploy scope — and, when it had an end, the same lifetime from today — returned once like a mint (`{ ok, key, id, prefix, role, workspaces, expiresAt, replaced: { id, endsAt } }`). `grace`: `0` (default — the old key is revoked at once), or a duration up to `7d` such as `1h` or `24h`, during which the old key still works and after which it is refused as expired. A key is rotated once: rotating a key already replaced is 409 `this key was already rotated to <prefix>…; rotate that one` — and someone who lost the new key's secret rotates the NEW key, which mints another and ends the lost one (rotating the old one again would leave the lost key working). Two rotations of one key at once mint one key. `keys:manage`; a key outside the caller's scope is 404. Audited as `apikey.rotated` |
334
329
  | `/api/team` | GET, POST | members with their roles and workspace scope; mint an invite — `role` (viewer \| editor \| admin, default editor), `workspaces` (names, or omit for all), `email` (bound into the invite: only that address can accept it; sent through the account's `RESEND_API_KEY` unless `send: false`). Returns `path` (the invite's relative URL), `expires`, `sent` and any `sendError` either way; the full link is in the mail |
335
330
  | `/api/team/members/<id>` | PATCH, DELETE | change a member's role (`role`) and/or which workspaces they may open (`workspaces`: names, or null for all); remove them |
@@ -337,13 +332,13 @@ Reads return which secrets exist, never their values.
337
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 |
338
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 |
339
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 |
340
- | `/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 |
341
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` |
342
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 |
343
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 |
344
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) |
345
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 |
346
- | `/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) |
347
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) |
348
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 |
349
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 |
@@ -473,4 +468,3 @@ signed-in person, never by a key — mints an ordinary API key at their role.
473
468
  | `/api/approve/<tenant>/<ws>/<id>?token=` | GET, POST | decide a run parked on a human, from the link in its notification. GET shows what is waiting and two buttons — it never decides, because inbox link-checkers follow GETs. POST `decision` (`approve` or `reject`) and an optional `note`, form-encoded or JSON; answers JSON when `Accept` prefers it. The token is derived from the run id under the install key, not stored, so a key rotation kills every link. Needs `FOLDRUN_PUBLIC_URL` to be minted at all |
474
469
  | `/api/workspaces/<ws>/hooks/<flow>` | GET | read the flow's webhook URL without changing it: `{ ok, flow, path, url }` — `path` carries the token, `url` is it made absolute with `FOLDRUN_PUBLIC_URL` (null when that is unset). `workspace:manage`, as rotate and the settings page that shows it, since the URL is a credential. `404` for no such flow, `409` for a flow that is not `trigger: webhook` |
475
470
  | `/api/workspaces/<ws>/hooks/<flow>/rotate` | POST | new token, old URLs stop working |
476
- | `/api/git/<tenant>/<ws>?branch=&dir=` | POST | GitHub push webhook — HMAC-signed, fetches the tarball at that commit and deploys it |
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.
@@ -29,7 +29,7 @@ It holds the database only, not the data volume.
29
29
  ### What it does not hold
30
30
 
31
31
  - Workspace files, run records, `state/`, `memory/`, the vault and each
32
- workspace's git history: the data volume is not backed up. A workspace's
32
+ workspace's history: the data volume is not backed up. A workspace's
33
33
  source can still be restored from its own history (below).
34
34
  - **Storage file bytes.** These live in object storage (R2), not on the data
35
35
  volume. *Unverified:* whether a storage file deleted by mistake can still
@@ -63,18 +63,26 @@ public key).
63
63
 
64
64
  ## Restoring a workspace yourself
65
65
 
66
- Every change to a workspace — a dashboard save, a deploy, a `git push` — is
67
- a commit in its own git repository. Restoring puts the workspace's source
68
- back as it was at one of those commits:
66
+ Every change to a workspace — a dashboard save, a deploy, an import — is a
67
+ revision, and every revision keeps a snapshot of the whole source as it
68
+ stood right after it. Restoring puts the workspace's source back as it was
69
+ at one of them — the platform's own deploy history, so it works the same
70
+ whether or not you keep the source in git:
69
71
 
70
72
  ```sh
71
73
  foldrun restore sales --to 2026-09-28T14:00 --dry-run # what would change, with the diff
72
74
  foldrun restore sales --to 2026-09-28T14:00 # asks you to type the name, then does it
73
75
  ```
74
76
 
75
- `--to` takes a commit (or the start of one), a branch or tag, a date or time
76
- (the last commit at or before it — a time without a zone is read in the
77
- server's timezone, so add `Z` or an offset to be exact), or an age: `90m`, `12h`, `3d`, `2w`.
77
+ `--to` takes a revision id (`r-…`, from the History page or `foldrun
78
+ history`), the commit a deploy named, a date or time (the last revision at
79
+ or before it — a time without a zone is read in the server's timezone, so
80
+ add `Z` or an offset to be exact), or an age: `90m`, `12h`, `3d`, `2w`.
81
+ A revision's diff is that one change; its snapshot is the whole source as
82
+ it stood on disk just after it, so two people saving at once each see the
83
+ other's change in their snapshot. History holds snapshots from 17 October 2026: each workspace's starts with
84
+ a "history starts here" revision, its source as it stood then, and nothing
85
+ before it can be restored this way.
78
86
 
79
87
  - **Preview first.** The dry run lists every file that would change, come
80
88
  back, or go, with a line diff. Nothing is written.
@@ -82,8 +90,8 @@ server's timezone, so add `Z` or an offset to be exact), or an age: `90m`, `12h`
82
90
  back (`confirm` in the API). The CLI sends the removals you were shown, and
83
91
  if anything else would now be removed — a file added in between — the
84
92
  restore is refused and you are asked to look again.
85
- - **It is undoable.** A restore is a new commit whose files are the old
86
- ones; history says "restored to abc1234". To undo it, restore to the commit
93
+ - **It is undoable.** A restore is a new revision whose files are the old
94
+ ones; history says "restored to r-…". To undo it, restore to the revision
87
95
  it answered as `from`.
88
96
  - **It touches only the source.** Runs, `state/`, storage, secrets and the
89
97
  memory agents wrote are what a deploy keeps, and a restore is a deploy:
@@ -97,11 +105,12 @@ server's timezone, so add `Z` or an offset to be exact), or an age: `90m`, `12h`
97
105
  ```http
98
106
  POST /api/workspaces/<ws>/restore
99
107
  { "to": "3d", "dryRun": true }
100
- { "to": "<sha from the dry run>", "confirm": "<ws>", "expectRemoved": [ … ] }
108
+ { "to": "<to.id from the dry run>", "confirm": "<ws>", "expectRemoved": [ … ] }
101
109
  ```
102
110
 
103
- The workspace's History page lists every commit, and its Repository page can
104
- deploy any one of them — the same operation, by commit instead of by time.
111
+ The workspace's History page lists every revision; open one and **Roll back
112
+ to this** does the same restore, by revision instead of by time, with its
113
+ preview first.
105
114
 
106
115
  ## Asking for a restore from a snapshot
107
116
 
@@ -25,8 +25,9 @@ mistake the editor names rather than a cap it quietly keeps.
25
25
  When an agent's cap and the flow's both apply to one step, the tighter one
26
26
  wins, and the step's error says which line set it: `budget: in the flow
27
27
  file` or `budget: on the writer agent`.
28
- | **runs at once** | `concurrency: 3` in the account's `AGENTS.md`, or Settings → Defaults | the fourth run waits its turn in the queue — never refused, only later. Empty means the install's plan number (`FOLDRUN_PLAN_CONCURRENCY`, which applies only where billing is on), or no cap |
29
- | **the queue lane** | `priority: high` in the account's `AGENTS.md` (`high` \| `normal` \| `low`) | its runs are claimed before other accounts' normal ones when slots are short; a flow's own `priority:` adds to it. The cap above still holds — a lane jumps the queue, it does not hold the fleet |
28
+ | **runs at once** | `concurrency: 3` in the account's `AGENTS.md`, or Settings → Defaults | the fourth run waits its turn in the queue — never refused, only later. Empty means the install's share (`FOLDRUN_ACCOUNT_CONCURRENCY`) or plan number (`FOLDRUN_PLAN_CONCURRENCY`, which applies only where billing is on), or no cap. Your own number can lower that, never raise it; a higher number comes with a plan |
29
+ | **the queue lane** | the plan's lane (`high` \| `normal` \| `low`); `priority: low` in the account's `AGENTS.md` lowers it, and `high` there counts only with a plan that gives it | when slots are short, accounts take turns, weighted by lane — high 3, normal 2, low 1: every account's first run goes before anyone's second, a fast lane takes three turns to a slow lane's one and never all of them, an account with runs going waits behind one with none, and a run that has waited ten minutes moves up a turn. A flow's own `priority:` orders runs within that. The cap above still holds |
30
+ | **room to start** | the install's `FOLDRUN_RUN_MEMORY_GIB` and `FOLDRUN_ACCOUNT_PODS` | a step whose sandbox would not fit, or whose account already has its most sandboxes running, waits to start — `room: waiting to start — …` on its trail — instead of failing. The wait is not billed; the run page's start-up chip shows it as `room`. A step still waiting after 30 minutes (`FOLDRUN_ROOM_WAIT_SEC`) fails, saying why |
30
31
 
31
32
  A cap is a literal, never an expression, so it is readable off the file. The
32
33
  run cap is hard: a step that reaches its share of the remainder is stopped
@@ -142,6 +143,35 @@ having more, smaller steps. The Usage page shows every table in credits at
142
143
  today's rates beside the meters, so a customer can read what a run will
143
144
  cost from what the last one consumed.
144
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
+
145
175
  ## What each desk costs
146
176
 
147
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). `--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` |
@@ -145,9 +145,7 @@ claude.ai or Claude Code there, nor any MCP configuration on disk.
145
145
  | `foldrun flags` | the feature flags for this account: each one `on` or `off` and why — `account` (set for this account), `global` (set for every account), `rollout` (a share of accounts, with its percentage) or `default` — and what it does. Read-only: only the platform's super admin changes a flag, in the console. `--json` for the API's answer as it came. See [Feature flags](feature-flags.md) |
146
146
  | `foldrun history [path]` | every change to a deployed workspace, newest first: revision id, when, who, the message and the files. A path narrows it to one file; `--id <revision>` prints that revision as diffs; `--limit <n>`; `--to <workspace>` |
147
147
  | `foldrun history restore <path> --id <revision>` | the History drawer's **Restore this version**: the file is rewritten with that revision's text as a new revision, `restored <rev> from history`, so the restore is itself undone the same way. Shows the diff against the file now and asks (`--yes`, `--dry-run`); sent with `ifMatch`, so a file that changed since is refused (`--to`) |
148
- | `foldrun repo ls` | a workspace's repository: main, its branches and tags with their last commit, and the mirror. `repo diff <branch>` is what a branch changes against main; `repo deploy <commit\|branch\|tag>` makes that tree live (a rollback is a new commit on main, itself undoable); `repo merge <branch>` merges into main and deploys. Deploy and merge ask first (`--yes` with no terminal) and print the check issues or the run in flight that refused them |
149
- | `foldrun repo rm-branch <branch>` | delete a branch (asks; `--yes`). `repo mirror <git-url>` sends every push to main there too, with the workspace's `MIRROR_TOKEN` (`repo mirror off` stops it); `repo mirror-now` pushes main now and prints what the remote said (`--to`) |
150
- | `foldrun restore <workspace> --to <commit\|time\|age>` | put a workspace's source back as it was then — `--to` is a commit or its prefix, a branch or tag, a date/time (the last commit at or before it) or an age (`90m`, `12h`, `3d`, `2w`). Always shows the change first (`--dry-run`: the full diff and nothing else); the restore asks for the workspace's name typed back (`--yes` with no terminal) and sends the removals it showed, so a file added in between refuses it. A new commit on main, so it prints the `foldrun restore … --to <from>` that undoes it. Runs, `state/`, storage and secrets are not touched. See [Backups and restore](backups-and-restore.md) |
148
+ | `foldrun restore <workspace> --to <revision\|time\|age>` | put a workspace's source back as it was then — `--to` is a revision id (`r-…`, from `foldrun history`), the commit a deploy named, a date/time (the last revision at or before it) or an age (`90m`, `12h`, `3d`, `2w`). Always shows the change first (`--dry-run`: the full diff and nothing else); the restore asks for the workspace's name typed back (`--yes` with no terminal) and sends the removals it showed, so a file added in between refuses it. A new revision, so it prints the `foldrun restore … --to <from>` that undoes it. Runs, `state/`, storage and secrets are not touched. See [Backups and restore](backups-and-restore.md) |
151
149
  | `foldrun backups` | how the account is backed up: the schedule, how many are kept, the last snapshot (verified? copied off the server? encrypted?), the snapshots that hold the account, each marked encrypted or not, each workspace's last change, and earlier restore requests (`--limit`, `--json`). `backups request --what runs\|state\|storage\|everything --at "<when>" [--to <workspace>] [--backup <id>] [--note "…"]` asks the platform team for a restore from a snapshot — recorded and emailed; nothing is restored until they do it. `backups requests` lists every request this account has made, and where each stands (`GET /api/account/backups/requests`, `--json`) |
152
150
  | `foldrun accounts` | every account signed in on this machine, and which one a bare command acts as. `foldrun profiles` is the same command |
153
151
  | `foldrun use <name>` | act as one of them from here on. `foldrun switch <name>` is the same command |
@@ -214,7 +212,6 @@ claude.ai or Claude Code there, nor any MCP configuration on disk.
214
212
  | `--client-id` / `--client-secret` | `connect`: the OAuth app; prompted when omitted, the secret never echoed |
215
213
  | `--port <n>` | `connect`: the loopback port (default 8642) |
216
214
  | `--role <r>` | `keys create`: `viewer`, `editor` (default) or `admin` |
217
- | `--for <workspace>` | `keys create`: a deploy key for one workspace; `--access read` or `write` |
218
215
  | `--expires <span>` | `keys create`: when it stops working — `30d`, `90d`, `365d` or `never` (default) |
219
216
  | `--grace <span>` | `keys rotate`: keep the old key working for `1h` or `24h` (default `0`: revoked now) |
220
217
  | `--force` | `deploy`: deploy while runs are in flight. `pull`, `storage get`: overwrite local files |
@@ -292,8 +289,7 @@ admin's: a key is never an owner.
292
289
  installation; the last one signed in to is the default afterwards, and
293
290
  `--url` on any command picks another. `foldrun login --token <key>` stores a
294
291
  key made in the dashboard instead of opening a browser — for a machine with
295
- none, or to hold a deploy key for one workspace — after checking that it
296
- works.
292
+ none — after checking that it works.
297
293
 
298
294
  `foldrun whoami` says who the platform thinks you are and where the
299
295
  credential came from. `foldrun logout` forgets the key and, when `login`
@@ -301,8 +297,8 @@ made it, revokes it.
301
297
 
302
298
  **In CI, set `FOLDRUN_TOKEN`** rather than logging in: the environment beats
303
299
  the file, so a job with a key in its variables never reads one from disk.
304
- `foldrun keys create ci --for leads --access write` makes a key that can only
305
- `git push` that one workspace.
300
+ `foldrun keys create ci --workspaces leads` makes a key that opens that one
301
+ workspace and nothing else on the account.
306
302
 
307
303
  ## Signing in to a site, for an agent
308
304
 
@@ -478,7 +474,6 @@ foldrun logs --to publish-desk # what ran there lately
478
474
  foldrun open runs # the same, in the browser
479
475
  ```
480
476
 
481
- A push to any branch other than main previews it: the platform deploys the
482
- branch's tree to `<workspace>-preview-<branch>` and runs its evals there.
483
- Previews never fire on a schedule, read the source workspace's secrets, and
484
- disappear when the branch does.
477
+ Previews of a branch come back with the GitHub and GitLab app; until then a
478
+ second workspace (`foldrun deploy --to leads-try`) is the place to try a
479
+ change.
package/docs/deploying.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # Deploying
2
2
 
3
- A workspace deploys by pushing files. Two doors, one mechanism: `foldrun
4
- deploy` from a folder, or `git push` to the remote every workspace has.
5
- Both are checked before anything goes live, both refuse to swap files under
6
- a run in flight, and neither touches what the platform owns.
3
+ A workspace deploys by sending its files: `foldrun deploy` from a folder,
4
+ from your laptop or from CI. Every deploy is checked before anything goes
5
+ live, refuses to swap files under a run in flight, and never touches what
6
+ the platform owns.
7
7
 
8
8
  ## From a folder
9
9
 
@@ -15,67 +15,56 @@ foldrun deploy --to leads --commit $(git rev-parse HEAD)
15
15
  ```
16
16
 
17
17
  In CI there is no browser: set `FOLDRUN_URL` and `FOLDRUN_TOKEN` (a key from
18
- Settings → API keys, or `foldrun keys create`) and the same commands run.
18
+ Settings → API keys, or `foldrun keys create`) and the same commands run. A
19
+ key for CI should open the one workspace it deploys:
20
+ `foldrun keys create ci --workspaces leads`.
19
21
 
20
- ## From git
22
+ `--commit` records which commit of your own repository the files came from;
23
+ the workspace page and its History show it.
21
24
 
22
- Every workspace is a git remote:
25
+ ## Your own GitHub or GitLab
23
26
 
24
- ```sh
25
- git clone https://foldrun:<api-key>@app.example.com/git/<account>/<workspace>.git
26
- # edit, commit
27
- git push origin main # deploys, and runs the evals
28
- ```
29
-
30
- Any username, an API key as the password. A **deploy key** — minted for one
31
- workspace, read or write, with `foldrun keys create ci --for <workspace>
32
- --access write` or on the Settings page — is what a CI job or a contractor
33
- should hold; it is refused on every other workspace and on every other route.
34
-
35
- Push a branch other than `main` and you get a **preview**: a workspace named
36
- `<workspace>-preview-<branch>` deployed from that branch, with its evals run
37
- there, its schedules never fired, and its source's secrets. Deleting the
38
- branch deletes it.
27
+ Keep the folder in your own repository and deploy it from CI with the
28
+ commands above. A connected app — a push to your main branch deploys,
29
+ other branches become previews, a check on every commit — is coming, for
30
+ GitHub first, then GitLab. Until 17 October 2026 every workspace was also a
31
+ git remote on the platform, with deploy keys, branch previews, a mirror and
32
+ a GitHub push webhook; those went, and history moved to the platform's own
33
+ snapshots (below).
39
34
 
40
35
  ## What a deploy checks
41
36
 
42
37
  The tree is validated the way `foldrun check` validates it — a step naming an
43
38
  agent that does not exist, a tool that resolves to nothing, a knowledge
44
- concept with no `type:` — and a push with issues is refused with the list.
45
- Git says so at push time; the workspace on disk is unchanged.
39
+ concept with no `type:` — and a deploy with issues is refused with the list.
40
+ The workspace on disk is unchanged.
46
41
 
47
42
  ## What a deploy never touches
48
43
 
49
44
  `runs/`, `state/`, `secrets.json`, and any memory an agent wrote that the
50
- push does not mention. A deploy that reverted what an agent learned would
45
+ deploy does not mention. A deploy that reverted what an agent learned would
51
46
  make every run a little dumber, and a deploy that reset a cursor would make
52
47
  the next run redo a fortnight.
53
48
 
54
49
  ## Runs in flight
55
50
 
56
- A push while a run is in flight — including one parked at an approval gate
57
- — is **accepted but not applied**. Git warns at push time, and the platform
58
- applies it the moment those runs finish. Swapping files under a running flow
51
+ A deploy while a run is in flight — including one parked at an approval gate
52
+ — is refused, saying which runs hold it. Swapping files under a running flow
59
53
  would mean step 3 reads agents step 1 never saw. `foldrun deploy --force`
60
54
  overrides that when you mean it.
61
55
 
62
56
  ## History and rollback
63
57
 
64
- Every change to a workspace is a revision — a push, a dashboard edit, an
65
- agent's memory write — with who, when, and a diff, on the workspace's
66
- History page and at `/api/workspaces/<ws>/history`. A deploy's revision id
67
- is its commit.
58
+ Every change to a workspace is a revision — a deploy, a dashboard edit, an
59
+ import — with who, when and a diff, on the workspace's History page and at
60
+ `/api/workspaces/<ws>/history`. Every revision also keeps a snapshot of the
61
+ whole source as it stood just after it: the platform's own deploy history,
62
+ so rolling back never needs a repository.
68
63
 
69
- A rollback is a deploy of an earlier ref (`POST …/repo {action: deploy, ref}`
70
- or the History page), and it lands as a **new commit on `main`** — history
71
- moves forward, never rewrites. **Preview** beside "Deploy this commit" (and
72
- "Preview deploy" beside a branch's "Merge & deploy" on the Repository page)
64
+ A rollback is **Roll back to this** on a History row, or
65
+ `foldrun restore <ws> --to <revision|time|age>`, and it lands as a **new
66
+ revision** — history moves forward, never rewrites. **Preview** beside it
73
67
  shows first what it would change, add and remove, with diffs, and any run in
74
68
  flight that would hold it — the restore dry run
75
- (`POST …/restore {to, dryRun: true}`), which is the same operation.
76
-
77
- ## Mirroring
78
-
79
- A workspace can mirror to a repository you host — GitHub, GitLab — over
80
- https with a token held as the `MIRROR_TOKEN` secret. The platform's remote
81
- stays the mechanism; the mirror is the copy you read elsewhere.
69
+ (`POST …/restore {to, dryRun: true}`), which is the same operation. See
70
+ [Backups and restore](backups-and-restore).
package/docs/flows.md CHANGED
@@ -338,7 +338,7 @@ normal outcome, recorded as one.
338
338
  | `sla` | how long a run is expected to take; past it, one notification |
339
339
  | `approvers` | who may decide this flow's gates — addresses, or `admins` |
340
340
  | `approve_within` | how long a gate waits before the run is rejected |
341
- | `live` | `true` to make this flow's runs real where the platform would otherwise make them [test runs](runs#test-runs): in a preview workspace, and as an eval's subject. Only ever an opt-out of the safe default |
341
+ | `live` | `true` to make this flow's runs real where the platform would otherwise make them [test runs](runs#test-runs): as an eval's subject. Only ever an opt-out of the safe default |
342
342
 
343
343
  Every duration here is written the way `wait:` is — `45s`, `10m`, `2h`, `3d`,
344
344
  or bare seconds — and capped at thirty days.
@@ -481,12 +481,11 @@ almost never means "run two". `queue` starts it but holds it until the live one
481
481
  finishes. Unset, runs may overlap.
482
482
 
483
483
  `priority:` decides where the flow's runs stand when the platform has more
484
- runs than slots. `high` is claimed before the round-robin, `low` after it,
485
- `normal` (unset) takes its turn. It adds to the account's own `priority:`
486
- (the plan's lane, set in the account's `AGENTS.md`), so a high flow on a low
487
- account is normal. The account's `concurrency:` still bounds how many of its
488
- runs are live at once — a high lane jumps the queue, it does not hold the
489
- fleet.
484
+ runs than slots. Accounts take turns first, weighted by lane (see [Budgets
485
+ and billing](budgets-and-billing)), and among runs level on that, `high` goes before `normal` (unset) and `normal`
486
+ before `low`. It adds to the account's lane, so a high flow on a low account
487
+ is normal. The account's `concurrency:` still bounds how many of its runs are
488
+ live at once.
490
489
 
491
490
  ## Model resolution
492
491
 
package/docs/glossary.md CHANGED
@@ -20,8 +20,8 @@ checks and headlines read.
20
20
  a blog desk, a Reddit desk — its agents and flows, running on a schedule.
21
21
  Nothing in the format is called a desk; it is a workspace.
22
22
 
23
- **deploy** — pushing a workspace's source tree to the platform, by
24
- `foldrun deploy` or `git push`. Never touches runs, state, secrets or memory.
23
+ **deploy** — sending a workspace's source tree to the platform with
24
+ `foldrun deploy`. Never touches runs, state, secrets or memory.
25
25
 
26
26
  **eval** — a test for an agent or a flow: a task and what a good answer
27
27
  must contain. Runs on every push.
@@ -34,7 +34,7 @@ The design bets on three things:
34
34
  | **Locally** | `npm install -g foldrun` and a model key | everything on your machine; no account, no server |
35
35
  | **Hosted** | an account | ours: schedules, sandboxes, a vault, a team |
36
36
 
37
- The same workspace moves between them with `foldrun deploy` or a `git push`.
37
+ The same workspace moves between them with `foldrun deploy`.
38
38
  Start locally; that is where every workspace should be born. The CLI and
39
39
  runtime are free from npm; the platform is not distributed — see
40
40
  [Running it yourself](self-hosting).
package/docs/runs.md CHANGED
@@ -16,7 +16,8 @@ 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
+ | `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 |
20
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)) |
21
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 |
22
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 |
@@ -47,13 +48,15 @@ one bar per step with its tool calls marked in milliseconds, so a slow run
47
48
  says which step was slow. Every step shows its instruction, what it was
48
49
  handed from earlier groups, what it returned, its cost, and how many times it
49
50
  reached for a path it was not allowed and was refused (see [Security](security)).
51
+ A **start-up** chip in a step's header says how long it took the step to
52
+ reach the model's first word; hover it for each phase's share.
50
53
 
51
54
  The page follows the run as it moves, gates included, without a reload: a
52
55
  **live** dot beside the status says the stream is connected (amber while it
53
56
  reconnects), and the browser tab reads the state and the step — `● running ·
54
57
  step 3/7` — so you can switch away and still see where it is. Only the step
55
58
  that is happening is open; a finished step folds to one line with its
56
- 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.
57
60
  Click a step to pin it open or shut, or use **expand all** / **collapse
58
61
  all** above the column. A **json** link opens the run record exactly as the
59
62
  runner wrote it.
@@ -124,9 +127,8 @@ cannot reach it.
124
127
  Start one with the **Test run** checkbox on the Run button, `foldrun run
125
128
  <flow> --test` locally, `foldrun invoke <flow> --test` on a platform, or
126
129
  `POST .../flows/<flow>/run` with `{ "test": true }`. An eval's runs are test
127
- runs, and so is every run in a preview workspace (`<ws>-preview-<branch>`),
128
- unless the eval or the flow says `live: true` in its frontmatter — the safe
129
- default is opted out of, never into. A re-run of a test run, or a run started
130
+ runs unless the eval or the flow says `live: true` in its frontmatter — the
131
+ safe default is opted out of, never into. A re-run of a test run, or a run started
130
132
  from one of its steps, stays a test.
131
133
 
132
134
  What the platform does on a test run, in the order a step meets it:
@@ -51,8 +51,7 @@ foldrun login
51
51
  foldrun deploy # this folder → a workspace with the same name
52
52
  ```
53
53
 
54
- Or push it: every workspace on the platform is a git remote. Nothing about
55
- the folder changes. What the platform adds is the operating around it —
54
+ Nothing about the folder changes. What the platform adds is the operating around it —
56
55
  [Scheduling](scheduling), [Runs](runs) in gVisor sandboxes,
57
56
  [Secrets](secrets) in a vault, [Notifications](notifications),
58
57
  [Budgets and billing](budgets-and-billing), [Team and access](team-and-access).
@@ -61,6 +60,6 @@ the folder changes. What the platform adds is the operating around it —
61
60
 
62
61
  On your machine: in the folder. On the platform: your workspace's files and
63
62
  every run's record, on our data volume and in the file store, readable
64
- through the dashboard and the API, exportable as a git clone. A deploy never
63
+ through the dashboard and the API, exportable as a .zip (`foldrun export`). A deploy never
65
64
  touches what an agent learned or a run recorded. See [Storage and
66
65
  sharing](storage-and-sharing) and [Security](security).
@@ -44,17 +44,16 @@ connection, or returned as a link to pass on yourself.
44
44
  A key is minted with a role (`editor` unless you say otherwise) and acts as
45
45
  the account with that role. It can never be wider than the person who made
46
46
  it: not a higher role, and not a workspace they cannot open — a key minted
47
- by someone scoped to two workspaces is scoped to those two. The git remote
48
- honours the same scope: pushing needs write on that workspace, and pushing
49
- the library needs admin. Two things a key cannot do: act against a named
47
+ by someone scoped to two workspaces is scoped to those two. Two things a key cannot do: act against a named
50
48
  member — inviting, re-roling, removing, transferring need a signed-in person,
51
49
  because a key names an account and never a who — and exceed its role.
52
50
  A scoped admin sees and revokes only keys inside its own scope; a key wider
53
51
  than that, the owner's included, is not in its list.
54
52
 
55
- **Deploy keys** are keys scoped to one workspace's git remote, read or write.
56
- They clone and push that workspace and are refused everywhere else, which is
57
- what a CI job or a contractor's checkout should hold.
53
+ A CI job or a contractor should hold a key that opens one workspace —
54
+ `foldrun keys create ci --workspaces leads`, or Settings → API keys. (Deploy
55
+ keys, for the platform's own git, went with it on 17 October 2026; one
56
+ minted before opens nothing.)
58
57
 
59
58
  A key **is** its account: the platform reads the account off the key, so one
60
59
  account's key can never reach another's workspaces. Someone who looks after
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`.
@@ -219,11 +219,10 @@ regenerated every run is `storage/`; a value accumulated across runs is
219
219
 
220
220
  ## What a deploy touches
221
221
 
222
- A deploy — `foldrun deploy`, or a `git push` to the workspace's remote —
223
- replaces the source tree: agents, flows, tools, skills, evals, knowledge,
222
+ A deploy — `foldrun deploy` — replaces the source tree: agents, flows, tools, skills, evals, knowledge,
224
223
  `AGENTS.md`. It **never** touches `runs/`, `state/`, `secrets.json`, or any
225
- memory an agent wrote that your push does not mention. Those belong to the
226
- platform, not to git, and a deploy that reverted what an agent learned would
224
+ memory an agent wrote that your deploy does not mention. Those belong to the
225
+ platform, not to your source, and a deploy that reverted what an agent learned would
227
226
  make every run a little dumber.
228
227
 
229
228
  The agent engine's own files are not workspace content either. Each step
@@ -234,14 +233,9 @@ They stay in the sandbox: never copied back, never copied in. (Your own
234
233
  `.claude/agents/*.md` at the workspace root is different — a Claude Code
235
234
  subagent you wrote, deployed as an agent.)
236
235
 
237
- A push while a run is in flight is accepted and applied when the run
238
- finishes; git tells you so at push time. A run parked at an approval gate
239
- counts as in flight. See [Deploying](deploying).
236
+ A deploy while a run is in flight is refused, saying which runs hold it; a
237
+ run parked at an approval gate counts as in flight. See [Deploying](deploying).
240
238
 
241
- ## Previews
242
-
243
- Push a branch instead of `main` and the platform deploys a copy of the
244
- workspace named `<workspace>-preview-<branch>` from that branch's tree, runs
245
- its evals there, and never fires its schedules. It reads the source
246
- workspace's secrets. Deleting the branch deletes the preview. It is how a
247
- change to a live desk gets tried without touching the live desk.
239
+ Trying a change to a live desk without touching it is a second workspace —
240
+ `foldrun deploy --to <workspace>-try`. Branch previews come back with the
241
+ GitHub and GitLab app.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "foldrun",
3
- "version": "1.3.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": "^1.4.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",
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.
@@ -3736,8 +3778,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
3736
3778
  * asks "is this your terminal?"; the person says yes; the key that makes
3737
3779
  * lands here and is kept in ~/.foldrun/credentials.json. No key to copy,
3738
3780
  * nothing pasted into a shell history. `--token` skips the browser and
3739
- * stores a key made in the dashboard — for a machine with no browser, or a
3740
- * deploy key for one workspace.
3781
+ * stores a key made in the dashboard — for a machine with no browser.
3741
3782
  */
3742
3783
  async function login(flags) {
3743
3784
  const url = normaliseUrl(flags.url ?? env("FOLDRUN_URL") ?? defaultPlatform() ?? DEFAULT_PLATFORM);
@@ -3972,8 +4013,7 @@ async function doctor(flags) {
3972
4013
  *
3973
4014
  * keys ls every key, live and revoked
3974
4015
  * keys create <label> [--role r] an account key (editor unless said)
3975
- * keys create <label> --for <ws> [--access read|write]
3976
- * a deploy key: git clone/push for one workspace
4016
+ * keys create <label> --workspaces <ws> one that opens that workspace alone
3977
4017
  * keys revoke <id>
3978
4018
  * keys create <label> --expires 90d it stops working in 90 days (30d, 365d, never)
3979
4019
  * keys rotate <id> [--grace 1h] a new key, same label/role/scope; the old
@@ -3997,7 +4037,8 @@ async function keysCmd(positional, flags) {
3997
4037
  console.log("");
3998
4038
  const day = (iso) => (iso ? iso.slice(0, 10) : "");
3999
4039
  for (const k of keys) {
4000
- const what = k.scope ? `deploy · ${k.scope.workspace} (${k.scope.access})` : `${k.role ?? "admin"} · ${k.workspaces ? k.workspaces.join(",") || "no workspaces" : "all workspaces"}`;
4040
+ // A deploy key opened the platform's own git, gone since 17 Oct 2026.
4041
+ const what = k.scope ? "deploy key · opens nothing since the platform's git went — revoke it" : `${k.role ?? "admin"} · ${k.workspaces ? k.workspaces.join(",") || "no workspaces" : "all workspaces"}`;
4001
4042
  const expired = !k.revokedAt && k.expiresAt && Date.parse(k.expiresAt) <= Date.now();
4002
4043
  const state = k.revokedAt ? c.red("revoked") : expired ? c.red("expired") : c.green("live");
4003
4044
  console.log(` ${state.padEnd(20)} ${k.id} ${k.prefix}… ${c.bold(k.label)} ${c.dim(what)}${k.createdBy ? c.dim(` by ${k.createdBy}`) : ""}`);
@@ -4012,8 +4053,6 @@ async function keysCmd(positional, flags) {
4012
4053
  if (verb === "create" || verb === "new") {
4013
4054
  if (!arg) throw new Error("what is the key for? foldrun keys create <label>");
4014
4055
  const body = { label: arg };
4015
- if (typeof flags.for === "string") body.workspace = flags.for;
4016
- if (typeof flags.access === "string") body.access = flags.access;
4017
4056
  if (typeof flags.role === "string") body.role = flags.role;
4018
4057
  // --expires 30d|90d|365d|never; unsaid, the key never expires.
4019
4058
  if (typeof flags.expires === "string") body.expires = flags.expires;
@@ -4884,6 +4923,10 @@ async function workspacesCmd(positional, flags, layout) {
4884
4923
  );
4885
4924
  return reach ? 1 : 0;
4886
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}`);
4887
4930
  if (!all.length) {
4888
4931
  console.log(`\n ${c.dim("no workspaces — `foldrun new <name>` makes one")}\n`);
4889
4932
  return 0;
@@ -6211,10 +6254,34 @@ function filesWritten(run) {
6211
6254
  return [...out].filter(Boolean);
6212
6255
  }
6213
6256
 
6257
+ /** Trim what prose puts around a URL: trailing markdown/punctuation and a
6258
+ * trailing `)` that nothing in the URL opened — so `(url)` comes back bare,
6259
+ * but a wikipedia `..._(bar)` keeps its balanced `)`. `[` and `]` are not
6260
+ * URL chars, so a markdown `](url)` never carries a bracket in. */
6261
+ function trimUrl(u) {
6262
+ let prev;
6263
+ do {
6264
+ prev = u;
6265
+ u = u.replace(/[.,;:!?*_~`]+$/, "");
6266
+ if (u.at(-1) === ")" && u.split(")").length > u.split("(").length) u = u.slice(0, -1);
6267
+ } while (u !== prev);
6268
+ return u;
6269
+ }
6270
+
6214
6271
  /** Every address a run said it published to, from its summary and conclusions. */
6215
- function published(run) {
6272
+ export function published(run) {
6216
6273
  const text = [run.summary ?? "", ...(run.steps ?? []).map((s) => s.conclusion ?? s.result ?? "")].join("\n");
6217
- return [...new Set((text.match(/https?:\/\/[^\s)"'<>\]]+/g) ?? []).map((u) => u.replace(/[.,;]+$/, "")))];
6274
+ return [...new Set((text.match(/https?:\/\/[^\s"'<>`\[\]]+/g) ?? []).map(trimUrl).filter(Boolean))];
6275
+ }
6276
+
6277
+ /** How long the step took to reach the model's first word — the record's
6278
+ * `startup` phases (seconds since the step started); null before 17 Oct
6279
+ * 2026 and for in-process steps. */
6280
+ export function startupLabel(step) {
6281
+ const ph = step.startup;
6282
+ if (!ph || typeof ph !== "object") return null;
6283
+ const end = typeof ph.model === "number" ? ph.model : Math.max(...Object.values(ph).filter((v) => typeof v === "number"));
6284
+ return Number.isFinite(end) ? `start-up ${end.toFixed(1)}s` : null;
6218
6285
  }
6219
6286
 
6220
6287
  /** Which runner image a step ran on, as the run page's chip says it:
@@ -6271,6 +6338,36 @@ const stepError = (step) => step.error ?? [...(step.tries ?? [])].reverse().find
6271
6338
  * before it. Then the three things a person goes looking for afterwards:
6272
6339
  * what it wrote, what it published, and what is still waiting on them.
6273
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
+
6274
6371
  async function reportCmd(runId, flags, layout) {
6275
6372
  const url = platformFor(flags, "report");
6276
6373
  if (!runId) throw new Error("which run? `foldrun report <run-id>` — `foldrun runs` lists them");
@@ -6282,10 +6379,7 @@ async function reportCmd(runId, flags, layout) {
6282
6379
  return run.status === "failed" ? 1 : 0;
6283
6380
  }
6284
6381
 
6285
- const tokens = (run.steps ?? []).reduce(
6286
- (t, s) => ({ input: t.input + (s.tokens?.input ?? 0), output: t.output + (s.tokens?.output ?? 0) }),
6287
- { input: 0, output: 0 },
6288
- );
6382
+ const tokens = sumTokens((run.steps ?? []).map((s) => s.tokens));
6289
6383
  const cost = runCost(run);
6290
6384
 
6291
6385
  const blocked = run.status === "completed" && verdictOf(run) === "BLOCKED";
@@ -6293,7 +6387,8 @@ async function reportCmd(runId, flags, layout) {
6293
6387
  console.log(` ${c.dim(`${ws} · ${run.status}`)}${verdictTag(run) ? ` ${verdictTag(run)}` : ""}${c.dim(run.test ? " · test run" : "")}`);
6294
6388
  console.log(` ${c.dim(`started ${when(run.startedAt)} (${ago(run.startedAt)} ago) · ${runDuration(run)} · $${cost.toFixed(4)}`)}`);
6295
6389
  if (tokens.input || tokens.output) {
6296
- 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}` : ""}`)}`);
6297
6392
  }
6298
6393
  if (run.budgetUsd) console.log(` ${c.dim(`budget $${Number(run.budgetUsd).toFixed(2)}`)}`);
6299
6394
  if (run.summary) {
@@ -6307,7 +6402,9 @@ async function reportCmd(runId, flags, layout) {
6307
6402
  step.status,
6308
6403
  step.attempts > 1 ? `${step.attempts} attempts` : null,
6309
6404
  imageLabel(step),
6405
+ startupLabel(step),
6310
6406
  step.costUsd ? `$${step.costUsd.toFixed(4)}` : null,
6407
+ cachedLabel(cachedShare(step.tokens)),
6311
6408
  step.startedAt && step.finishedAt
6312
6409
  ? humanDuration(Date.parse(step.finishedAt) - Date.parse(step.startedAt))
6313
6410
  : null,
@@ -8170,6 +8267,8 @@ function sinceDays(flags, fallback) {
8170
8267
  }
8171
8268
 
8172
8269
  const secs = (n) => (n == null ? "—" : humanDuration(n * 1000));
8270
+ /** Seconds that are often under one — start-up phases. */
8271
+ const fine = (n) => (n == null ? "—" : n < 1 ? `${Math.round(n * 1000)}ms` : `${n.toFixed(2)}s`);
8173
8272
  const pct = (a, b) => (b ? `${Math.round((a / b) * 100)}%` : "—");
8174
8273
 
8175
8274
  /** One workspace's observability report, as the Observe page reads it. */
@@ -8184,8 +8283,11 @@ function printObservation(o) {
8184
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(" ")}`);
8185
8284
  };
8186
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)}`]));
8187
- 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)}`]));
8188
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(", ")]));
8288
+ // Where a step's first seconds go, phase by phase (FOL-29): each phase's
8289
+ // own share, and when it was done counted from the step's start.
8290
+ 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)]));
8189
8291
  const fails = (o.failures ?? []).slice(0, 8);
8190
8292
  if (fails.length) {
8191
8293
  console.log(`\n ${c.dim("recent failures")}`);
@@ -8262,7 +8364,8 @@ async function usageCmd(flags) {
8262
8364
  const t = u.totals ?? {};
8263
8365
  const usd = (n) => `$${Number(n ?? 0).toFixed(2)}`;
8264
8366
  console.log(`\n ${c.bold(u.tenant ?? "usage")} ${c.dim("what the run records hold")}`);
8265
- 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`);
8266
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)}` : ""}`)}`);
8267
8370
  const ws = (u.workspaces ?? []).slice().sort((a, b) => b.modelUsd + b.platformUsd - (a.modelUsd + a.platformUsd));
8268
8371
  if (ws.length) {
@@ -8270,7 +8373,7 @@ async function usageCmd(flags) {
8270
8373
  console.log();
8271
8374
  for (const x of ws) {
8272
8375
  const stored = (x.storage?.sourceBytes ?? 0) + (x.storage?.filesBytes ?? 0) + (x.storage?.runsBytes ?? 0);
8273
- 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))}`);
8274
8377
  }
8275
8378
  }
8276
8379
  console.log(`\n ${c.bold("charged")} ${c.dim(`${h.from?.slice(0, 10)} → ${h.to?.slice(0, 10)}, by ${h.bucket} · the ledger`)}`);
@@ -8708,85 +8811,6 @@ async function historyCmd(positional, flags, layout) {
8708
8811
  return 0;
8709
8812
  }
8710
8813
 
8711
- /**
8712
- * `foldrun repo ls | diff <branch> | deploy <ref> | merge <branch>` — the
8713
- * Repository page: branches, what one changes, deploy any commit (a rollback
8714
- * is a new commit on main), merge a branch into main and deploy it.
8715
- */
8716
- async function repoCmd(positional, flags, layout) {
8717
- const url = platformFor(flags, "repo");
8718
- const ws = platformWorkspace(flags, layout, "repo");
8719
- const [verb = "ls", arg] = positional;
8720
- const base = `/api/workspaces/${enc(ws)}/repo`;
8721
- if (verb === "ls" || verb === "list") {
8722
- const r = await remoteCall(url, flags, base);
8723
- console.log(`\n ${c.bold(ws)} ${c.dim(`main ${r.main ? String(r.main).slice(0, 7) : "— nothing committed yet"}`)}`);
8724
- const refs = (title, list) => {
8725
- if (!list?.length) return;
8726
- console.log(`\n ${c.dim(title)}`);
8727
- const w = Math.max(...list.map((b) => b.name.length));
8728
- for (const b of list) console.log(` ${b.name === "main" ? c.green("●") : c.dim("○")} ${c.bold(pad(b.name, w))} ${c.dim(String(b.sha).slice(0, 7))} ${firstLine(b.subject, 60)} ${c.dim(b.at ? `${ago(b.at)} ago` : "")}`);
8729
- };
8730
- refs("branches", r.branches);
8731
- refs("tags", r.tags);
8732
- if (r.settings?.mirror) console.log(`\n ${c.dim(`mirror ${r.settings.mirror}${r.mirror?.at ? ` · last push ${ago(r.mirror.at)} ago${r.mirror.ok === false ? " (failed)" : ""}` : ""}`)}`);
8733
- console.log(`\n ${c.dim(`foldrun repo diff <branch> · repo merge <branch> · repo deploy <ref> --to ${ws}`)}\n`);
8734
- return 0;
8735
- }
8736
- if (verb === "diff") {
8737
- if (!arg) throw new Error("`foldrun repo diff <branch> --to <workspace>` — what it changes against main");
8738
- const r = await remoteCall(url, flags, `${base}?branch=${enc(arg)}`);
8739
- if (!r.files?.length) console.log(`\n ${c.dim(`${arg} changes nothing against main`)}\n`);
8740
- console.log();
8741
- for (const f of r.files ?? []) {
8742
- printOps(f.path, f.diff ?? []);
8743
- console.log();
8744
- }
8745
- return 0;
8746
- }
8747
- if (verb === "rm-branch") {
8748
- if (!arg || arg === "main") throw new Error("`foldrun repo rm-branch <branch> --to <workspace>` — any branch but main");
8749
- if (!(await sureToDelete(flags, "repo rm-branch", `branch ${arg} in ${ws}`))) return 1;
8750
- await remoteCall(url, flags, base, { method: "POST", body: JSON.stringify({ action: "delete-branch", branch: arg }) });
8751
- console.log(`\n ${c.green("✓")} branch ${arg} deleted ${c.dim(`in ${ws}`)}\n`);
8752
- return 0;
8753
- }
8754
- if (verb === "mirror") {
8755
- if (!arg) throw new Error("`foldrun repo mirror <git-url> --to <workspace>` (every push to main goes there too, with the workspace's MIRROR_TOKEN) or `repo mirror off`");
8756
- const r = await remoteCall(url, flags, base, { method: "POST", body: JSON.stringify({ action: "mirror", url: arg === "off" ? null : arg }) });
8757
- console.log(`\n ${c.green("✓")} ${r.settings?.mirror ? `main is mirrored to ${r.settings.mirror}` : "no mirror"} ${c.dim(`· ${ws}${r.settings?.mirror ? " · `foldrun repo mirror-now` pushes it now" : ""}`)}\n`);
8758
- return 0;
8759
- }
8760
- if (verb === "mirror-now") {
8761
- const r = await remoteCall(url, flags, base, { method: "POST", body: JSON.stringify({ action: "mirror-now" }) });
8762
- const st = r.status;
8763
- if (!st) {
8764
- console.log(`\n ${c.dim(`${ws} has no mirror — \`foldrun repo mirror <git-url> --to ${ws}\` sets one`)}\n`);
8765
- return 1;
8766
- }
8767
- console.log(`\n ${st.ok ? c.green("✓") : c.red("✗")} mirror ${st.ok ? `has ${String(st.sha ?? "").slice(0, 7)}` : "push failed"}${st.detail ? ` — ${st.detail}` : ""} ${c.dim(ws)}\n`);
8768
- return st.ok ? 0 : 1;
8769
- }
8770
- if (verb !== "deploy" && verb !== "merge") throw new Error(`unknown repo verb "${verb}" — ls, diff <branch>, deploy <ref>, merge <branch>, rm-branch <branch>, mirror <url|off>, mirror-now`);
8771
- if (!arg) throw new Error(verb === "deploy" ? "`foldrun repo deploy <commit|branch|tag> --to <workspace>`" : "`foldrun repo merge <branch> --to <workspace>`");
8772
- if (verb === "merge" && arg === "main") throw new Error("main is what a branch merges into — name the branch");
8773
- console.log(
8774
- `\n ${c.yellow("!")} ${verb === "deploy" ? `deploying ${c.bold(arg)} makes it what is live in ${ws} — a new commit on main, which can itself be undone` : `merging ${c.bold(arg)} into main deploys the result to ${ws}`}`,
8775
- );
8776
- if (!(await confirmed(flags, `repo ${verb}`, `${verb === "deploy" ? "Deploy" : "Merge"} it? [y/N] `))) {
8777
- console.log(`\n ${c.dim(`nothing ${verb === "deploy" ? "deployed" : "merged"}`)}\n`);
8778
- return 1;
8779
- }
8780
- const r = await remoteCall(url, flags, base, { method: "POST", body: JSON.stringify(verb === "deploy" ? { action: "deploy", ref: arg } : { action: "merge", branch: arg }) });
8781
- if (r.applied) {
8782
- console.log(`\n ${c.green("✓")} ${verb === "deploy" ? `${arg} is live` : `${arg} merged and live`} ${c.dim(`in ${ws} · main ${String(r.main ?? r.sha ?? "").slice(0, 7)}`)}\n`);
8783
- return 0;
8784
- }
8785
- console.log(`\n ${c.red("✗")} not deployed${r.blockedBy ? ` — a run is in flight (${Array.isArray(r.blockedBy) ? r.blockedBy.join(", ") : r.blockedBy})` : ""}`);
8786
- for (const i of r.issues ?? []) console.log(` ${c.red("error")} ${c.bold(i.where)} ${i.message}`);
8787
- console.log();
8788
- return 1;
8789
- }
8790
8814
 
8791
8815
  /**
8792
8816
  * `foldrun onboarding` — the account's getting-started steps, each worked
@@ -8881,7 +8905,7 @@ async function backupsCmd(positional, flags) {
8881
8905
  }
8882
8906
  }
8883
8907
  if (b.workspaces?.length) {
8884
- console.log(`\n ${c.dim("restore a workspace's source yourself — foldrun restore <workspace> --to <commit|time|3d> --dry-run")}`);
8908
+ console.log(`\n ${c.dim("restore a workspace's source yourself — foldrun restore <workspace> --to <revision|time|3d> --dry-run")}`);
8885
8909
  const w = Math.max(...b.workspaces.map((x) => x.name.length));
8886
8910
  for (const x of b.workspaces) {
8887
8911
  console.log(` ${pad(x.name, w)} ${x.lastChange ? c.dim(`last change ${when(x.lastChange.at)} — ${firstLine(x.lastChange.message, 50)}`) : c.dim("no history yet")}`);
@@ -8895,8 +8919,12 @@ async function backupsCmd(positional, flags) {
8895
8919
  return 0;
8896
8920
  }
8897
8921
 
8922
+ /** A revision as the CLI prints it: a git sha shortened as git does, a
8923
+ * platform revision id (r-…) whole — it is what --to takes back. */
8924
+ const revShort = (id) => (/^[0-9a-f]{40}$/.test(String(id)) ? String(id).slice(0, 7) : String(id));
8925
+
8898
8926
  /**
8899
- * `foldrun restore <workspace> --to <commit|branch|tag|time|age> [--dry-run]`
8927
+ * `foldrun restore <workspace> --to <revision|time|age> [--dry-run]`
8900
8928
  * — the workspace's source as it was then. Always shows the change first;
8901
8929
  * the restore asks for the workspace's name typed back (or --yes), and
8902
8930
  * sends the removals it showed, so a file added in between refuses it.
@@ -8904,11 +8932,11 @@ async function backupsCmd(positional, flags) {
8904
8932
  async function restoreCmd(positional, flags) {
8905
8933
  const url = platformFor(flags, "restore");
8906
8934
  const ws = positional[0];
8907
- if (!ws) throw new Error("`foldrun restore <workspace> --to <commit|time|3d> [--dry-run]`");
8908
- if (typeof flags.to !== "string" || !flags.to.trim()) throw new Error("--to <commit, date or age> — the point to restore to, e.g. --to 2026-09-28T14:00 or --to 3d");
8935
+ if (!ws) throw new Error("`foldrun restore <workspace> --to <revision|time|3d> [--dry-run]`");
8936
+ if (typeof flags.to !== "string" || !flags.to.trim()) throw new Error("--to <revision, date or age> — the point to restore to, e.g. --to 2026-09-28T14:00 or --to 3d");
8909
8937
  const base = `/api/workspaces/${enc(ws)}/restore`;
8910
8938
  const plan = await remoteCall(url, flags, base, { method: "POST", body: JSON.stringify({ to: flags.to, dryRun: true }) });
8911
- console.log(`\n ${c.bold(ws)} → ${c.bold(String(plan.to.sha).slice(0, 7))} ${c.dim(`${when(plan.to.at)} · ${plan.to.by} · ${firstLine(plan.to.message, 60)}`)}`);
8939
+ console.log(`\n ${c.bold(ws)} → ${c.bold(revShort(plan.to.id ?? plan.to.sha))} ${c.dim(`${when(plan.to.at)} · ${plan.to.by} · ${firstLine(plan.to.message, 60)}`)}`);
8912
8940
  if (plan.noop) {
8913
8941
  console.log(` ${c.dim(`${ws} already matches that point — nothing to restore`)}\n`);
8914
8942
  return 0;
@@ -8934,13 +8962,13 @@ async function restoreCmd(positional, flags) {
8934
8962
  }
8935
8963
  const r = await remoteCall(url, flags, base, {
8936
8964
  method: "POST",
8937
- body: JSON.stringify({ to: plan.to.sha, confirm: ws, expectRemoved: plan.removed }),
8965
+ body: JSON.stringify({ to: plan.to.id ?? plan.to.sha, confirm: ws, expectRemoved: plan.removed }),
8938
8966
  });
8939
8967
  if (r.noop) {
8940
- console.log(`\n ${c.dim(`${ws} already matches ${String(plan.to.sha).slice(0, 7)}`)}\n`);
8968
+ console.log(`\n ${c.dim(`${ws} already matches ${revShort(plan.to.id ?? plan.to.sha)}`)}\n`);
8941
8969
  return 0;
8942
8970
  }
8943
- console.log(`\n ${c.green("✓")} ${ws} restored to ${String(r.to.sha).slice(0, 7)} ${c.dim(`· main ${String(r.main ?? "").slice(0, 7)} · undo: foldrun restore ${ws} --to ${String(r.from ?? "").slice(0, 7)}`)}\n`);
8971
+ console.log(`\n ${c.green("✓")} ${ws} restored to ${revShort(r.to.id ?? r.to.sha)} ${c.dim(`· main ${revShort(r.main ?? "")} · undo: foldrun restore ${ws} --to ${String(r.from ?? "")}`)}\n`);
8944
8972
  return 0;
8945
8973
  }
8946
8974
 
@@ -9426,7 +9454,9 @@ async function assistantCmd(positional, flags, layout) {
9426
9454
  if (d.usage) {
9427
9455
  const k = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
9428
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` : "";
9429
- 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`);
9430
9460
  }
9431
9461
  if (proposedWs) console.log(` ${c.bold("new workspace")} ${proposedWs.name} ${c.dim(`— ${proposedWs.purpose}`)}\n`);
9432
9462
  await showFiles();
@@ -10417,7 +10447,8 @@ export async function run(command, positional, flags, workspace, layout) {
10417
10447
  if (positional[0] === "restore") return historyRestoreCmd(positional, flags, layout);
10418
10448
  return historyCmd(positional, flags, layout);
10419
10449
  case "repo":
10420
- return repoCmd(positional, flags, layout);
10450
+ // The platform's own git went on 17 Oct 2026.
10451
+ throw new Error("`foldrun repo` is gone with the platform's own git — `foldrun history` lists a workspace's revisions and `foldrun restore <ws> --to <revision|time|age>` rolls back");
10421
10452
  case "restore":
10422
10453
  return restoreCmd(positional, flags);
10423
10454
  case "backups":