foldrun 1.3.0 → 2.0.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.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
 
@@ -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 and retries, per-tool calls/errors/latency, per-flow duration, day series, recent failures, and `startup` — one row per start-up phase in order, `{ phase, took, at }`, each `{ n, p50, p95, max }` in seconds (`took` is the phase's own share, `at` when it ended counted from the step's start; percentiles need five samples). `days` 1–365, default 30 (`since=`, its older name, still works — deprecated) |
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 |
@@ -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 |
@@ -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
package/docs/cli.md CHANGED
@@ -127,7 +127,7 @@ 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 |
130
+ | `foldrun observe` | where the account fails, retries and spends: one row per workspace — runs, failed and what share, steps, cost — then the recent failures in their own words. `--to <workspace>` prints that workspace in full, as the Observe page does: per flow (runs, failed, p50/p95 duration, cost), per agent (failed, retried, skipped, p95), per tool (calls, errors, p95 latency, which agents use it), and start-up — each phase of a step's first seconds, its own share and when it was done, p50/p95. `--since <days>` (default 30), `--json` for the document |
131
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 |
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`) |
@@ -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
@@ -17,6 +17,7 @@ Each run holds its steps in group order. A step carries:
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
19
  | `costUsd`, `tokens`, `computeSecs`, `startupSecs` | what it cost and how long the sandbox lived — **summed across its attempts** |
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,6 +48,8 @@ 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
@@ -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
@@ -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.0.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.0.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-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
@@ -3736,8 +3736,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
3736
3736
  * asks "is this your terminal?"; the person says yes; the key that makes
3737
3737
  * lands here and is kept in ~/.foldrun/credentials.json. No key to copy,
3738
3738
  * 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.
3739
+ * stores a key made in the dashboard — for a machine with no browser.
3741
3740
  */
3742
3741
  async function login(flags) {
3743
3742
  const url = normaliseUrl(flags.url ?? env("FOLDRUN_URL") ?? defaultPlatform() ?? DEFAULT_PLATFORM);
@@ -3972,8 +3971,7 @@ async function doctor(flags) {
3972
3971
  *
3973
3972
  * keys ls every key, live and revoked
3974
3973
  * 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
3974
+ * keys create <label> --workspaces <ws> one that opens that workspace alone
3977
3975
  * keys revoke <id>
3978
3976
  * keys create <label> --expires 90d it stops working in 90 days (30d, 365d, never)
3979
3977
  * keys rotate <id> [--grace 1h] a new key, same label/role/scope; the old
@@ -3997,7 +3995,8 @@ async function keysCmd(positional, flags) {
3997
3995
  console.log("");
3998
3996
  const day = (iso) => (iso ? iso.slice(0, 10) : "");
3999
3997
  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"}`;
3998
+ // A deploy key opened the platform's own git, gone since 17 Oct 2026.
3999
+ 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
4000
  const expired = !k.revokedAt && k.expiresAt && Date.parse(k.expiresAt) <= Date.now();
4002
4001
  const state = k.revokedAt ? c.red("revoked") : expired ? c.red("expired") : c.green("live");
