foldrun 1.0.0 → 1.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 +13 -5
- package/docs/agents.md +1 -0
- package/docs/api.md +26 -3
- package/docs/assistant.md +139 -0
- package/docs/cli.md +10 -4
- package/docs/flows.md +1 -1
- package/docs/network.md +158 -0
- package/docs/secrets.md +54 -0
- package/docs/security.md +14 -7
- package/docs/tools.md +6 -0
- package/package.json +3 -3
- package/src/commands.mjs +423 -4
- package/src/guide.mjs +1 -1
package/bin/foldrun.mjs
CHANGED
|
@@ -107,6 +107,7 @@ const HELP = `foldrun — agents are just folders
|
|
|
107
107
|
foldrun agent run <name> run one agent once on the platform (--task "…", --to, --wait, --test)
|
|
108
108
|
foldrun agent link <agent> add to its team, in its frontmatter: --subagent <worker>, --consult <agent> or --can-ask (--description for a worker with none)
|
|
109
109
|
foldrun agent unlink <agent> take one away: --subagent <name>, --consult <name> or --can-ask
|
|
110
|
+
foldrun agent network <agent> the sites its sandbox may reach; set the level (open, trusted, none, inherit) and --add / --remove <site> (repeatable)
|
|
110
111
|
foldrun agent import <workspace>/<agent> copy an agent you already have in another workspace into this one — its agent.md, own skills and scripts, not its memory (--as <name>, --workspace <name>; --to <ws> on the platform)
|
|
111
112
|
foldrun agent import --list what could be imported into a deployed workspace: every other workspace you can read, and its agents (--to, --json)
|
|
112
113
|
foldrun flow add <flow> <pattern> one canvas block as a markdown edit: chain, parallel, router, fan-out, loop, approval, ask, wait, rescue, subflow — diff shown, checked, --dry-run
|
|
@@ -122,6 +123,8 @@ const HELP = `foldrun — agents are just folders
|
|
|
122
123
|
foldrun flow copy-step <flow> --step <n|agent>[,<n>…] the steps' markdown on stdout, as the canvas's Copy
|
|
123
124
|
foldrun flow paste <flow> step markdown from stdin or --file in as new groups (--after <g>, default last) or beside one (--group <g>) — diff, checked, --dry-run
|
|
124
125
|
foldrun flow draft "<what it should do>" Draft with AI on the platform (--to; --flow <name> redrafts one): shows the files and the check, saves on yes (--yes, --dry-run)
|
|
126
|
+
foldrun assistant ["<message>"] the dashboard's assistant (also: foldrun ai): answers from the foldrun docs and the deployed workspace's files, writes agents, flows, tools and skills as diffs with the check, saves on yes (--to, --yes, --dry-run, --json); every reply is kept — --conversation <id> carries one on; no message in a terminal is a conversation (/save, /files, /drop, /new, /quit)
|
|
127
|
+
foldrun assistant history your kept conversations, newest first (--q <text> searches them, --workspace, --json); show <id> prints one, rm <id> deletes it
|
|
125
128
|
foldrun tool test <name> exercise one tool alone, no model and no run (key=value args, --to)
|
|
126
129
|
foldrun hook ls the hooks in this workspace and the account library: on, tool, description — invalid ones with the reason (--json); --to <ws>: the DEPLOYED workspace's, who turns each on, and what they denied, rewrote or failed lately (--runs <n>, --all)
|
|
127
130
|
foldrun hook add <name> --on <event> a hook folder: hook.md and a run.mjs that allows everything, the contract in its comments (--tool <pattern>, --library, --to)
|
|
@@ -184,7 +187,8 @@ const HELP = `foldrun — agents are just folders
|
|
|
184
187
|
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)
|
|
185
188
|
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]
|
|
186
189
|
foldrun backups requests every restore asked of the platform team, and where each stands (--json)
|
|
187
|
-
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
|
|
190
|
+
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
|
|
191
|
+
foldrun secrets sites NAME [site…] bind a secret to sites: the egress proxy fills it for those alone and it never enters a sandbox; no sites lists them, --clear unbinds
|
|
188
192
|
foldrun secrets clients the OAuth clients saved on the platform — also add <name> (--provider or --authorize-url/--token-url, --client-id, --scopes), rm <name>
|
|
189
193
|
foldrun connect NAME OAuth sign-in from the terminal, stored as an auto-refreshing secret — --client <saved> runs it from a saved client through the platform
|
|
190
194
|
foldrun deploy [dir] push the whole account, or deploy <workspace> for one of them
|
|
@@ -288,12 +292,16 @@ Options
|
|
|
288
292
|
--step <n> approve, reject: decide only that step; default is every step that is waiting
|
|
289
293
|
--payload <json|text|@f> approve: release a step waiting on wait: event with this body, recorded as you
|
|
290
294
|
--note "<text>" approve, reject: guidance the agent reads — or the reason for a refusal; billing credit: the ledger line's note
|
|
291
|
-
--yes approve, stop, rerun (bulk), flow rotate-hook, flow draft, history restore, restore <ws>, import, billing plan, repo, workspace set name, and every rm/revoke/unshare: skip the confirmation, deliberately (required with no terminal)
|
|
295
|
+
--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)
|
|
292
296
|
--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}
|
|
293
297
|
--events <a,b> account set notify: failed, awaiting-approval, completed
|
|
294
298
|
--failed webhooks deliveries: only the ones that gave up
|
|
295
299
|
--limit <n> runs, billing, history, changelog: how many rows
|
|
296
300
|
--value "<text>" secrets set: skip the prompt (careful with shell history)
|
|
301
|
+
--site <domain> secrets set, secrets sites: a site the secret is bound to — the egress proxy fills it for those alone (repeatable, or a,b)
|
|
302
|
+
--clear secrets sites: unbind it
|
|
303
|
+
--add <site> agent network: add a site to its network: list (repeatable)
|
|
304
|
+
--remove <site> agent network: take a site off its network: list (repeatable)
|
|
297
305
|
--out <file> api spec: where to write the OpenAPI document (default: stdout)
|
|
298
306
|
--file <path> source put, library put, flow paste: the local file to send (default: stdin); secrets set --kind file|service-account: the file it holds; storage get, report get, report live, account export, export, billing statement: where to write it (- for stdout)
|
|
299
307
|
--message "<why>" source put: recorded on the file's revision
|
|
@@ -331,7 +339,7 @@ Platform options (deploy, invoke, secrets, logs, keys)
|
|
|
331
339
|
--local deploy: into the installation on this machine, even when signed in
|
|
332
340
|
--commit <sha> deploy: record which commit this is
|
|
333
341
|
--overwrite import: replace files the workspace already has with different text (refused otherwise)
|
|
334
|
-
--dry-run deploy: check and report, change nothing; flow add, flow set, flow trigger, flow move-step, flow paste, flow draft, history restore, agent link, agent unlink: show the diff, write nothing; restore <ws>: the diff only, restore nothing; import: the preview only, write nothing; stop, rerun (bulk): list what matches, touch nothing
|
|
342
|
+
--dry-run deploy: check and report, change nothing; flow add, flow set, flow trigger, flow move-step, flow paste, flow draft, assistant, history restore, agent link, agent unlink: show the diff, write nothing; restore <ws>: the diff only, restore nothing; import: the preview only, write nothing; stop, rerun (bulk): list what matches, touch nothing
|
|
335
343
|
--no-runtimes deploy: do not wait for the workspace's environments to be built
|
|
336
344
|
--force deploy: deploy even while runs are in flight; pull, storage get: overwrite local files
|
|
337
345
|
--yes deploy: allow deleting files this folder no longer has (asked otherwise; required with no terminal)
|
|
@@ -352,9 +360,9 @@ if (!command || command === "--help" || command === "-h") {
|
|
|
352
360
|
// `--value` as the account's argument and stored an empty secret; `--force
|
|
353
361
|
// ./dir` swallowed the directory. A flag followed by another flag is also
|
|
354
362
|
// boolean, so an unlisted switch at least does not eat its neighbour.
|
|
355
|
-
const BOOLEAN_FLAGS = new Set(["account", "follow", "force", "oauth2", "wait", "watch", "print", "dry-run", "help", "no-browser", "local", "test", "flat", "yes", "platform", "json", "forever", "all", "check", "new-client", "quiet", "off", "can-ask", "preview", "csv", "failed", "starter", "history", "list", "library"]);
|
|
363
|
+
const BOOLEAN_FLAGS = new Set(["account", "follow", "force", "oauth2", "wait", "watch", "print", "dry-run", "help", "no-browser", "local", "test", "flat", "yes", "platform", "json", "forever", "all", "check", "new-client", "quiet", "off", "can-ask", "preview", "csv", "failed", "starter", "history", "list", "library", "clear"]);
|
|
356
364
|
// Flags said more than once collect into a list: `--tag a --tag b`.
|
|
357
|
-
const REPEATABLE = new Set(["tag", "expect", "header"]);
|
|
365
|
+
const REPEATABLE = new Set(["tag", "expect", "header", "site", "add", "remove"]);
|
|
358
366
|
// `--wait` is a switch everywhere but `flow add <flow> wait`, where it takes
|
|
359
367
|
// a span: `--wait 30m`, `--wait event`. Only a value that reads as one is
|
|
360
368
|
// taken, so `invoke x --wait` and friends are as they were.
|
package/docs/agents.md
CHANGED
|
@@ -65,6 +65,7 @@ the point of the week; a step that weighs two arguments and picks one is not.
|
|
|
65
65
|
| `mcpServers` | an MCP server declared inline |
|
|
66
66
|
| `secrets` | vault entries its tools may use — names only, never values |
|
|
67
67
|
| `limits` | the most calls one step may make — per tool, per web action, or in all: `limits: {web.search: 40, crm: 20, calls: 300}`. A call past a limit is refused, never run ([Tools](tools#limits--how-many-calls-a-step-may-make)) |
|
|
68
|
+
| `network` | which sites its sandbox may reach: `open`, `trusted` or `none`, or a list of sites added to the trusted list — `network: [api.hubapi.com]`. Unset, an agent that may fetch or browse the web is `open` and every other `trusted`. Also in a workspace's or the account's `AGENTS.md` ([Network](network)) |
|
|
68
69
|
| `permissionMode` | `plan` makes the run read-only, whatever else was granted |
|
|
69
70
|
| `web` | the `web` tool's settings: `actions:` — which of its eight it may use (all when absent) — and who does each, `web: {search: brave, fetch: jina, browse: browserbase, crawl: firecrawl}`, with the key named in the vault. Unset is foldrun's own. `search:` also takes a block of the own engine's settings — engines, categories, safesearch, plugins ([Tools](tools#tuning-the-accounts-own-engine)); `browse:` a block of the browser's ([below](#webbrowse--which-browser-and-how-it-presents-itself)), and `browse:` alone cascades from `AGENTS.md`. The providers and their keys: [Tools](tools#reaching-the-web). A per-action key outside the block is not read — `check` errors on it |
|
|
70
71
|
|
package/docs/api.md
CHANGED
|
@@ -65,7 +65,7 @@ api workspaces/$WS/flows/publish/run -X POST # start a flow
|
|
|
65
65
|
|
|
66
66
|
| Route | Methods | |
|
|
67
67
|
|---|---|---|
|
|
68
|
-
| `/api/workspaces` | GET, POST | list; create (`name`, plus one of: `template: true` — blank, just an AGENTS.md, what the dashboard sends; `starter: true` — the example researcher, writer and publish flow; `demo: true`; `files`) |
|
|
68
|
+
| `/api/workspaces` | GET, POST | list; create (`name`, plus one of: `template: true` — blank, just an AGENTS.md, what the dashboard sends; `starter: true` — the example researcher, writer and publish flow; `demo: true`; `files`). A POST to a name that exists deploys over it; add `ifAbsent: true` to create only — a name that exists is then `409` and nothing changes, decided atomically (the assistant's Create workspace and `foldrun assistant` send it) |
|
|
69
69
|
| `/api/workspaces/<ws>` | GET, PATCH, DELETE | read; rename or re-describe (`name`, `description`), or set its defaults (`timezone`, `budget`, `notify` — null clears one, edited in AGENTS.md in place); delete |
|
|
70
70
|
| `/api/workspaces/<ws>/vocabulary` | GET | which document kinds this workspace admits — its `docs` list is what `[[ ]]` resolves: knowledge, memory, `state/` and `storage/` files, plus the folders themselves |
|
|
71
71
|
|
|
@@ -145,12 +145,35 @@ Left out, `test` is decided by the platform:
|
|
|
145
145
|
a preview workspace's runs are tests unless the flow says `live: true`;
|
|
146
146
|
everywhere else a run is live.
|
|
147
147
|
|
|
148
|
+
## Assistant
|
|
149
|
+
|
|
150
|
+
| Route | Methods | |
|
|
151
|
+
|---|---|---|
|
|
152
|
+
| `/api/workspaces/<ws>/assistant` | POST | one reply from the assistant, which answers from the foldrun docs and this workspace's files only and writes agents, flows, tools and skills: `{ messages: [{ role: "user"\|"assistant", content }], files?: [{ path, content }], page?, conversation? }` — the conversation so far (the last message the person's, at most 8,000 characters), the files it proposed that are not saved yet, and the dashboard page being looked at. Returns `{ ok, reply, files: [{ path, content, before, sha256 }], issues: [{ where, message, level }], sources: [{ doc, section, url }], supply, workspace, usage, chargedUsd }` — `files` is every unsaved proposal as it now stands, checked; `workspace` is `{ name, purpose }` when it proposes a new workspace (free and kebab-case when proposed; create it with `POST /api/workspaces { name, template: true }`, then talk to the assistant there), else `null`; `usage` is `{ inputTokens, outputTokens, costUsd }` across every model call of the reply; `chargedUsd` what the account was charged for it on the platform's models (the same price as a run's tokens), `null` on its own provider; `conversation` is the id it was kept under — send it back as `conversation` to carry on (without one, a new conversation starts). **Writes nothing**; save with the source PUT (`ifMatch` the `sha256`, `null` for a new file). With `Accept: text/event-stream` it streams `status` (what it is reading), `text` (the reply as it is written), then `done` with the same body, or `error`. Needs `workspace:write`; 60 replies an hour per person or key, and on the platform's models 300 a day per account (`429`); at most 200 messages of 60,000 characters each (`413`). Closing the stream does not stop the reply: it finishes (at most $2, 30 tool calls, five minutes) and is charged; a Chat-Completions `provider:` is `422`. On the platform's models the balance is checked first (`402`) and each reply is one ledger line, flow `ai-assistant` |
|
|
153
|
+
| `/api/assistant/conversations?workspace=&q=&limit=` | GET | the asker's kept assistant conversations — a person's own, or a key's own — newest first, across the workspaces they may see: `{ conversations: [{ id, workspace, title, updatedAt, messages, unsaved }] }`. `q` searches the titles and every message; `workspace` narrows to one; `limit` 1–200 (50) |
|
|
154
|
+
| `/api/assistant/conversations/<id>` | GET, DELETE | one kept conversation, whole: `{ id, workspace, title, messages: [{ role, content, meta? }], files, createdAt, updatedAt }` (`meta` is what the dashboard draws under a reply: sources, tokens, charge, a proposed workspace); DELETE removes it. Anyone else's id is `404`, the same as none |
|
|
155
|
+
|
|
156
|
+
It may propose only `flows/<name>.md`, `agents/<name>/agent.md`,
|
|
157
|
+
`skills/<name>/SKILL.md` (and files under the skill's `scripts/`,
|
|
158
|
+
`references/` or `assets/`), and `tools/<name>/tool.md` with its program, or
|
|
159
|
+
`tools/<name>.md`. It reads the workspace's source and the account library,
|
|
160
|
+
never `state/`, `storage/`, runs or secrets. A proposal is checked with the
|
|
161
|
+
deploy's checks and the flow lint; a frontmatter key the docs never name, and
|
|
162
|
+
a value shaped like a credential, are errors.
|
|
163
|
+
|
|
164
|
+
A reply is not computed by the web: it is queued, and the platform's
|
|
165
|
+
assistant workers answer it — a pool of their own that scales with the
|
|
166
|
+
queue, so a burst of questions never slows the dashboard or takes a run's
|
|
167
|
+
turn. At most three replies of one account run at once. Each reply is kept
|
|
168
|
+
as a conversation private to whoever asked; the caller still sends the
|
|
169
|
+
history with each message.
|
|
170
|
+
|
|
148
171
|
## Agents and evals
|
|
149
172
|
|
|
150
173
|
| Route | Methods | |
|
|
151
174
|
|---|---|---|
|
|
152
175
|
| `/api/workspaces/<ws>/agents` | GET, POST | list; create (`name`) |
|
|
153
|
-
| `/api/workspaces/<ws>/agents/<agent>` | PATCH | edit one line of an agent's frontmatter in place, as the flow canvas does: `{ list: { key, name, action } }` — `key` one of `subagents`, `agents`, `tools`; `action` `add` or `remove` — or `{ set: { key: "description", value } }
|
|
176
|
+
| `/api/workspaces/<ws>/agents/<agent>` | PATCH | edit one line of an agent's frontmatter in place, as the flow canvas does: `{ list: { key, name, action } }` — `key` one of `subagents`, `agents`, `tools`; `action` `add` or `remove` — or `{ set: { key: "description", value } }`, or `{ network }` — the sites its sandbox may reach: `"open"`, `"trusted"`, `"none"`, a list of sites, or `{ level, allow, defaults }`; `null` removes the line so it inherits ([Network](network)). A `network` nobody could read is a 400 that says why. Only that key's lines change — comments, key order and prose stay. Answers `{ ok, changed }`; 404 for an unknown agent. `workspace:write` |
|
|
154
177
|
| `/api/workspaces/<ws>/agents/<agent>/run` | POST | run one agent alone (`task`; `test: true` for a test run) |
|
|
155
178
|
| `/api/workspaces/<ws>/agents/import` | GET, POST | copy an agent in from another workspace of the account. GET lists what could be imported (every other workspace you can read, with its agents). POST `{ from, agent, as? }` copies `agents/<agent>/` — its agent.md and own skills/, scripts/, knowledge/; never memory/, outputs/, state/ — and answers `{ name, files, warnings }`; `warnings` names tools, skills, scripts and secrets the copy uses that this workspace lacks. 409 on a name clash (pass `as`), 404 for an unknown workspace or agent, 403 when `from` is outside your key's scope. Idempotent |
|
|
156
179
|
| `/api/workspaces/<ws>/export` | GET | this workspace, one flow or one agent as a `.zip` — `?kind=flow&name=<flow>` (the flow, every flow it runs as a step, every agent those steps and their colleagues name, and the workspace tools, skills and scripts they grant), `?kind=agent&name=<agent>` (its folder and what it grants), or neither for the whole authored tree. Never memory/, state/, outputs/, storage, runs or a secret's value; `foldrun-package.json` inside says what it is. Any role that can read the workspace |
|
|
@@ -293,7 +316,7 @@ its header and requires a URL it can GET for itself.
|
|
|
293
316
|
|
|
294
317
|
| Route | Methods | |
|
|
295
318
|
|---|---|---|
|
|
296
|
-
| `/api/secrets?workspace=` | GET, PUT, DELETE | the vault: list which secrets exist (never a value); set one (`name` plus one shape, below); delete one (`name`). `workspace` names a workspace's own store; unsaid is the account's, which a key scoped to some workspaces may not touch. PUT is idempotent |
|
|
319
|
+
| `/api/secrets?workspace=` | GET, PUT, PATCH, DELETE | the vault: list which secrets exist (never a value), each with the `sites` it is bound to; set one (`name` plus one shape, below, and optionally `sites`); bind one to sites with PATCH `{ name, sites }` — `sites` a list like `["api.example.com", "*.example.com"]`, `[]` unbinds, the value untouched, 404 for a secret that is not there, 400 for a site with a scheme, path or port and for a file, ssh or api secret ([bound to sites](secrets#bound-to-sites)); delete one (`name`). `workspace` names a workspace's own store; unsaid is the account's, which a key scoped to some workspaces may not touch. PUT is idempotent; PATCH and a PUT with `sites` are audited as `secret.sites` / `secret.set` |
|
|
297
320
|
|
|
298
321
|
`PUT` takes `name` plus exactly one shape: `value` (a string), `file` (a PEM,
|
|
299
322
|
a cert), `oauth2`, `service_account`, `ssh`, or `api`. They go to the vault;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# Assistant
|
|
2
|
+
|
|
3
|
+
The assistant is a panel on every page of the dashboard. Ask it how
|
|
4
|
+
foldrun works, or describe what you want built. It writes agents, flows,
|
|
5
|
+
tools and skills for a workspace and shows each one as a diff, checked, for
|
|
6
|
+
you to save.
|
|
7
|
+
|
|
8
|
+
Open it with **Ask AI** at the bottom right. **Draft with AI** on the Flows
|
|
9
|
+
page opens it too, with "Draft a flow that" ready to finish.
|
|
10
|
+
|
|
11
|
+
Inside a workspace it works in that workspace. Anywhere else, such as the
|
|
12
|
+
account home, Billing or the docs, it works in the one chosen in its header;
|
|
13
|
+
your browser remembers the choice. Each workspace keeps its own conversation.
|
|
14
|
+
|
|
15
|
+
## What it knows
|
|
16
|
+
|
|
17
|
+
Only two things:
|
|
18
|
+
|
|
19
|
+
- **foldrun's docs**, these pages. It searches them and reads the sections
|
|
20
|
+
it needs before it answers, and it cites what it used as
|
|
21
|
+
`[doc › section]`, which links to the heading. Under each reply,
|
|
22
|
+
**Read N doc sections** lists them.
|
|
23
|
+
- **This workspace's files**: its `AGENTS.md`, agents, flows, skills,
|
|
24
|
+
tools, hooks, knowledge, evals and scripts, plus the account library and
|
|
25
|
+
the account's `AGENTS.md`. It never reads `state/`, `storage/`, runs or
|
|
26
|
+
secrets.
|
|
27
|
+
|
|
28
|
+
It has no web access, no shell and no other tools. If the docs do not cover
|
|
29
|
+
something, it says so; it does not guess. Anything that is not about
|
|
30
|
+
building on foldrun gets a one-line refusal. Text inside your files is
|
|
31
|
+
treated as data, never as instructions to it.
|
|
32
|
+
|
|
33
|
+
## What it writes
|
|
34
|
+
|
|
35
|
+
Only these paths:
|
|
36
|
+
|
|
37
|
+
| Kind | Path |
|
|
38
|
+
|---|---|
|
|
39
|
+
| a flow | `flows/<name>.md` |
|
|
40
|
+
| an agent | `agents/<name>/agent.md` |
|
|
41
|
+
| a skill | `skills/<name>/SKILL.md`, and files under the skill's `scripts/`, `references/` or `assets/` |
|
|
42
|
+
| a tool | `tools/<name>/tool.md` with its program beside it, or `tools/<name>.md` |
|
|
43
|
+
|
|
44
|
+
When something else needs to change, such as `AGENTS.md`, a secret or a
|
|
45
|
+
setting, it tells you what and where, and you make that change.
|
|
46
|
+
|
|
47
|
+
## New workspaces
|
|
48
|
+
|
|
49
|
+
When you ask for a new workspace, or describe a separate desk or pipeline,
|
|
50
|
+
it proposes one: a name and what it is for, with a **Create workspace**
|
|
51
|
+
button. Creating it needs `workspace:manage` (admin or owner). The workspace
|
|
52
|
+
starts blank, with only its `AGENTS.md`. The dashboard then opens it, and the
|
|
53
|
+
conversation carries on there: the assistant writes the new workspace's
|
|
54
|
+
agents, flows, tools and skills for you to save.
|
|
55
|
+
|
|
56
|
+
It never proposes a name that is taken. An account with no workspace yet
|
|
57
|
+
gets a box to name its first one.
|
|
58
|
+
|
|
59
|
+
Every file it proposes is checked as it writes it, and the model sees the
|
|
60
|
+
result and fixes errors before it finishes:
|
|
61
|
+
|
|
62
|
+
- the [deploy's own checks](deploying) and the flow lint, over the workspace
|
|
63
|
+
as it would be with the file in it;
|
|
64
|
+
- a frontmatter key that appears nowhere in these docs is an error, because
|
|
65
|
+
foldrun would not read it;
|
|
66
|
+
- a value shaped like a credential (an API key, a token, a private key) is
|
|
67
|
+
an error. Secrets go in the vault; see [Secrets](secrets);
|
|
68
|
+
- a skill needs `name:` (its folder) and `description:`;
|
|
69
|
+
- a script tool's `run:` program must be proposed or already exist.
|
|
70
|
+
|
|
71
|
+
Anything still wrong after that is shown beside the file, not hidden.
|
|
72
|
+
|
|
73
|
+
## Saving
|
|
74
|
+
|
|
75
|
+
Nothing is written until you press **Save** on a file, or **Save all**. Each
|
|
76
|
+
save goes through the same write as the editor: one revision per file, with
|
|
77
|
+
the note "written with the assistant: …", which you can restore from
|
|
78
|
+
[History](backups-and-restore). If a file changed after it was proposed, the
|
|
79
|
+
save is refused and nothing is overwritten; ask the assistant to propose it
|
|
80
|
+
again.
|
|
81
|
+
|
|
82
|
+
Saving is not deploying. On the platform, the saved file is the workspace's
|
|
83
|
+
source, the same as an edit in the dashboard.
|
|
84
|
+
|
|
85
|
+
## History
|
|
86
|
+
|
|
87
|
+
Every reply is kept. **History** in the panel's header lists your
|
|
88
|
+
conversations, newest first and grouped by day, across every workspace you
|
|
89
|
+
can see; type to search them, by title or by anything said in them. Open one
|
|
90
|
+
to carry it on: the files it proposed and you did not save come back with
|
|
91
|
+
it, and the panel moves to its workspace. **New chat** starts another.
|
|
92
|
+
|
|
93
|
+
Conversations are yours alone. Nobody else in the account, owners included,
|
|
94
|
+
can list or read them; an API key's conversations belong to that key. Each
|
|
95
|
+
person keeps their newest 500.
|
|
96
|
+
|
|
97
|
+
## Where it runs
|
|
98
|
+
|
|
99
|
+
Not in the dashboard. Each reply is queued and answered by the platform's
|
|
100
|
+
assistant workers, a pool of its own that grows with the queue, so a burst
|
|
101
|
+
of questions never slows the dashboard or takes a turn from your runs. At
|
|
102
|
+
most three replies of one account run at once; more wait their turn, and the
|
|
103
|
+
panel says so. The model only ever reads through the assistant's own tools,
|
|
104
|
+
on the server, scoped to your account and workspace; it runs no code and has
|
|
105
|
+
no network or shell of its own.
|
|
106
|
+
|
|
107
|
+
## Who can use it, and what it costs
|
|
108
|
+
|
|
109
|
+
You need to be able to edit the workspace (`workspace:write`: editor,
|
|
110
|
+
admin or owner). Viewers do not see the button enabled.
|
|
111
|
+
|
|
112
|
+
Each reply is one model call that may use several tools, on the same models
|
|
113
|
+
your runs use: the workspace's or account's own [provider](providers) or
|
|
114
|
+
key when there is one, otherwise the platform's. On the platform's models
|
|
115
|
+
the balance is checked first, and the reply is charged at the same price as
|
|
116
|
+
a run's tokens, as one line on [usage](budgets-and-billing), flow
|
|
117
|
+
`ai-assistant`. On your own provider or key, that provider bills you and
|
|
118
|
+
foldrun charges nothing.
|
|
119
|
+
|
|
120
|
+
Under every reply is what it used: tokens in (cache reads included) and out,
|
|
121
|
+
and what it was charged, or "on your own model key". One reply is capped at
|
|
122
|
+
$2, 30 tool calls and five minutes. Each person or key can send 60 replies an
|
|
123
|
+
hour, and when the platform's models answer, an account can send 300 a day.
|
|
124
|
+
|
|
125
|
+
**Stop** stops what you see, not the reply: it finishes on the server within
|
|
126
|
+
those caps and is charged, because what a reply cost is only known when it
|
|
127
|
+
ends. Its answer is in **History**.
|
|
128
|
+
|
|
129
|
+
## From a terminal, and the API
|
|
130
|
+
|
|
131
|
+
`foldrun assistant "<message>" --to <workspace>` (or `foldrun ai`) asks
|
|
132
|
+
once, prints the reply, what it read and every proposed file as a diff, and
|
|
133
|
+
saves on yes. With no message, in a terminal, it is a conversation.
|
|
134
|
+
`foldrun assistant history` lists your kept conversations and
|
|
135
|
+
`--conversation <id>` carries one on. See [CLI](cli).
|
|
136
|
+
|
|
137
|
+
The route is `POST /api/workspaces/<ws>/assistant`. It writes nothing and
|
|
138
|
+
returns the proposals for you to save with the source PUT. See
|
|
139
|
+
[API](api#assistant).
|
package/docs/cli.md
CHANGED
|
@@ -47,6 +47,7 @@ claude.ai or Claude Code there, nor any MCP configuration on disk.
|
|
|
47
47
|
| `foldrun agent import <workspace>/<agent>` | copy an agent you already have in another workspace into this one: its `agent.md` and own `skills/`, `scripts/`, `knowledge/` — never `memory/`, `outputs/` or `state/`. Into the workspace you stand in, or `--workspace <name>`; `--as <name>` imports it under another name (and rewrites `name:`), which a name clash requires. `--to <workspace>` does it on the platform (`POST /api/workspaces/<ws>/agents/import`) and lists the tools, skills, scripts and secrets the copy uses that the workspace lacks. See [Importing an agent](workspaces#importing-an-agent-you-already-have) |
|
|
48
48
|
| `foldrun agent import --list` | what could be imported into a deployed workspace: every other workspace this key may read, and each one's agents with their descriptions (`GET …/agents/import`). Imports nothing. `--to <workspace>`, `--json` |
|
|
49
49
|
| `foldrun agent unlink <agent>` | take one away: `--subagent <name>`, `--consult <name>` or `--can-ask`. An emptied list is written `key: []`, not deleted. In this folder, refused the same way, nothing written, when the file changed since it was read |
|
|
50
|
+
| `foldrun agent network <agent> [open\|trusted\|none\|inherit]` | which sites the agent's sandbox may reach ([Network](network)). Alone, prints what a run resolves — the level, where it came from, the sites added. With a level and/or `--add <site>` / `--remove <site>` (repeatable), rewrites the one `network:` line of its `agent.md` — the edit the dashboard's Network card makes — prints the diff and the new resolution; `inherit` drops the agent's own level, and a line with nothing left to say is removed. `--dry-run` writes nothing; `--to <workspace>` edits the deployed copy through `PATCH /api/workspaces/<ws>/agents/<name>` |
|
|
50
51
|
| `foldrun flow add <flow> <pattern>` | one palette block from the [flow canvas](flows#editing-on-the-canvas), as the same markdown edit the canvas writes (core's `flow-patterns`): `chain` (alias `step`: `--agent <name>`, `--instruction "…"`, `--after <n>` or `--before <n>`, default last), `parallel` (`--agent`, `--group <n>`), `router` (`--agent <triage>`, `--cases BUG=bugs,DOCS=docs`, `--else <agent>`, `--after`), `fan-out` (`--step`, `--each lines\|items\|"rows of <path>"`, `--max <n>`), `loop` (alias `evaluator`: `--step`, `--loop <n>` default 3, `--until <MARKER>` default APPROVED, `--judge "<claim>"` adds `verify: judge:`), `approval` (`--step`; the `!` marker, `approve: true` on a `?` step), `ask` (`--step`, `--question "…"`), `wait` (`--step`, `--wait 30m\|event`), `rescue` (`--step`, `--on-fail <agent>`), `subflow` (`--flow <other>`, `--after`). `--step` is the step's place in the file (1 = the first step line) or the agent it runs; `--off` takes a step pattern back off. Prints the diff, runs the rules `check` runs over the result and refuses — writing nothing — an edit that would add an error; one that was there before does not block it. `--dry-run` shows the diff and writes nothing. In this folder by default; `--to <workspace>` edits the deployed copy through the canvas's own route (`PATCH …/flows/<flow>` with `{ edit }`), and the sha256 of the file it read as `expect`: one edited meanwhile is refused (`409`), nothing written. In this folder it re-reads the file just before writing — after the y/N, after `check` — and refuses the same way, writing nothing, when it changed since it was read |
|
|
51
52
|
| `foldrun flow rm-step <flow> --step <n\|agent>` | delete one step, as the canvas's × does (core's `removeStep`): the step line and its indented options go, the groups after an emptied one renumber, prose and the agent's file are untouched. Says what else changes first (`case:`/`when:` steps that will route on the step before, the renumbering), prints the diff, refuses one `foldrun check` would call a new error, then asks — no terminal needs `--yes`. `--step` is the step's place in the file (1 = the first step line) or the agent it runs. `--dry-run` writes nothing; `--to <workspace>` sends the canvas's `PATCH …/flows/<flow>` with `{ edit: { op: "remove", step } }`, and the sha256 of the file it read as `expect`: one edited meanwhile is refused (`409`), nothing written. In this folder it re-reads the file just before writing — after the y/N, after `check` — and refuses the same way, writing nothing, when it changed since it was read |
|
|
52
53
|
| `foldrun flow dup-step <flow> --step <n\|agent>` | duplicate one step, as the canvas's Duplicate (Cmd/Ctrl+D) does (core's `duplicateStep`): a copy of the step line — its `?`/`!` marker and every indented option — goes directly under it with the same group number, so the copy runs in parallel with the original; nothing else moves. Prints the diff and refuses one `foldrun check` would call a new error; additive, so it does not ask. `--dry-run` writes nothing; `--to <workspace>` sends `PATCH …/flows/<flow>` with `{ edit: { op: "duplicate", step } }`, and the sha256 of the file it read as `expect`: one edited meanwhile is refused (`409`), nothing written. In this folder it re-reads the file just before writing — after the y/N, after `check` — and refuses the same way, writing nothing, when it changed since it was read |
|
|
@@ -60,6 +61,8 @@ claude.ai or Claude Code there, nor any MCP configuration on disk.
|
|
|
60
61
|
| `foldrun flow copy-step <flow> --step <n\|agent>[,<n>…]` | the canvas's Copy: the steps' markdown — line, marker and every option (core's `stepSource`) — on stdout. Reads only |
|
|
61
62
|
| `foldrun flow paste <flow>` | the canvas's Paste: step markdown from stdin or `--file <path>` goes in as new groups after `--after <g>` (default last), or beside group `--group <g>`, renumbered to fit (core's `pasteSteps`) — `foldrun flow copy-step a --step 2 \| foldrun flow paste b`. Diff, checked, `--dry-run`; `--to` sends `PATCH { edit: { op: "paste" } }` |
|
|
62
63
|
| `foldrun flow draft "<what it should do>"` | **Draft with AI**, on the platform (`--to <workspace>`; `--flow <name>` redrafts an existing flow): `POST …/flows/draft` has a model write the flow and any agent files it needs, checks and repairs once, and the CLI prints the notes, every file as a diff and what the check still finds. Nothing is saved until you say yes — `--yes` on a pipe, `--dry-run` never; on a pipe without `--yes` it saves nothing and exits 1, though the draft call was made and charged; each file goes through `PUT …/source` with `ifMatch`, so one that changed meanwhile is refused. One model call, charged like a step |
|
|
64
|
+
| `foldrun assistant ["<message>"]` | the dashboard's **assistant**, from a terminal (also `foldrun ai`; `--to <workspace>`): `POST …/assistant` answers from the foldrun docs and the deployed workspace's files only, and writes agents, flows, tools and skills as proposals — the CLI prints the reply, the doc sections it read, every proposed file as a diff and what the check finds. Nothing is saved until you say yes — `--yes` on a pipe, `--dry-run` never; on a pipe without `--yes` it saves nothing and exits 1 (a plain question with no files exits 0); each file goes through `PUT …/source` with `ifMatch`, so one that changed meanwhile is refused. It prints the tokens the reply used and what it was charged. A proposed new workspace is created blank on yes (`--yes` on a pipe; never over one that exists); in a conversation the CLI then moves into it and carries on. Every reply is kept; `--conversation <id>` carries one on (its messages and unsaved files come back, and its workspace unless `--to` says another). With no message, in a terminal, it is a conversation: `/save`, `/files`, `/drop <path>`, `/new`, `/quit`. `--json` prints the platform's answer. Each reply is one model call, charged like a step. See [Assistant](assistant) |
|
|
65
|
+
| `foldrun assistant history` | your kept assistant conversations, newest first (`GET /api/assistant/conversations`): id, workspace, when, how many messages and unsaved files, and the first question. `--q <text>` searches them, `--workspace <name>` narrows to one, `--json` prints the answer. `foldrun assistant show <id>` prints one whole; `foldrun assistant rm <id>` deletes it (asks; `--yes`) |
|
|
63
66
|
| `foldrun tool test <name>` | exercise one tool, alone — no model, no run, no flow. The real thing runs: the HTTP request against the declared `base:`, the script with the workspace's secrets in its environment, the MCP handshake. Prints the transport, whether it exited cleanly, how long it took, everything the tool printed, and any secret it needed by NAME and never by value. Its arguments are `key=value` pairs, one per argument the tool declares, and `--path` is an http tool's probe path. It waits five minutes by default, because a tool that crawls takes as long as it takes — `--timeout <seconds>` waits longer, and a test given up on says so rather than claiming the tool failed |
|
|
64
67
|
| `foldrun hook ls` | the hooks in this workspace (`hooks/<name>/`) and the account library (`library/hooks/<name>/`): each one's `on:`, `tool:` and description, read the way a run reads them — a folder that is not a valid hook is listed with the reason and the command exits 1. A workspace hook shadows a library one of the same name. `--json` for a script. `--to <workspace>` lists the DEPLOYED workspace's hooks instead (`GET /api/workspaces/<ws>/hooks`, the dashboard's Hooks panel): who turns each on, and the denies, rewrites and failures they wrote into recent runs — `--runs <n>` reads further back (default 50, up to 500), `--all` shows every decision |
|
|
65
68
|
| `foldrun hook add <name> --on <event>` | a hook folder: `hook.md` (`on:` one of `tool.call`, `tool.result`, `step.start`, `step.end`; `tool:` from `--tool <pattern>` — `Bash`, `Write\|Edit`, `mcp__*` — on the tool events only) and a self-contained `run.mjs` that allows everything, with the whole contract in its comments: the event on stdin, one JSON answer on stdout, exit 2 to deny with stderr as the reason. `--library` writes it in the account library for every workspace; `--to <workspace>` picks one in an account. An agent turns it on with `hooks: [name]`; `hooks:` in a workspace's or the account's AGENTS.md turns it on for every agent under it |
|
|
@@ -82,7 +85,8 @@ claude.ai or Claude Code there, nor any MCP configuration on disk.
|
|
|
82
85
|
| `foldrun stop <run-id>` | kill a run in flight: the queued job is dropped and the sandbox the current step is spending in is destroyed. It prints which step is running and what has been spent, then asks you to type the run id back unless `--yes`. Finished steps keep their results, and costs already incurred stay on the bill. A run that has already finished is said so, not stopped. With no run id and a filter — `--status running,queued`, `--since 2h`, `--until 1h` (started before then: a span ago, or a date), `--flow <name>` — it stops every run that matches (`POST …/runs/bulk`), in `--to <workspace>` or across the account: it lists what matches first, asks, and then stops exactly the runs it listed, so one that starts in between is not swept up. `--dry-run` lists and touches nothing. The route refuses more than 100 at once |
|
|
83
86
|
| `foldrun answer <run-id> "…"` | answer the question an agent is asking mid-step (`tools: [ask]`). `--option <n>` picks one of the choices it offered; `--question <id>` one of several. The agent is waiting in its sandbox and carries on at once. `foldrun approvals` lists what is being asked |
|
|
84
87
|
| `foldrun message <run-id> "…"` | say something to a running agent. It reaches the model after its next tool call, as context — it changes none of the step's tools. `--step <n>` picks one when a parallel group has several running. Platform only |
|
|
85
|
-
| `foldrun secrets set NAME` | store a secret — also `ls`, `rm`, and `status` (OAuth grant health) |
|
|
88
|
+
| `foldrun secrets set NAME` | store a secret — also `ls`, `rm`, and `status` (OAuth grant health). `--site <domain>` (repeatable, or `a,b`) binds it as it is stored |
|
|
89
|
+
| `foldrun secrets sites NAME [site…]` | bind a secret to the sites it is for: the egress proxy fills it in requests to those sites alone, and it never enters a sandbox ([bound to sites](secrets#bound-to-sites)). No sites prints what it is bound to; `--clear` unbinds. On the platform, or this machine's vault with `--local` |
|
|
86
90
|
| `foldrun secrets set NAME --kind <k>` | the other shapes the Connections form stores: `file` (`--file <path>`), `ssh` (`--host`, `--user`, `--port`, `--key-file <path>` or a prompted password), `api` (`--base-url`, `--header "Name: value"` repeatable), `service-account` (`--file <key.json>`, `--scopes`), `m2m` (a client-credentials app: `--token-url`, `--client-id`, `--scopes`, the secret prompted). On the platform, or this machine's vault with `--local` |
|
|
87
91
|
| `foldrun secrets clients` | the OAuth clients saved on the platform — what a consent runs from without typing the client again; the client secret is never shown. `clients add <name> --provider google\|github\|microsoft\|linkedin` (or `--authorize-url` / `--token-url`) `--client-id … --scopes …` saves one (secret prompted), `clients rm <name>` removes it |
|
|
88
92
|
| `foldrun connect NAME` | OAuth sign-in from the terminal, stored as an auto-refreshing secret — `--provider google\|github\|microsoft\|linkedin`; reconnecting a secret whose client is saved asks for nothing (`--new-client` to enter one) |
|
|
@@ -192,12 +196,14 @@ claude.ai or Claude Code there, nor any MCP configuration on disk.
|
|
|
192
196
|
| `--step <n>` | `approve`, `reject`: decide one gate, numbered as `report` prints it. Without it, every step of that run which is waiting |
|
|
193
197
|
| `--payload <json\|text\|@file>` | `approve`: release a step waiting on `wait: event` with this body (`POST …/approve` `{ payload }`). JSON is sent as JSON, anything else as text; `@file` reads a file |
|
|
194
198
|
| `--note "<text>"` | `approve`: guidance the approved step reads in its prompt — "approve, but skip the Sydney batch". `reject`: the reason, recorded on the trace. `billing credit`: the ledger line's note |
|
|
195
|
-
| `--yes` | skip the confirmation, deliberately: `approve`, `stop`, `deploy` (when it would delete files), and a bulk `stop` or `rerun`, `flow rotate-hook`, `flow draft`, `history restore`, `restore`, `billing plan`, `repo deploy`/`merge`, `workspace set name`, and every delete — `runs rm`, `storage rm`, `storage unshare`, `source rm`, `secrets rm`, `keys revoke`, `workspaces rm`. Required when there is no terminal to ask; without it nothing is deleted |
|
|
196
|
-
| `--json` | `report`: the raw run record instead of the report. `observe`, `usage`, `workspaces`, `workspaces show`, `agent ls`, `flow ls`, `agent import --list`, `backups requests`, `status --platform --history`, `team`: the raw document |
|
|
199
|
+
| `--yes` | skip the confirmation, deliberately: `approve`, `stop`, `deploy` (when it would delete files), and a bulk `stop` or `rerun`, `flow rotate-hook`, `flow draft`, `assistant`, `history restore`, `restore`, `billing plan`, `repo deploy`/`merge`, `workspace set name`, and every delete — `runs rm`, `storage rm`, `storage unshare`, `source rm`, `secrets rm`, `keys revoke`, `workspaces rm`. Required when there is no terminal to ask; without it nothing is deleted |
|
|
200
|
+
| `--json` | `report`: the raw run record instead of the report. `observe`, `usage`, `workspaces`, `workspaces show`, `agent ls`, `flow ls`, `agent import --list`, `assistant`, `backups requests`, `status --platform --history`, `team`: the raw document |
|
|
197
201
|
| `--events <a,b>` | `account set notify`: which events are notified on — `failed`, `awaiting-approval`, `completed` |
|
|
198
202
|
| `--watch` | `invoke`: follow the run's trace here as it happens; the exit code is the run's |
|
|
199
203
|
| `--print` | `open`: print the URL, do not open it |
|
|
200
204
|
| `--value "<text>"` | `secrets set`: skip the prompt — careful with shell history |
|
|
205
|
+
| `--site <domain>`, `--clear` | `secrets set`, `secrets sites`: a site the secret is bound to (repeatable); `--clear` unbinds |
|
|
206
|
+
| `--add <site>`, `--remove <site>` | `agent network`: a site to add to, or take off, the agent's `network:` list (repeatable) |
|
|
201
207
|
| `--account` | `secrets`: account scope rather than this workspace's — the workspace is the folder's name (`--workspace`), local or on the platform alike |
|
|
202
208
|
| `--wait` | `invoke`, `agent run`: hold on and print the result. Asked in 25-second pieces, so a flow that runs for many minutes is never cut by whatever sits in front of the platform; a run parked on a person prints its page and exits 2 |
|
|
203
209
|
| `--from <n>` | `invoke`, `flow run`, `rerun`: start at step n; earlier steps are recorded as skipped. A bulk `rerun` defaults to 1. (One flag, two meanings: a template on `init`, a step everywhere else) |
|
|
@@ -224,7 +230,7 @@ claude.ai or Claude Code there, nor any MCP configuration on disk.
|
|
|
224
230
|
| `--timeout <s>` | how many seconds one request to the platform may take — or `FOLDRUN_TIMEOUT`. Default 30, and 300 for `tool test`, where the tool itself is the thing being waited on |
|
|
225
231
|
| `--local` | `deploy`, `secrets`, `logs`: the installation on this machine, even when signed in |
|
|
226
232
|
| `--quiet` | leave out the dim "acting as …" line a platform command prints on stderr |
|
|
227
|
-
| `--dry-run` | `extract`: list what would move, change nothing. `flow add`, `flow rm-step`, `flow dup-step`, `flow set`, `flow trigger`, `flow move-step`, `flow paste`, `flow draft`, `history restore`, `agent link`/`unlink`: show the diff, write nothing. `restore`: the diff only, restore nothing. A bulk `stop`/`rerun`: list what matches, touch nothing |
|
|
233
|
+
| `--dry-run` | `extract`: list what would move, change nothing. `flow add`, `flow rm-step`, `flow dup-step`, `flow set`, `flow trigger`, `flow move-step`, `flow paste`, `flow draft`, `assistant`, `history restore`, `agent link`/`unlink`, `agent network`: show the diff, write nothing. `restore`: the diff only, restore nothing. A bulk `stop`/`rerun`: list what matches, touch nothing |
|
|
228
234
|
| `--out <file>` | `api spec`: where to write the OpenAPI document (default stdout) |
|
|
229
235
|
| `--file <path>` | `source put`: the file to send. `storage get`, `report get`, `account export`, `billing statement --csv`: where to write — `-` for stdout |
|
|
230
236
|
|
package/docs/flows.md
CHANGED
|
@@ -112,7 +112,7 @@ begins.
|
|
|
112
112
|
| `ask:` | the same gate carrying a question; the typed answer reaches the prompt (a plain approval with no note reaches it as "approved with no note") |
|
|
113
113
|
| `preview:` | what the gate shows: paths under `storage/` (a `workspace/storage/` prefix is accepted), comma-separated, globs allowed — `draft/*.mdx, draft/images/*.webp`; at most 12 files |
|
|
114
114
|
| `wait:` | `3d` (`s`/`m`/`h`/`d`, capped at 30 days) or `event` — the run page shows the event URL and can release it with a payload, recorded as released by you (approve with `payload`) |
|
|
115
|
-
| `on-fail:` | another agent takes the step over, with the failure as context. The rescuer owes what the step owed: its `verify
|
|
115
|
+
| `on-fail:` | another agent takes the step over, with the failure as context — and, for a gated step, the person's approval and answer, which it acts within. The rescuer owes what the step owed: its `verify:`, its `output:`, and a `loop:` step's `until:` marker (a loop that runs out of cycles goes to its rescuer too). A `verify:` that accepts `BLOCKED` never fails, so it never reaches the rescuer |
|
|
116
116
|
| `delegate:` | the step's agent picks who runs next, from this set |
|
|
117
117
|
|
|
118
118
|
### What is retried without a `retry:`
|
package/docs/network.md
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# Network
|
|
2
|
+
|
|
3
|
+
Every step runs in a sandbox of its own, and `network:` says which sites that
|
|
4
|
+
sandbox may reach. An agent that reads the open web may reach any public
|
|
5
|
+
site; every other agent reaches a list of trusted sites plus whatever it
|
|
6
|
+
declares. Least access by default, widened on purpose — the same default
|
|
7
|
+
Anthropic's own cloud sandboxes start from.
|
|
8
|
+
|
|
9
|
+
```yaml
|
|
10
|
+
---
|
|
11
|
+
name: crm-sync
|
|
12
|
+
tools: [crm_sync]
|
|
13
|
+
network: [api.hubapi.com] # the trusted list, plus this
|
|
14
|
+
---
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## The three levels
|
|
18
|
+
|
|
19
|
+
| level | the sandbox may reach |
|
|
20
|
+
|---|---|
|
|
21
|
+
| `open` | any public site |
|
|
22
|
+
| `trusted` | the trusted list, the sites you add, and what the step declares |
|
|
23
|
+
| `none` | only the sites you add and what the step declares |
|
|
24
|
+
|
|
25
|
+
**What the step declares** is always allowed, at every level: the hosts of
|
|
26
|
+
its `apis:`, its remote MCP servers, its web providers (`web: {search: brave}`),
|
|
27
|
+
and the sites its [secrets are bound to](secrets#bound-to-sites). You never list
|
|
28
|
+
those twice.
|
|
29
|
+
|
|
30
|
+
No level is ever a way into private space: the cluster, the LAN and the
|
|
31
|
+
cloud metadata endpoint are refused whatever `network:` says.
|
|
32
|
+
|
|
33
|
+
## The default
|
|
34
|
+
|
|
35
|
+
Unset, the level follows what the agent does:
|
|
36
|
+
|
|
37
|
+
- an agent with the `web` tool that may **fetch or browse** is `open` — a search
|
|
38
|
+
result can name any site, and a list would break it;
|
|
39
|
+
- every other agent is `trusted`.
|
|
40
|
+
|
|
41
|
+
An agent whose `web:` allows only `search` (`web: {actions: [search]}`) is
|
|
42
|
+
`trusted`: it reads results, not pages.
|
|
43
|
+
|
|
44
|
+
## The trusted list
|
|
45
|
+
|
|
46
|
+
Package registries, source hosts and cloud SDKs — the domains Anthropic's
|
|
47
|
+
Claude Code cloud environments allow at their default *Trusted* level, plus
|
|
48
|
+
the Debian mirrors the runner image installs `runtime: system:` packages
|
|
49
|
+
from. Among them:
|
|
50
|
+
|
|
51
|
+
| | |
|
|
52
|
+
|---|---|
|
|
53
|
+
| Anthropic | api.anthropic.com, claude.ai, docs.claude.com, … |
|
|
54
|
+
| Source | github.com, raw.githubusercontent.com, gitlab.com, bitbucket.org, … |
|
|
55
|
+
| Packages | registry.npmjs.org, pypi.org, files.pythonhosted.org, crates.io, rubygems.org, proxy.golang.org, Maven, NuGet, … |
|
|
56
|
+
| Containers | registry-1.docker.io, ghcr.io, gcr.io, public.ecr.aws, … |
|
|
57
|
+
| Cloud | \*.googleapis.com, \*.amazonaws.com, \*.microsoftonline.com, … |
|
|
58
|
+
| Linux | deb.debian.org, security.debian.org, archive.ubuntu.com, … |
|
|
59
|
+
|
|
60
|
+
The whole list is `TRUSTED_SITES` in `@foldrun/core/network`; `foldrun agent
|
|
61
|
+
network <agent>` says how many sites it holds. It includes wide wildcards
|
|
62
|
+
such as `*.amazonaws.com` because SDKs need them — which means a bucket
|
|
63
|
+
anyone can create is reachable too. An agent handling something sensitive
|
|
64
|
+
can drop the list: `defaults: false`.
|
|
65
|
+
|
|
66
|
+
## Writing it
|
|
67
|
+
|
|
68
|
+
```yaml
|
|
69
|
+
network: trusted # a level
|
|
70
|
+
network: [api.example.com, "*.example.org"] # add sites; the level stays
|
|
71
|
+
network:
|
|
72
|
+
level: none
|
|
73
|
+
allow: [api.example.com]
|
|
74
|
+
defaults: false # trusted without the trusted list
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
A site is a domain or `*.domain` — no scheme, no path, no port; every port
|
|
78
|
+
of the host is covered. `*.example.com` covers `api.example.com`,
|
|
79
|
+
`a.b.example.com` and `example.com` itself. Quote a wildcard in YAML.
|
|
80
|
+
|
|
81
|
+
It is read on the agent, in the workspace's `AGENTS.md` and in the account's
|
|
82
|
+
`AGENTS.md`. The level and `defaults` are nearest-wins — the agent's, else the
|
|
83
|
+
workspace's, else the account's. The `allow` lists add up across all three,
|
|
84
|
+
so a site every agent needs is written once, in the account's `AGENTS.md`.
|
|
85
|
+
|
|
86
|
+
`foldrun check` and every deploy refuse a value they cannot read — a level
|
|
87
|
+
that is not one, a site with `https://` or a path — because it would leave
|
|
88
|
+
the agent on the level above it, wider or narrower than you wrote.
|
|
89
|
+
|
|
90
|
+
## Changing it
|
|
91
|
+
|
|
92
|
+
On the agent's page in the dashboard, the **Network** card shows the level a
|
|
93
|
+
run resolves, where it came from, and the sites on it; **Change** sets the
|
|
94
|
+
level and the sites to add. From the terminal:
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
foldrun agent network crm-sync # what a run resolves
|
|
98
|
+
foldrun agent network crm-sync --add api.hubapi.com # add a site
|
|
99
|
+
foldrun agent network crm-sync none --remove a.com # a level, and take one off
|
|
100
|
+
foldrun agent network crm-sync inherit # back to the workspace's or the default
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Both write the one `network:` line of `agent.md` and nothing else. Over the
|
|
104
|
+
API it is `PATCH /api/workspaces/<ws>/agents/<agent>` with `{ "network": … }`
|
|
105
|
+
(`null` inherits).
|
|
106
|
+
|
|
107
|
+
## How it is held
|
|
108
|
+
|
|
109
|
+
Every program in the sandbox reaches the internet through the **egress
|
|
110
|
+
proxy**: the pod's `HTTPS_PROXY` and `HTTP_PROXY` name it, with the step's
|
|
111
|
+
own lease as the password, and `NODE_USE_ENV_PROXY=1` turns that on for Node.
|
|
112
|
+
curl, Python's `requests` and `httpx`, pip, npm, git, apt and the Agent SDK
|
|
113
|
+
all read those variables. The proxy holds the step's list and checks every
|
|
114
|
+
connection against it:
|
|
115
|
+
|
|
116
|
+
- a site **off the list** is refused — `403`, saying which site and what to
|
|
117
|
+
add — and the refusal is a line in the run trace;
|
|
118
|
+
- a site **on the list** is a tunnel: the bytes pass through and the proxy
|
|
119
|
+
never reads them;
|
|
120
|
+
- a site a **secret is bound to** is intercepted, so the secret can be filled
|
|
121
|
+
in on the way out ([Secrets](secrets#bound-to-sites)).
|
|
122
|
+
|
|
123
|
+
On a cluster the pod's network policy backs this up. A step whose level is
|
|
124
|
+
`trusted` or `none` runs in a pod that may reach the proxy and DNS and
|
|
125
|
+
nothing else, so a program that ignores the proxy variables finds no route
|
|
126
|
+
instead of going round the list; it cannot reach the account's browser or
|
|
127
|
+
search pods either, which are the world fetched on a run's behalf.
|
|
128
|
+
|
|
129
|
+
The trace opens with the step's network:
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
network: trusted (default) — the trusted list (207 sites); added: api.hubapi.com
|
|
133
|
+
egress: CONNECT pypi.org:443 × 3 (840ms total)
|
|
134
|
+
network: example.com is not a site this step may reach (network: trusted). Add it to the agent's network: list — network: [example.com] — or set network: open. Refused.
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Report-only, while an install learns
|
|
138
|
+
|
|
139
|
+
An install enforces the list when its worker has `FOLDRUN_NETWORK_ENFORCE=1`.
|
|
140
|
+
Until then the proxy only **reports**: a site off the list is allowed, and
|
|
141
|
+
the trace names it once per step —
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
network: api.example.com would be refused (network: trusted) — allowed, because this install only reports. Add it to the agent's network: list to keep it reachable.
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
— so the sites your agents really use can be added before anything is
|
|
148
|
+
refused. The trace line `network: … — report only on this install` says
|
|
149
|
+
which mode a step ran in.
|
|
150
|
+
|
|
151
|
+
### Where it is not held
|
|
152
|
+
|
|
153
|
+
`foldrun run` on your own machine has no sandbox, and a compose install with
|
|
154
|
+
no egress proxy (no `FOLDRUN_EGRESS_URL`) has no forward door: there,
|
|
155
|
+
`network:` is checked and resolved but not enforced, and the trace says
|
|
156
|
+
`not enforced here`. On the single-container tier the proxy variables are
|
|
157
|
+
set and the proxy holds the list, but the container's network is not walled
|
|
158
|
+
in, so a program that ignores the variables still goes direct.
|
package/docs/secrets.md
CHANGED
|
@@ -12,6 +12,7 @@ git — which is what makes a workspace safe to commit.
|
|
|
12
12
|
```sh
|
|
13
13
|
foldrun secrets set STRIPE_KEY # this workspace's; prompted, never echoed
|
|
14
14
|
foldrun secrets set STRIPE_KEY --account # the account's, shared by every workspace
|
|
15
|
+
foldrun secrets set STRIPE_KEY --site api.stripe.com # bound: never in a sandbox (below)
|
|
15
16
|
foldrun secrets ls
|
|
16
17
|
```
|
|
17
18
|
|
|
@@ -112,6 +113,59 @@ land on any worker behind the Service and still find its lease. Without
|
|
|
112
113
|
a proxy configured (the CLI, a compose install without
|
|
113
114
|
`FOLDRUN_EGRESS_URL`) a step materialises everything, as it always did.
|
|
114
115
|
|
|
116
|
+
## Bound to sites
|
|
117
|
+
|
|
118
|
+
A secret can be **bound** to the sites it is for. A bound secret never
|
|
119
|
+
enters a sandbox, whatever the step grants — Bash, a script tool, a browser.
|
|
120
|
+
The sandbox holds `${NAME}`; the [egress proxy](network#how-it-is-held), which
|
|
121
|
+
every program's HTTPS goes through, fills it in requests to those sites and
|
|
122
|
+
no other. This is how Anthropic's cloud sandboxes attach a network secret,
|
|
123
|
+
and it closes the gap above: a step with Bash used to get every declared
|
|
124
|
+
secret's real value.
|
|
125
|
+
|
|
126
|
+
```sh
|
|
127
|
+
foldrun secrets sites DATAFORSEO_PASSWORD api.dataforseo.com
|
|
128
|
+
foldrun secrets sites DATAFORSEO_PASSWORD # what it is bound to
|
|
129
|
+
foldrun secrets sites DATAFORSEO_PASSWORD --clear # unbind it
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Or **Bind to sites** under the secret in Settings → Secrets, or over the API:
|
|
133
|
+
|
|
134
|
+
```sh
|
|
135
|
+
PATCH /api/secrets { "name": "DATAFORSEO_PASSWORD", "sites": ["api.dataforseo.com"] }
|
|
136
|
+
PUT /api/secrets { "name": "…", "value": "…", "sites": ["api.dataforseo.com"] } # set and bind at once
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
What a program in the sandbox writes is unchanged; it sends the placeholder
|
|
140
|
+
where the value would go:
|
|
141
|
+
|
|
142
|
+
```sh
|
|
143
|
+
curl -H "Authorization: Bearer $HUBSPOT_TOKEN" https://api.hubapi.com/crm/v3/objects/contacts
|
|
144
|
+
curl -u "$DATAFORSEO_LOGIN:$DATAFORSEO_PASSWORD" https://api.dataforseo.com/v3/serp/...
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The proxy fills `${NAME}` in the request's URL and headers, and inside a
|
|
148
|
+
`Basic` Authorization header (which `curl -u` sends base64'd). To do that
|
|
149
|
+
for a bound site it ends the TLS connection itself, with a certificate from
|
|
150
|
+
the install's **egress CA**, which the sandbox trusts: the pod's start puts
|
|
151
|
+
that CA in a bundle and points `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`,
|
|
152
|
+
`CURL_CA_BUNDLE`, `PIP_CERT`, `GIT_SSL_CAINFO` and `NODE_EXTRA_CA_CERTS` at
|
|
153
|
+
it. Only bound sites are intercepted; every other site is a tunnel the
|
|
154
|
+
proxy cannot read. The CA is derived from the install's key, so every
|
|
155
|
+
worker presents the same one, and rotating the key rotates it.
|
|
156
|
+
|
|
157
|
+
A bound site is always reachable, at every [network](network) level.
|
|
158
|
+
|
|
159
|
+
Leave a secret unbound when the program needs the value itself rather than
|
|
160
|
+
in a request: one that signs requests with it (an HMAC, AWS SigV4), puts it
|
|
161
|
+
in a request body, or uses it for something other than HTTP. A file, an ssh
|
|
162
|
+
key or an `api` header set cannot be bound — they are unpacked inside the
|
|
163
|
+
sandbox. The run trace says which way each secret went:
|
|
164
|
+
|
|
165
|
+
```
|
|
166
|
+
secrets: GA_KEY materialised in the sandbox for bash; DATAFORSEO_PASSWORD bound to sites — filled by the egress proxy, never in the sandbox; api tools and the model key go through the egress proxy
|
|
167
|
+
```
|
|
168
|
+
|
|
115
169
|
## Connected accounts
|
|
116
170
|
|
|
117
171
|
Some third parties are connected rather than pasted — Google, GitHub,
|
package/docs/security.md
CHANGED
|
@@ -19,9 +19,14 @@ machine.
|
|
|
19
19
|
|
|
20
20
|
## What the network allows
|
|
21
21
|
|
|
22
|
-
A run pod may reach
|
|
23
|
-
|
|
24
|
-
and
|
|
22
|
+
A run pod may reach DNS, the egress proxy where its credentials are filled
|
|
23
|
+
in, and — for an agent whose [network](network) is `open` — the public
|
|
24
|
+
internet and its own account's search and browser pods; nothing else. An
|
|
25
|
+
agent that is `trusted` or `none` (the default for one that does not read
|
|
26
|
+
the web) reaches the internet only through the egress proxy, which holds
|
|
27
|
+
its list of sites per connection; on an install that enforces it
|
|
28
|
+
(`FOLDRUN_NETWORK_ENFORCE=1`) its pod has no other route, and no way to the
|
|
29
|
+
account's browser or search pods. The policy denies every private range (RFC1918) and the
|
|
25
30
|
cloud metadata endpoint — the address that, on a cloud host, hands out the
|
|
26
31
|
machine's own credentials. It cannot reach the platform's API, its
|
|
27
32
|
database, its object store, or another account's pods. Workspace files
|
|
@@ -65,10 +70,12 @@ On a cluster the value does not enter the sandbox either. The worker keeps
|
|
|
65
70
|
it and the step sends its API and model requests through a per-step
|
|
66
71
|
**egress proxy** with `${NAME}` placeholders; the proxy fills each one for
|
|
67
72
|
the host it was granted to and forwards. A compromised script in the pod
|
|
68
|
-
reads its environment and finds the name, not the key. The
|
|
69
|
-
|
|
70
|
-
cookies), which materialises
|
|
71
|
-
|
|
73
|
+
reads its environment and finds the name, not the key. The exception is a
|
|
74
|
+
step whose script tools or Bash need the value in process (a browser seeding
|
|
75
|
+
cookies), which materialises its secrets into the sandbox and says so in the
|
|
76
|
+
trace — unless the secret is [bound to sites](secrets#bound-to-sites): a bound
|
|
77
|
+
secret never enters a sandbox, because the proxy fills it for those sites
|
|
78
|
+
whatever program sent the request.
|
|
72
79
|
|
|
73
80
|
At rest, each account has its own encryption key; the account keys are
|
|
74
81
|
wrapped by one root key the platform holds. A stolen copy of an account's
|
package/docs/tools.md
CHANGED
|
@@ -123,6 +123,12 @@ a `name @ url` is refused, with a line in the log.
|
|
|
123
123
|
never seen the program, and say when to use the tool in `description` — that
|
|
124
124
|
sentence is what decides whether it gets called at all.
|
|
125
125
|
|
|
126
|
+
The program gets each argument the agent fills in as a long flag,
|
|
127
|
+
`--<name> <value>`: the tool above runs as
|
|
128
|
+
`run.py --query "SELECT …" --files sales.csv`. An argument left out or empty
|
|
129
|
+
is not passed, so give optional ones a default in the program. What it
|
|
130
|
+
prints, stdout and stderr together, is what the agent reads back.
|
|
131
|
+
|
|
126
132
|
## MCP — someone else's tools
|
|
127
133
|
|
|
128
134
|
```yaml
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "foldrun",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Agents are just folders. Write them, check them, run them — on your machine.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"node": ">=22"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@foldrun/core": "^1.
|
|
14
|
+
"@foldrun/core": "^1.1.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",
|
|
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",
|
|
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
|
@@ -889,6 +889,10 @@ async function checkProblems(workspace, flags = {}) {
|
|
|
889
889
|
const { readAgentsMd } = await core();
|
|
890
890
|
const { readLimits } = await import("@foldrun/core/limits");
|
|
891
891
|
for (const w of readLimits(readAgentsMd(workspace)?.data?.limits).problems) note("error", "AGENTS.md", w);
|
|
892
|
+
// `network:` here is every agent's default: a value nobody can read
|
|
893
|
+
// leaves them on the account's, or the platform's.
|
|
894
|
+
const net = await import("@foldrun/core/network").catch(() => null);
|
|
895
|
+
for (const w of net ? net.networkProblems(readAgentsMd(workspace)?.data?.network) : []) note("error", "AGENTS.md", w);
|
|
892
896
|
}
|
|
893
897
|
|
|
894
898
|
const { unopenedFrontmatter } = /** @type {any} */ (await core());
|
|
@@ -912,6 +916,9 @@ async function checkProblems(workspace, flags = {}) {
|
|
|
912
916
|
// written into the file. The run would say so in its trail and carry
|
|
913
917
|
// on; the deploy refuses it; this is where a person hears it first.
|
|
914
918
|
for (const w of a.webProblems ?? []) note("error", `agents/${a.name}`, w);
|
|
919
|
+
// A network: value that cannot be read leaves the agent on the level
|
|
920
|
+
// above it — the deploy refuses it, so check says so first.
|
|
921
|
+
for (const w of a.networkProblems ?? []) note("error", `agents/${a.name}`, w);
|
|
915
922
|
// What works but should change: the old `session:` key, and a pinned
|
|
916
923
|
// user_agent whose Chrome is not the runner image's (the platform's
|
|
917
924
|
// /api/version says which Chrome; FOLDRUN_RUNNER_ENGINES on a server).
|
|
@@ -2798,6 +2805,19 @@ async function readStorage(page, origin) {
|
|
|
2798
2805
|
.catch(() => empty);
|
|
2799
2806
|
}
|
|
2800
2807
|
|
|
2808
|
+
/** Bind on this machine's vault — a core from before site binding says so. */
|
|
2809
|
+
async function localSetSites(name, sites, scope) {
|
|
2810
|
+
const k = await core();
|
|
2811
|
+
if (typeof k.setSecretSites !== "function") throw new Error("binding a secret to sites needs @foldrun/core 1.1 or later — update foldrun");
|
|
2812
|
+
return k.setSecretSites("default", name, sites, scope);
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2815
|
+
/** `--site a.com --site "*.b.org"` or `--site a.com,b.org`, as one list. */
|
|
2816
|
+
function siteFlags(flags) {
|
|
2817
|
+
const raw = flags.site === undefined ? [] : Array.isArray(flags.site) ? flags.site : [flags.site];
|
|
2818
|
+
return raw.filter((v) => typeof v === "string").flatMap((v) => v.split(",")).map((v) => v.trim()).filter(Boolean);
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2801
2821
|
async function secretsCmd(positional, flags, layout) {
|
|
2802
2822
|
// --local: this machine's store even when signed in, the same escape
|
|
2803
2823
|
// hatch deploy and keys have. Without it, a signed-in laptop sent every
|
|
@@ -2828,7 +2848,7 @@ async function secretsCmd(positional, flags, layout) {
|
|
|
2828
2848
|
console.log();
|
|
2829
2849
|
for (const s of entries) {
|
|
2830
2850
|
console.log(
|
|
2831
|
-
` ${c.bold(s.name)} ${c.dim(`${s.scope}${s.shadowed ? " · shadowed" : ""} · ${s.updatedAt ?? ""}`)}`,
|
|
2851
|
+
` ${c.bold(s.name)} ${c.dim(`${s.scope}${s.shadowed ? " · shadowed" : ""} · ${s.updatedAt ?? ""}${s.sites?.length ? ` · sent only to ${s.sites.join(", ")}` : ""}`)}`,
|
|
2832
2852
|
);
|
|
2833
2853
|
}
|
|
2834
2854
|
console.log();
|
|
@@ -2863,6 +2883,31 @@ async function secretsCmd(positional, flags, layout) {
|
|
|
2863
2883
|
|
|
2864
2884
|
if (!name) throw new Error(`which secret? try \`foldrun secrets ${verb} NAME\``);
|
|
2865
2885
|
|
|
2886
|
+
// A secret bound to sites never enters a sandbox: steps hold `${NAME}` and
|
|
2887
|
+
// the egress proxy fills it in requests to those sites alone.
|
|
2888
|
+
if (verb === "sites") {
|
|
2889
|
+
const given = [...positional.slice(2), ...siteFlags(flags)];
|
|
2890
|
+
if (!given.length && flags.clear !== true) {
|
|
2891
|
+
const entries = url
|
|
2892
|
+
? (await remoteCall(url, flags, `/api/secrets${scope ? `?workspace=${encodeURIComponent(scope)}` : ""}`)).secrets
|
|
2893
|
+
: (await core()).listSecrets("default", scope);
|
|
2894
|
+
const hit = entries.find((s) => s.name === name);
|
|
2895
|
+
if (!hit) throw new Error(`no secret ${name}${scope ? ` in workspace ${scope}` : " in the account"}`);
|
|
2896
|
+
console.log(hit.sites?.length
|
|
2897
|
+
? `\n ${c.bold(name)} is sent only to ${hit.sites.join(", ")} — on the platform it never enters a sandbox\n`
|
|
2898
|
+
: `\n ${c.bold(name)} is not bound to sites — a step with a script or Bash gets its real value. Bind it: foldrun secrets sites ${name} api.example.com\n`);
|
|
2899
|
+
return 0;
|
|
2900
|
+
}
|
|
2901
|
+
const sites = flags.clear === true ? [] : given;
|
|
2902
|
+
const stored = url
|
|
2903
|
+
? (await remoteCall(url, flags, "/api/secrets", { method: "PATCH", body: JSON.stringify({ name, sites, workspace: scope }) })).sites
|
|
2904
|
+
: await localSetSites(name, sites, scope);
|
|
2905
|
+
console.log(stored.length
|
|
2906
|
+
? `\n ${c.green("✓")} ${name} is sent only to ${stored.join(", ")} — on the platform the egress proxy fills it, and it never enters a sandbox\n`
|
|
2907
|
+
: `\n ${c.green("✓")} ${name} unbound — a step with a script or Bash gets its real value again\n`);
|
|
2908
|
+
return 0;
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2866
2911
|
if (verb === "set") {
|
|
2867
2912
|
// --kind: the other shapes the Connections form stores — a file, an SSH
|
|
2868
2913
|
// login, API headers, a service account's key, a client-credentials app.
|
|
@@ -2898,15 +2943,18 @@ async function secretsCmd(positional, flags, layout) {
|
|
|
2898
2943
|
const value =
|
|
2899
2944
|
typeof flags.value === "string" ? flags.value : await promptHidden(` value for ${name}: `);
|
|
2900
2945
|
if (!value) throw new Error("empty value — nothing stored");
|
|
2946
|
+
const sites = siteFlags(flags);
|
|
2901
2947
|
if (url) {
|
|
2902
2948
|
await remoteCall(url, flags, "/api/secrets", {
|
|
2903
2949
|
method: "PUT",
|
|
2904
|
-
body: JSON.stringify({ name, value, workspace: scope }),
|
|
2950
|
+
body: JSON.stringify({ name, value, workspace: scope, ...(sites.length ? { sites } : {}) }),
|
|
2905
2951
|
});
|
|
2906
2952
|
} else {
|
|
2907
|
-
|
|
2953
|
+
const k = await core();
|
|
2954
|
+
k.setSecret("default", name, value, scope);
|
|
2955
|
+
if (sites.length) await localSetSites(name, sites, scope);
|
|
2908
2956
|
}
|
|
2909
|
-
console.log(`\n ${c.green("✓")} ${name} stored — declare it in agent.md under \`secrets:\` to use it\n`);
|
|
2957
|
+
console.log(`\n ${c.green("✓")} ${name} stored${sites.length ? ` and sent only to ${sites.join(", ")}` : ""} — declare it in agent.md under \`secrets:\` to use it\n`);
|
|
2910
2958
|
return 0;
|
|
2911
2959
|
}
|
|
2912
2960
|
|
|
@@ -7243,6 +7291,84 @@ async function agentLinkCmd(positional, flags, layout) {
|
|
|
7243
7291
|
return 0;
|
|
7244
7292
|
}
|
|
7245
7293
|
|
|
7294
|
+
/**
|
|
7295
|
+
* `foldrun agent network <agent> [open|trusted|none|inherit] [--add <site>]…
|
|
7296
|
+
* [--remove <site>]…` — which sites the agent's sandbox may reach, its
|
|
7297
|
+
* `network:` line, edited the way the dashboard's Network card edits it
|
|
7298
|
+
* (core network.ts setNetworkSetting: that one line, nothing else). With no
|
|
7299
|
+
* level and no site it prints what a run resolves.
|
|
7300
|
+
*/
|
|
7301
|
+
async function agentNetworkCmd(positional, flags, layout) {
|
|
7302
|
+
const name = positional[1];
|
|
7303
|
+
const word = positional[2];
|
|
7304
|
+
const usage = "`foldrun agent network <agent> [open|trusted|none|inherit] [--add <site>]… [--remove <site>]…`";
|
|
7305
|
+
if (!name) throw new Error(usage);
|
|
7306
|
+
if (word !== undefined && !["open", "trusted", "none", "inherit"].includes(word)) throw new Error(`${usage} — "${word}" is not a level`);
|
|
7307
|
+
const net = await import("@foldrun/core/network").catch(() => null);
|
|
7308
|
+
if (!net) throw new Error("agent network needs @foldrun/core 1.1 or later — update foldrun");
|
|
7309
|
+
const desk = await openDesk(flags, layout, `agent network ${name}`);
|
|
7310
|
+
const rel = `agents/${name}/agent.md`;
|
|
7311
|
+
if (!desk.files.has(rel)) {
|
|
7312
|
+
const agents = [...desk.files.keys()].map((r) => /^agents\/([^/]+)\/agent\.md$/.exec(r)?.[1]).filter(Boolean);
|
|
7313
|
+
throw new Error(`no agent "${name}" in ${desk.label} — it has ${agents.join(", ") || "none"}`);
|
|
7314
|
+
}
|
|
7315
|
+
const resolve = (files) =>
|
|
7316
|
+
inTree(desk, files, async (dir) => {
|
|
7317
|
+
const { listAgents, readFrontmatter } = await core();
|
|
7318
|
+
return {
|
|
7319
|
+
info: listAgents("default", "workspace").find((a) => a.name === name),
|
|
7320
|
+
own: net.readNetwork(readFrontmatter(path.join(dir, rel)).network).setting,
|
|
7321
|
+
};
|
|
7322
|
+
});
|
|
7323
|
+
const show = (info) => {
|
|
7324
|
+
const p = info?.network;
|
|
7325
|
+
if (!p) return;
|
|
7326
|
+
console.log(` ${net.networkLine(p, true)}`);
|
|
7327
|
+
if (p.level !== "open") console.log(` ${c.dim("a run also allows its MCP servers, its web providers and the sites its secrets are bound to")}`);
|
|
7328
|
+
};
|
|
7329
|
+
const raw = desk.files.get(rel);
|
|
7330
|
+
const now = await resolve(desk.files);
|
|
7331
|
+
const list = (v) => (v === undefined ? [] : Array.isArray(v) ? v : [v]).filter((x) => typeof x === "string").flatMap((x) => x.split(",")).map((x) => x.trim().toLowerCase()).filter(Boolean);
|
|
7332
|
+
const adds = list(flags.add);
|
|
7333
|
+
const removes = list(flags.remove);
|
|
7334
|
+
if (word === undefined && !adds.length && !removes.length) {
|
|
7335
|
+
console.log(`\n ${c.bold(name)} ${c.dim(`· ${desk.label}`)}`);
|
|
7336
|
+
show(now.info);
|
|
7337
|
+
console.log();
|
|
7338
|
+
return 0;
|
|
7339
|
+
}
|
|
7340
|
+
const own = now.own ?? { allow: [] };
|
|
7341
|
+
const level = word === "inherit" ? undefined : word ?? own.level;
|
|
7342
|
+
const allow = [...new Set([...own.allow, ...adds])].filter((x) => !removes.includes(x));
|
|
7343
|
+
const setting = level || allow.length || own.defaults === false
|
|
7344
|
+
? { ...(level ? { level } : {}), allow, ...(own.defaults === false ? { defaults: false } : {}) }
|
|
7345
|
+
: null;
|
|
7346
|
+
const problems = setting ? net.readNetwork(setting).problems : [];
|
|
7347
|
+
if (problems.length) throw new Error(problems.join(" "));
|
|
7348
|
+
const next = net.setNetworkSetting(raw, setting);
|
|
7349
|
+
if (next === raw) {
|
|
7350
|
+
console.log(`\n ${c.dim(`${name}'s network: already says that — nothing to change`)}\n`);
|
|
7351
|
+
return 0;
|
|
7352
|
+
}
|
|
7353
|
+
console.log(`\n ${c.bold(name)} ${c.dim(`· network · ${desk.label}`)}\n`);
|
|
7354
|
+
await printDiff(rel, raw, next);
|
|
7355
|
+
if (flags["dry-run"] === true) {
|
|
7356
|
+
console.log(`\n ${c.dim("--dry-run: nothing written")}\n`);
|
|
7357
|
+
return 0;
|
|
7358
|
+
}
|
|
7359
|
+
if (desk.platform) {
|
|
7360
|
+
await remoteCall(desk.url, flags, `/api/workspaces/${enc(desk.ws)}/agents/${enc(name)}`, { method: "PATCH", body: JSON.stringify({ network: setting }) });
|
|
7361
|
+
} else {
|
|
7362
|
+
assertUnchanged(desk, rel, raw);
|
|
7363
|
+
writeIfUnchanged(desk, rel, raw, next);
|
|
7364
|
+
}
|
|
7365
|
+
const after = await resolve(new Map([...desk.files, [rel, next]]));
|
|
7366
|
+
console.log();
|
|
7367
|
+
show(after.info);
|
|
7368
|
+
console.log(`\n ${c.green("✓")} ${rel} ${c.dim(desk.platform ? `in ${desk.ws} — a revision on the platform` : "— `foldrun deploy` ships it")}\n`);
|
|
7369
|
+
return 0;
|
|
7370
|
+
}
|
|
7371
|
+
|
|
7246
7372
|
/**
|
|
7247
7373
|
* `foldrun flow show <flow>` — the canvas, in a terminal: the trigger, one
|
|
7248
7374
|
* block per group, each step's chips, the team under its agent, and what
|
|
@@ -8855,6 +8981,295 @@ async function flowDraftCmd(positional, flags, layout) {
|
|
|
8855
8981
|
return 0;
|
|
8856
8982
|
}
|
|
8857
8983
|
|
|
8984
|
+
/** `foldrun assistant history` — your kept conversations, newest first
|
|
8985
|
+
* (`--q` searches them, `--workspace` narrows to one, `--json`). */
|
|
8986
|
+
async function assistantHistoryCmd(url, flags) {
|
|
8987
|
+
const q = new URLSearchParams();
|
|
8988
|
+
if (typeof flags.q === "string" && flags.q.trim()) q.set("q", flags.q.trim());
|
|
8989
|
+
if (typeof flags.workspace === "string") q.set("workspace", flags.workspace);
|
|
8990
|
+
if (typeof flags.limit === "string") q.set("limit", flags.limit);
|
|
8991
|
+
const d = await remoteCall(url, flags, `/api/assistant/conversations${q.size ? `?${q}` : ""}`);
|
|
8992
|
+
const list = d.conversations ?? [];
|
|
8993
|
+
if (flags.json) {
|
|
8994
|
+
console.log(JSON.stringify(d, null, 2));
|
|
8995
|
+
return 0;
|
|
8996
|
+
}
|
|
8997
|
+
if (!list.length) {
|
|
8998
|
+
console.log(`\n ${c.dim(flags.q ? "nothing matches" : "no conversations yet")}\n`);
|
|
8999
|
+
return 0;
|
|
9000
|
+
}
|
|
9001
|
+
console.log();
|
|
9002
|
+
for (const x of list) {
|
|
9003
|
+
console.log(` ${c.bold(x.id)} ${x.workspace} ${c.dim(`${ago(x.updatedAt)} ago · ${x.messages} message${x.messages === 1 ? "" : "s"}${x.unsaved ? ` · ${x.unsaved} unsaved` : ""}`)}`);
|
|
9004
|
+
console.log(` ${x.title}`);
|
|
9005
|
+
}
|
|
9006
|
+
console.log(`\n ${c.dim("foldrun assistant show <id> · foldrun assistant --conversation <id> \"…\" · foldrun assistant rm <id>")}\n`);
|
|
9007
|
+
return 0;
|
|
9008
|
+
}
|
|
9009
|
+
|
|
9010
|
+
/** `foldrun assistant show <id>` — one kept conversation, whole. */
|
|
9011
|
+
async function assistantShowCmd(url, flags, id) {
|
|
9012
|
+
const d = await remoteCall(url, flags, `/api/assistant/conversations/${enc(id)}`);
|
|
9013
|
+
if (flags.json) {
|
|
9014
|
+
console.log(JSON.stringify(d, null, 2));
|
|
9015
|
+
return 0;
|
|
9016
|
+
}
|
|
9017
|
+
console.log(`\n ${c.bold(d.title)} ${c.dim(`${d.workspace} · ${ago(d.updatedAt)} ago`)}\n`);
|
|
9018
|
+
for (const m of d.messages ?? []) {
|
|
9019
|
+
console.log(` ${m.role === "user" ? c.bold("you ›") : c.dim("assistant ›")}`);
|
|
9020
|
+
console.log(`${String(m.content).split("\n").map((l) => ` ${l}`).join("\n")}\n`);
|
|
9021
|
+
}
|
|
9022
|
+
if ((d.files ?? []).length) console.log(` ${c.dim(`${d.files.length} proposed file${d.files.length === 1 ? "" : "s"} not saved: ${d.files.map((f) => f.path).join(", ")}`)}\n`);
|
|
9023
|
+
return 0;
|
|
9024
|
+
}
|
|
9025
|
+
|
|
9026
|
+
/** `foldrun assistant rm <id>` — delete one kept conversation (asks; --yes). */
|
|
9027
|
+
async function assistantRmCmd(url, flags, id) {
|
|
9028
|
+
if (!(await sureToDelete(flags, "assistant rm", `Delete the conversation ${id}`))) return 0;
|
|
9029
|
+
await remoteCall(url, flags, `/api/assistant/conversations/${enc(id)}`, { method: "DELETE" });
|
|
9030
|
+
console.log(` ${c.green("✓")} deleted ${id}\n`);
|
|
9031
|
+
return 0;
|
|
9032
|
+
}
|
|
9033
|
+
|
|
9034
|
+
/**
|
|
9035
|
+
* `foldrun assistant ["<message>"] --to <ws>` (also `foldrun ai`) — the
|
|
9036
|
+
* dashboard's assistant from a terminal: it answers from foldrun's docs and
|
|
9037
|
+
* the deployed workspace's files only, and writes agents, flows, tools and
|
|
9038
|
+
* skills as proposals (POST …/assistant). With a message it answers once;
|
|
9039
|
+
* with none, in a terminal, it is a conversation (/save, /files, /drop
|
|
9040
|
+
* <path>, /new, /quit). Proposals are shown as diffs with what the check
|
|
9041
|
+
* finds and saved only on yes (--yes; --dry-run never), each through
|
|
9042
|
+
* `PUT …/source` with ifMatch, so a file that changed meanwhile is refused.
|
|
9043
|
+
* --json prints the platform's answer as it is.
|
|
9044
|
+
*/
|
|
9045
|
+
async function assistantCmd(positional, flags, layout) {
|
|
9046
|
+
const url = platformFor(flags, "assistant");
|
|
9047
|
+
// The kept conversations: `history`, `show <id>`, `rm <id>`.
|
|
9048
|
+
const sub = positional[0];
|
|
9049
|
+
if (sub === "history" && positional.length === 1) return assistantHistoryCmd(url, flags);
|
|
9050
|
+
if ((sub === "show" || sub === "rm") && positional.length === 2 && /^c_[a-z0-9]{8,40}$/.test(positional[1])) {
|
|
9051
|
+
return sub === "show" ? assistantShowCmd(url, flags, positional[1]) : assistantRmCmd(url, flags, positional[1]);
|
|
9052
|
+
}
|
|
9053
|
+
// A kept conversation knows its workspace; --to may still say another.
|
|
9054
|
+
let ws = typeof flags.conversation === "string" && typeof flags.to !== "string" ? "" : platformWorkspace(flags, layout, "assistant");
|
|
9055
|
+
const first = positional.join(" ").trim();
|
|
9056
|
+
/** A new workspace the last reply proposed.
|
|
9057
|
+
* @type {{ name: string, purpose: string } | null} */
|
|
9058
|
+
let proposedWs = null;
|
|
9059
|
+
if (!first && !process.stdin.isTTY) {
|
|
9060
|
+
throw new Error('`foldrun assistant "<question or what to build>" --to <workspace>` — with no message it needs a terminal to talk in');
|
|
9061
|
+
}
|
|
9062
|
+
/** @type {{ role: "user" | "assistant", content: string }[]} */
|
|
9063
|
+
let messages = [];
|
|
9064
|
+
/** @type {{ path: string, content: string, before: string | null, sha256: string | null }[]} */
|
|
9065
|
+
let files = [];
|
|
9066
|
+
/** @type {{ where: string, message: string, level: string }[]} */
|
|
9067
|
+
let issues = [];
|
|
9068
|
+
/** The kept conversation this one continues; null until the first reply. */
|
|
9069
|
+
let conversation = typeof flags.conversation === "string" ? flags.conversation : null;
|
|
9070
|
+
if (conversation) {
|
|
9071
|
+
// Carry a kept one on: its messages and its unsaved files come back.
|
|
9072
|
+
const kept = await remoteCall(url, flags, `/api/assistant/conversations/${enc(conversation)}`);
|
|
9073
|
+
messages = (kept.messages ?? []).map((m) => ({ role: m.role, content: m.content }));
|
|
9074
|
+
files = kept.files ?? [];
|
|
9075
|
+
if (!ws) ws = kept.workspace;
|
|
9076
|
+
if (!flags.json) console.log(`\n ${c.dim(`carrying on "${kept.title}" in ${kept.workspace} — ${messages.length} message${messages.length === 1 ? "" : "s"}`)}`);
|
|
9077
|
+
}
|
|
9078
|
+
|
|
9079
|
+
const showFiles = async () => {
|
|
9080
|
+
for (const f of files) {
|
|
9081
|
+
console.log(` ${c.dim(f.before === null ? "new" : "replaces")}`);
|
|
9082
|
+
await printDiff(f.path, f.before ?? "", f.content);
|
|
9083
|
+
console.log();
|
|
9084
|
+
}
|
|
9085
|
+
for (const i of issues) console.log(` ${i.level === "error" ? c.red("error") : c.amber(" warn")} ${c.bold(i.where)} ${i.message}`);
|
|
9086
|
+
if (issues.length) console.log();
|
|
9087
|
+
};
|
|
9088
|
+
|
|
9089
|
+
const ask = async (text) => {
|
|
9090
|
+
messages.push({ role: "user", content: text });
|
|
9091
|
+
if (!flags.json) console.log(`\n ${c.dim("reading the docs and the workspace — one reply, charged like a step…")}`);
|
|
9092
|
+
let d;
|
|
9093
|
+
try {
|
|
9094
|
+
d = await remoteCall(
|
|
9095
|
+
url,
|
|
9096
|
+
flags,
|
|
9097
|
+
`/api/workspaces/${enc(ws)}/assistant`,
|
|
9098
|
+
{ method: "POST", body: JSON.stringify({ messages, files: files.map(({ path: p, content }) => ({ path: p, content })), ...(conversation ? { conversation } : {}) }) },
|
|
9099
|
+
{ seconds: Math.max(timeoutSeconds(flags), 300) },
|
|
9100
|
+
);
|
|
9101
|
+
} catch (err) {
|
|
9102
|
+
messages.pop();
|
|
9103
|
+
throw err;
|
|
9104
|
+
}
|
|
9105
|
+
messages.push({ role: "assistant", content: d.reply ?? "" });
|
|
9106
|
+
files = d.files ?? [];
|
|
9107
|
+
issues = d.issues ?? [];
|
|
9108
|
+
proposedWs = d.workspace ?? null;
|
|
9109
|
+
if (typeof d.conversation === "string" && d.conversation) conversation = d.conversation;
|
|
9110
|
+
if (flags.json) {
|
|
9111
|
+
console.log(JSON.stringify(d, null, 2));
|
|
9112
|
+
return;
|
|
9113
|
+
}
|
|
9114
|
+
console.log(`\n${(d.reply ?? "").split("\n").map((l) => ` ${l}`).join("\n")}\n`);
|
|
9115
|
+
if ((d.sources ?? []).length) console.log(` ${c.dim(`read: ${d.sources.map((s) => `${s.doc} › ${s.section}`).join(" · ")}`)}\n`);
|
|
9116
|
+
if (conversation) console.log(` ${c.dim(`kept as ${conversation} — carry on with --conversation ${conversation}`)}\n`);
|
|
9117
|
+
if (d.usage) {
|
|
9118
|
+
const k = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
|
|
9119
|
+
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` : "";
|
|
9120
|
+
console.log(` ${c.dim(`${k(d.usage.inputTokens)} tokens in · ${k(d.usage.outputTokens)} out${paid ? ` · ${paid}` : ""}`)}\n`);
|
|
9121
|
+
}
|
|
9122
|
+
if (proposedWs) console.log(` ${c.bold("new workspace")} ${proposedWs.name} ${c.dim(`— ${proposedWs.purpose}`)}\n`);
|
|
9123
|
+
await showFiles();
|
|
9124
|
+
};
|
|
9125
|
+
|
|
9126
|
+
/** What the last reply proposed — read through a call, because ask()
|
|
9127
|
+
* sets it and a type checker narrowing the variable cannot see that. */
|
|
9128
|
+
const proposal = () => proposedWs;
|
|
9129
|
+
|
|
9130
|
+
/** Create the proposed workspace, blank, as the dashboard does — after a
|
|
9131
|
+
* yes, and never over one that exists (the same POST deploys over it).
|
|
9132
|
+
* @param {{ name: string, purpose: string }} p */
|
|
9133
|
+
const createProposed = async (p) => {
|
|
9134
|
+
const name = p.name;
|
|
9135
|
+
if (!(await confirmed(flags, "assistant", `Create the workspace ${name}? [y/N] `))) {
|
|
9136
|
+
console.log(`\n ${c.dim("not created")}\n`);
|
|
9137
|
+
return false;
|
|
9138
|
+
}
|
|
9139
|
+
// ifAbsent: the server refuses a name that exists (409) rather than
|
|
9140
|
+
// deploying over it — atomically, where a list-then-create could race.
|
|
9141
|
+
try {
|
|
9142
|
+
await remoteCall(url, flags, "/api/workspaces", { method: "POST", body: JSON.stringify({ name, template: true, ifAbsent: true }) });
|
|
9143
|
+
} catch (err) {
|
|
9144
|
+
if (err instanceof HttpError && err.status === 409) {
|
|
9145
|
+
console.log(` ${c.red("✗")} a workspace called ${name} exists — nothing created\n`);
|
|
9146
|
+
return false;
|
|
9147
|
+
}
|
|
9148
|
+
throw err;
|
|
9149
|
+
}
|
|
9150
|
+
console.log(` ${c.green("✓")} created ${name}\n`);
|
|
9151
|
+
proposedWs = null;
|
|
9152
|
+
return name;
|
|
9153
|
+
};
|
|
9154
|
+
|
|
9155
|
+
const save = async (note) => {
|
|
9156
|
+
if (!files.length) {
|
|
9157
|
+
console.log(` ${c.dim("nothing proposed to save")}\n`);
|
|
9158
|
+
return true;
|
|
9159
|
+
}
|
|
9160
|
+
if (!(await confirmed(flags, "assistant", `Save ${files.length} file${files.length === 1 ? "" : "s"} to ${ws}${files.some((f) => f.before !== null) ? " (replacing what is there)" : ""}? [y/N] `))) {
|
|
9161
|
+
console.log(`\n ${c.dim("nothing saved")}\n`);
|
|
9162
|
+
return true;
|
|
9163
|
+
}
|
|
9164
|
+
const message = `written with the assistant: ${note.replace(/\s+/g, " ").slice(0, 80)}`;
|
|
9165
|
+
for (const f of [...files]) {
|
|
9166
|
+
try {
|
|
9167
|
+
await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/source`, { method: "PUT", body: JSON.stringify({ path: f.path, content: f.content, message, ifMatch: f.sha256 ?? null }) });
|
|
9168
|
+
console.log(` ${c.green("✓")} ${f.path}`);
|
|
9169
|
+
files = files.filter((x) => x.path !== f.path);
|
|
9170
|
+
issues = issues.filter((i) => !(i.where === f.path || i.where.startsWith(`${f.path}:`)));
|
|
9171
|
+
} catch (err) {
|
|
9172
|
+
console.log(` ${c.red("✗")} ${f.path} — ${err instanceof Error ? err.message : err}`);
|
|
9173
|
+
return false;
|
|
9174
|
+
}
|
|
9175
|
+
}
|
|
9176
|
+
console.log(`\n ${c.dim(`each is a revision — \`foldrun history --to ${ws}\` · \`foldrun check --to ${ws}\``)}\n`);
|
|
9177
|
+
return true;
|
|
9178
|
+
};
|
|
9179
|
+
|
|
9180
|
+
if (first) {
|
|
9181
|
+
await ask(first);
|
|
9182
|
+
const p = proposal();
|
|
9183
|
+
if (p && !flags.json) {
|
|
9184
|
+
if (flags["dry-run"] === true) {
|
|
9185
|
+
console.log(` ${c.dim("--dry-run: no workspace created")}\n`);
|
|
9186
|
+
return 0;
|
|
9187
|
+
}
|
|
9188
|
+
if (flags.yes !== true && !process.stdin.isTTY) {
|
|
9189
|
+
console.log(` ${c.dim(`not a terminal to ask — no workspace created; run it again with --yes, or create it with \`foldrun new ${p.name}\` and deploy`)}\n`);
|
|
9190
|
+
return 1;
|
|
9191
|
+
}
|
|
9192
|
+
const made = await createProposed(p);
|
|
9193
|
+
if (made) console.log(` ${c.dim(`carry on there: foldrun assistant --to ${made} "<what it should do>"`)}\n`);
|
|
9194
|
+
return made ? 0 : 1;
|
|
9195
|
+
}
|
|
9196
|
+
if (!files.length || flags.json) return 0;
|
|
9197
|
+
if (flags["dry-run"] === true) {
|
|
9198
|
+
console.log(` ${c.dim("--dry-run: nothing saved")}\n`);
|
|
9199
|
+
return 0;
|
|
9200
|
+
}
|
|
9201
|
+
if (flags.yes !== true && !process.stdin.isTTY) {
|
|
9202
|
+
console.log(` ${c.dim(`the reply was made (charged like a step), but this is not a terminal to ask — nothing saved — run it again with --yes to save ${files.length === 1 ? "this file" : `these ${files.length} files`}, or save them with \`foldrun source put\``)}\n`);
|
|
9203
|
+
return 1;
|
|
9204
|
+
}
|
|
9205
|
+
return (await save(first)) ? 0 : 1;
|
|
9206
|
+
}
|
|
9207
|
+
|
|
9208
|
+
const { createInterface } = await import("node:readline/promises");
|
|
9209
|
+
console.log(`\n ${c.bold("foldrun assistant")} ${c.dim(`· ${ws} · answers from the foldrun docs; nothing is saved until you /save`)}`);
|
|
9210
|
+
console.log(` ${c.dim("/save /files /drop <path> /new /quit")}\n`);
|
|
9211
|
+
for (;;) {
|
|
9212
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
9213
|
+
let line;
|
|
9214
|
+
try {
|
|
9215
|
+
line = (await rl.question(`${c.bold("you ›")} `)).trim();
|
|
9216
|
+
} catch {
|
|
9217
|
+
line = "/quit"; // the input closed (Ctrl-D)
|
|
9218
|
+
} finally {
|
|
9219
|
+
rl.close();
|
|
9220
|
+
}
|
|
9221
|
+
if (!line) continue;
|
|
9222
|
+
if (line === "/quit" || line === "/exit") {
|
|
9223
|
+
if (files.length) console.log(`\n ${c.dim(`${files.length} proposed file${files.length === 1 ? "" : "s"} not saved`)}`);
|
|
9224
|
+
console.log();
|
|
9225
|
+
return 0;
|
|
9226
|
+
}
|
|
9227
|
+
if (line === "/new") {
|
|
9228
|
+
messages = [];
|
|
9229
|
+
files = [];
|
|
9230
|
+
issues = [];
|
|
9231
|
+
conversation = null;
|
|
9232
|
+
console.log(` ${c.dim("a new conversation")}\n`);
|
|
9233
|
+
continue;
|
|
9234
|
+
}
|
|
9235
|
+
if (line === "/files") {
|
|
9236
|
+
if (files.length) await showFiles();
|
|
9237
|
+
else console.log(` ${c.dim("nothing proposed")}\n`);
|
|
9238
|
+
continue;
|
|
9239
|
+
}
|
|
9240
|
+
if (line.startsWith("/drop")) {
|
|
9241
|
+
const target = line.slice(5).trim();
|
|
9242
|
+
const before = files.length;
|
|
9243
|
+
files = files.filter((f) => f.path !== target);
|
|
9244
|
+
issues = issues.filter((i) => !(i.where === target || i.where.startsWith(`${target}:`)));
|
|
9245
|
+
console.log(` ${c.dim(files.length < before ? `dropped ${target}` : `${target || "(no path)"} is not proposed`)}\n`);
|
|
9246
|
+
continue;
|
|
9247
|
+
}
|
|
9248
|
+
if (line === "/save") {
|
|
9249
|
+
await save(messages.find((m) => m.role === "user")?.content ?? "");
|
|
9250
|
+
continue;
|
|
9251
|
+
}
|
|
9252
|
+
try {
|
|
9253
|
+
await ask(line);
|
|
9254
|
+
const p = proposal();
|
|
9255
|
+
if (p) {
|
|
9256
|
+
const made = await createProposed(p);
|
|
9257
|
+
if (made) {
|
|
9258
|
+
// The conversation moves into the new workspace and carries on.
|
|
9259
|
+
ws = made;
|
|
9260
|
+
files = [];
|
|
9261
|
+
issues = [];
|
|
9262
|
+
messages.push({ role: "assistant", content: `Created the workspace \`${made}\`.` });
|
|
9263
|
+
console.log(` ${c.dim(`now working in ${made}`)}`);
|
|
9264
|
+
await ask(`The workspace "${made}" now exists and you are working in it. Carry on with what I asked for: write its agents, flows, tools and skills.`);
|
|
9265
|
+
}
|
|
9266
|
+
}
|
|
9267
|
+
} catch (err) {
|
|
9268
|
+
console.log(`\n ${c.red("✗")} ${err instanceof Error ? err.message : err}\n`);
|
|
9269
|
+
}
|
|
9270
|
+
}
|
|
9271
|
+
}
|
|
9272
|
+
|
|
8858
9273
|
/**
|
|
8859
9274
|
* `foldrun history restore <path> --id <revision> --to <ws>` — the History
|
|
8860
9275
|
* drawer's "Restore this version": the file is rewritten with that
|
|
@@ -9769,11 +10184,15 @@ export async function run(command, positional, flags, workspace, layout) {
|
|
|
9769
10184
|
// `new` scaffolds one here; `run` runs one there. The noun is the same
|
|
9770
10185
|
// thing in both cases, which is why they share a command.
|
|
9771
10186
|
if (positional[0] === "link" || positional[0] === "unlink") return agentLinkCmd(positional, flags, layout);
|
|
10187
|
+
if (positional[0] === "network") return agentNetworkCmd(positional, flags, layout);
|
|
9772
10188
|
if (positional[0] === "import") return agentImportCmd(positional, flags, layout);
|
|
9773
10189
|
if (positional[0] === "ls" || positional[0] === "list") return remoteListCmd("agents", flags, layout);
|
|
9774
10190
|
return positional[0] === "run"
|
|
9775
10191
|
? agentRunCmd(positional, flags, layout)
|
|
9776
10192
|
: scaffoldCmd("agents", positional, flags, layout);
|
|
10193
|
+
case "assistant":
|
|
10194
|
+
case "ai":
|
|
10195
|
+
return assistantCmd(positional, flags, layout);
|
|
9777
10196
|
case "flow":
|
|
9778
10197
|
// `new` scaffolds a file; `add`, `rm-step` and `show` are the canvas; `run` is
|
|
9779
10198
|
// `invoke`, spelled the way `agent run` is; `rotate-hook` a new URL.
|
package/src/guide.mjs
CHANGED
|
@@ -30,7 +30,7 @@ export function agentRulesBlock() {
|
|
|
30
30
|
|
|
31
31
|
# Read foldrun's docs before you edit this folder
|
|
32
32
|
|
|
33
|
-
This is a foldrun account: agents, flows, tools and knowledge as markdown, run by foldrun. Its formats — frontmatter fields, step options, \`verify:\` prefixes, which folder a file goes in — are foldrun's own and may differ from your training data. Before you write or change an agent, flow, tool or skill, read the relevant guide: \`foldrun docs\` lists them and \`foldrun docs <page>\` prints one (start with \`foldrun docs coding-agents\`). They ship with the CLI, so they match the version you are driving. Check every edit with \`foldrun check\` — offline, no model calls. For a flow's shape, \`foldrun flow show <flow>\` draws it, and \`foldrun flow add\`, \`foldrun flow rm-step\`, \`foldrun flow dup-step\` and \`foldrun agent link\` make the same edits as the dashboard canvas (patterns, sub-agents, consults, \`ask\`). A script tool's own \`secrets: [NAMES]\` are granted with the tool; list on an agent only the secrets it uses directly. A hook (\`hooks/<name>/hook.md\` + \`run.mjs\`, turned on with \`hooks: [name]\`) is code at every tool call, step start and step end — \`foldrun hook add\`/\`test\` and \`foldrun docs hooks\`. Paths to the workspace's own folders are written \`workspace/storage/…\` and \`workspace/state/…\` — in an agent's prose, a flow's options and a script's arguments — not \`../../\`, which still works but is the older spelling.
|
|
33
|
+
This is a foldrun account: agents, flows, tools and knowledge as markdown, run by foldrun. Its formats — frontmatter fields, step options, \`verify:\` prefixes, which folder a file goes in — are foldrun's own and may differ from your training data. Before you write or change an agent, flow, tool or skill, read the relevant guide: \`foldrun docs\` lists them and \`foldrun docs <page>\` prints one (start with \`foldrun docs coding-agents\`). They ship with the CLI, so they match the version you are driving. Check every edit with \`foldrun check\` — offline, no model calls. For a flow's shape, \`foldrun flow show <flow>\` draws it, and \`foldrun flow add\`, \`foldrun flow rm-step\`, \`foldrun flow dup-step\` and \`foldrun agent link\` make the same edits as the dashboard canvas (patterns, sub-agents, consults, \`ask\`). A script tool's own \`secrets: [NAMES]\` are granted with the tool; list on an agent only the secrets it uses directly. A hook (\`hooks/<name>/hook.md\` + \`run.mjs\`, turned on with \`hooks: [name]\`) is code at every tool call, step start and step end — \`foldrun hook add\`/\`test\` and \`foldrun docs hooks\`. Paths to the workspace's own folders are written \`workspace/storage/…\` and \`workspace/state/…\` — in an agent's prose, a flow's options and a script's arguments — not \`../../\`, which still works but is the older spelling. Which sites a step's sandbox may reach is \`network:\` — open for an agent that may fetch or browse the web, the trusted list plus what it declares for every other; add a site with \`foldrun agent network <agent> --add <site>\` (\`foldrun docs network\`). Bind a secret to the sites it is for (\`foldrun secrets sites NAME <site>\`) and no sandbox ever holds its value.
|
|
34
34
|
|
|
35
35
|
This block is for coding tools. It is written and re-added by the foldrun CLI, and the runtime strips it, so the agents that run here never see it; the rest of this file is the account's own context, which they do read. Committing the block with your work keeps the tree clean.
|
|
36
36
|
|