4003
4002
  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 +4011,6 @@ async function keysCmd(positional, flags) {
4012
4011
  if (verb === "create" || verb === "new") {
4013
4012
  if (!arg) throw new Error("what is the key for? foldrun keys create <label>");
4014
4013
  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
4014
  if (typeof flags.role === "string") body.role = flags.role;
4018
4015
  // --expires 30d|90d|365d|never; unsaid, the key never expires.
4019
4016
  if (typeof flags.expires === "string") body.expires = flags.expires;
@@ -6211,10 +6208,34 @@ function filesWritten(run) {
6211
6208
  return [...out].filter(Boolean);
6212
6209
  }
6213
6210
 
6211
+ /** Trim what prose puts around a URL: trailing markdown/punctuation and a
6212
+ * trailing `)` that nothing in the URL opened — so `(url)` comes back bare,
6213
+ * but a wikipedia `..._(bar)` keeps its balanced `)`. `[` and `]` are not
6214
+ * URL chars, so a markdown `](url)` never carries a bracket in. */
6215
+ function trimUrl(u) {
6216
+ let prev;
6217
+ do {
6218
+ prev = u;
6219
+ u = u.replace(/[.,;:!?*_~`]+$/, "");
6220
+ if (u.at(-1) === ")" && u.split(")").length > u.split("(").length) u = u.slice(0, -1);
6221
+ } while (u !== prev);
6222
+ return u;
6223
+ }
6224
+
6214
6225
  /** Every address a run said it published to, from its summary and conclusions. */
6215
- function published(run) {
6226
+ export function published(run) {
6216
6227
  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(/[.,;]+$/, "")))];
6228
+ return [...new Set((text.match(/https?:\/\/[^\s"'<>`\[\]]+/g) ?? []).map(trimUrl).filter(Boolean))];
6229
+ }
6230
+
6231
+ /** How long the step took to reach the model's first word — the record's
6232
+ * `startup` phases (seconds since the step started); null before 17 Oct
6233
+ * 2026 and for in-process steps. */
6234
+ export function startupLabel(step) {
6235
+ const ph = step.startup;
6236
+ if (!ph || typeof ph !== "object") return null;
6237
+ const end = typeof ph.model === "number" ? ph.model : Math.max(...Object.values(ph).filter((v) => typeof v === "number"));
6238
+ return Number.isFinite(end) ? `start-up ${end.toFixed(1)}s` : null;
6218
6239
  }
6219
6240
 
6220
6241
  /** Which runner image a step ran on, as the run page's chip says it:
@@ -6307,6 +6328,7 @@ async function reportCmd(runId, flags, layout) {
6307
6328
  step.status,
6308
6329
  step.attempts > 1 ? `${step.attempts} attempts` : null,
6309
6330
  imageLabel(step),
6331
+ startupLabel(step),
6310
6332
  step.costUsd ? `$${step.costUsd.toFixed(4)}` : null,
6311
6333
  step.startedAt && step.finishedAt
6312
6334
  ? humanDuration(Date.parse(step.finishedAt) - Date.parse(step.startedAt))
@@ -8170,6 +8192,8 @@ function sinceDays(flags, fallback) {
8170
8192
  }
8171
8193
 
8172
8194
  const secs = (n) => (n == null ? "—" : humanDuration(n * 1000));
8195
+ /** Seconds that are often under one — start-up phases. */
8196
+ const fine = (n) => (n == null ? "—" : n < 1 ? `${Math.round(n * 1000)}ms` : `${n.toFixed(2)}s`);
8173
8197
  const pct = (a, b) => (b ? `${Math.round((a / b) * 100)}%` : "—");
8174
8198
 
8175
8199
  /** One workspace's observability report, as the Observe page reads it. */
@@ -8186,6 +8210,9 @@ function printObservation(o) {
8186
8210
  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
8211
  table("agents", ["agent", "steps", "failed", "retried", "skipped", "p95", "cost"], (o.agents ?? []).map((a) => [a.agent, a.steps, a.failed, a.retried, a.skipped, secs(a.seconds?.p95), `$${a.costUsd.toFixed(2)}`]));
8188
8212
  table("tools", ["tool", "calls", "errors", "p95", "used by"], (o.tools ?? []).map((t) => [t.name, t.calls, t.errors, t.ms?.p95 == null ? "—" : `${Math.round(t.ms.p95)}ms`, (t.agents ?? []).join(", ")]));
8213
+ // Where a step's first seconds go, phase by phase (FOL-29): each phase's
8214
+ // own share, and when it was done counted from the step's start.
8215
+ 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
8216
  const fails = (o.failures ?? []).slice(0, 8);
8190
8217
  if (fails.length) {
8191
8218
  console.log(`\n ${c.dim("recent failures")}`);
@@ -8708,85 +8735,6 @@ async function historyCmd(positional, flags, layout) {
8708
8735
  return 0;
8709
8736
  }
8710
8737
 
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
8738
 
8791
8739
  /**
8792
8740
  * `foldrun onboarding` — the account's getting-started steps, each worked
@@ -8881,7 +8829,7 @@ async function backupsCmd(positional, flags) {
8881
8829
  }
8882
8830
  }
8883
8831
  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")}`);
8832
+ console.log(`\n ${c.dim("restore a workspace's source yourself — foldrun restore <workspace> --to <revision|time|3d> --dry-run")}`);
8885
8833
  const w = Math.max(...b.workspaces.map((x) => x.name.length));
8886
8834
  for (const x of b.workspaces) {
8887
8835
  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 +8843,12 @@ async function backupsCmd(positional, flags) {
8895
8843
  return 0;
8896
8844
  }
8897
8845
 
8846
+ /** A revision as the CLI prints it: a git sha shortened as git does, a
8847
+ * platform revision id (r-…) whole — it is what --to takes back. */
8848
+ const revShort = (id) => (/^[0-9a-f]{40}$/.test(String(id)) ? String(id).slice(0, 7) : String(id));
8849
+
8898
8850
  /**
8899
- * `foldrun restore <workspace> --to <commit|branch|tag|time|age> [--dry-run]`
8851
+ * `foldrun restore <workspace> --to <revision|time|age> [--dry-run]`
8900
8852
  * — the workspace's source as it was then. Always shows the change first;
8901
8853
  * the restore asks for the workspace's name typed back (or --yes), and
8902
8854
  * sends the removals it showed, so a file added in between refuses it.
@@ -8904,11 +8856,11 @@ async function backupsCmd(positional, flags) {
8904
8856
  async function restoreCmd(positional, flags) {
8905
8857
  const url = platformFor(flags, "restore");
8906
8858
  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");
8859
+ if (!ws) throw new Error("`foldrun restore <workspace> --to <revision|time|3d> [--dry-run]`");
8860
+ 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
8861
  const base = `/api/workspaces/${enc(ws)}/restore`;
8910
8862
  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)}`)}`);
8863
+ 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
8864
  if (plan.noop) {
8913
8865
  console.log(` ${c.dim(`${ws} already matches that point — nothing to restore`)}\n`);
8914
8866
  return 0;
@@ -8934,13 +8886,13 @@ async function restoreCmd(positional, flags) {
8934
8886
  }
8935
8887
  const r = await remoteCall(url, flags, base, {
8936
8888
  method: "POST",
8937
- body: JSON.stringify({ to: plan.to.sha, confirm: ws, expectRemoved: plan.removed }),
8889
+ body: JSON.stringify({ to: plan.to.id ?? plan.to.sha, confirm: ws, expectRemoved: plan.removed }),
8938
8890
  });
8939
8891
  if (r.noop) {
8940
- console.log(`\n ${c.dim(`${ws} already matches ${String(plan.to.sha).slice(0, 7)}`)}\n`);
8892
+ console.log(`\n ${c.dim(`${ws} already matches ${revShort(plan.to.id ?? plan.to.sha)}`)}\n`);
8941
8893
  return 0;
8942
8894
  }
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`);
8895
+ 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
8896
  return 0;
8945
8897
  }
8946
8898
 
@@ -10417,7 +10369,8 @@ export async function run(command, positional, flags, workspace, layout) {
10417
10369
  if (positional[0] === "restore") return historyRestoreCmd(positional, flags, layout);
10418
10370
  return historyCmd(positional, flags, layout);
10419
10371
  case "repo":
10420
- return repoCmd(positional, flags, layout);
10372
+ // The platform's own git went on 17 Oct 2026.
10373
+ 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
10374
  case "restore":
10422
10375
  return restoreCmd(positional, flags);
10423
10376
  case "backups":