@armadra/agent 0.10.0 → 0.12.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.
Files changed (82) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/CHANGELOG.zh-CN.md +31 -0
  3. package/README.md +5 -0
  4. package/README.zh-CN.md +4 -0
  5. package/dist/acp.d.ts +2 -2
  6. package/dist/acp.js +2 -2
  7. package/dist/bundle/ama.cjs +1730 -446
  8. package/dist/cli/compose-agents.js +8 -0
  9. package/dist/cli/compose.d.ts +3 -0
  10. package/dist/cli/compose.js +1 -0
  11. package/dist/drivers/acp/client.d.ts +54 -5
  12. package/dist/drivers/acp/client.js +96 -9
  13. package/dist/drivers/acp/types.d.ts +69 -3
  14. package/dist/drivers/acp/types.js +30 -1
  15. package/dist/drivers/agents.js +1 -1
  16. package/dist/drivers/host-runners.d.ts +26 -1
  17. package/dist/drivers/host-runners.js +147 -0
  18. package/dist/hooks/matcher.d.ts +2 -2
  19. package/dist/hooks/matcher.js +3 -3
  20. package/dist/host/api-impl.d.ts +3 -0
  21. package/dist/host/api-impl.js +7 -2
  22. package/dist/host/types.d.ts +37 -1
  23. package/dist/host/types.js +5 -0
  24. package/dist/host.d.ts +1 -1
  25. package/dist/host.js +1 -1
  26. package/dist/i18n/catalog.d.ts +5 -0
  27. package/dist/i18n/messages/approval.d.ts +5 -0
  28. package/dist/i18n/messages/approval.js +5 -0
  29. package/dist/i18n/messages/drivers.d.ts +2 -0
  30. package/dist/i18n/messages/drivers.js +2 -0
  31. package/dist/i18n/messages/interactive-view.d.ts +5 -0
  32. package/dist/i18n/messages/interactive-view.js +5 -0
  33. package/dist/i18n/messages/interactive.d.ts +4 -0
  34. package/dist/index.d.ts +1 -1
  35. package/dist/index.js +1 -1
  36. package/dist/modes/commands-core.d.ts +7 -0
  37. package/dist/modes/commands-core.js +23 -5
  38. package/dist/modes/interactive/approval-dialog.js +17 -4
  39. package/dist/modes/interactive/commands.d.ts +2 -0
  40. package/dist/modes/interactive/commands.js +5 -1
  41. package/dist/modes/interactive/interactive-mode.d.ts +7 -0
  42. package/dist/modes/interactive/interactive-mode.js +14 -6
  43. package/dist/modes/interactive/tool-summary.d.ts +2 -0
  44. package/dist/modes/interactive/tool-summary.js +27 -0
  45. package/dist/modes/rpc/commands.js +4 -4
  46. package/dist/permissions/auto-safe.d.ts +3 -0
  47. package/dist/permissions/auto-safe.js +10 -3
  48. package/dist/permissions/classifier.js +1 -1
  49. package/dist/permissions/network-class.d.ts +28 -0
  50. package/dist/permissions/network-class.js +91 -0
  51. package/dist/permissions/pipeline.d.ts +1 -1
  52. package/dist/permissions/pipeline.js +24 -7
  53. package/dist/permissions/preview.js +3 -0
  54. package/dist/permissions/readonly-bash.d.ts +2 -2
  55. package/dist/permissions/readonly-bash.js +2 -2
  56. package/dist/permissions/rules.d.ts +1 -0
  57. package/dist/permissions/rules.js +5 -1
  58. package/dist/tools/presets.d.ts +8 -1
  59. package/dist/tools/presets.js +12 -3
  60. package/dist/tools/registry.d.ts +3 -1
  61. package/dist/tools/registry.js +4 -2
  62. package/dist/tools/task-ctl.d.ts +9 -0
  63. package/dist/tools/task-ctl.js +22 -3
  64. package/dist/tools/types.d.ts +4 -1
  65. package/dist/tools/web/fetch.d.ts +48 -0
  66. package/dist/tools/web/fetch.js +307 -0
  67. package/dist/tools/web/html-to-markdown.d.ts +31 -0
  68. package/dist/tools/web/html-to-markdown.js +522 -0
  69. package/dist/tools/web/url-guard.d.ts +59 -0
  70. package/dist/tools/web/url-guard.js +221 -0
  71. package/docs/en/guides/permissions.md +33 -11
  72. package/docs/en/reference/acp.md +7 -3
  73. package/docs/en/reference/host-api.md +61 -19
  74. package/docs/en/reference/rpc.md +7 -7
  75. package/docs/guides/agents.md +18 -1
  76. package/docs/guides/hooks.md +86 -8
  77. package/docs/guides/permissions.md +33 -11
  78. package/docs/guides/sandbox.md +6 -0
  79. package/docs/reference/acp.md +7 -3
  80. package/docs/reference/host-api.md +61 -19
  81. package/docs/reference/rpc.md +7 -7
  82. package/package.json +1 -1
@@ -0,0 +1,221 @@
1
+ /**
2
+ * `web_fetch` 的地址守卫(docs/guides/permissions.md「网络类」):URL 校验、私网 / 元数据地址判定、
3
+ * DNS 钉住与重定向策略。纯 `node:*`,模型侧固定英文。
4
+ *
5
+ * - URL:只收 `http:` / `https:`;拒 userinfo;全文 ≤ 2 048 字符、查询串 ≤ 1 024 字符;主机名必须含 `.`
6
+ * (`localhost`、`*.localhost` 与无点主机一律拒——本机服务请走 bash 审批);IP 字面量当场判定。
7
+ * - 地址:解析出的**全部**地址都不得落在环回、未指定、链路本地(含云元数据 `169.254.169.254`)、
8
+ * RFC 1918、运营商 NAT `100.64/10`、基准测试 `198.18/15`、保留与广播、组播、IPv6 ULA `fc00::/7`、
9
+ * 站点本地 `fec0::/10`、链路本地 `fe80::/10`、组播 `ff00::/8`、未指定与已弃用的兼容段 `::/96`;IPv4 映射 / 兼容、NAT64 `64:ff9b::/96`、
10
+ * 6to4 `2002::/16` 里嵌的 IPv4 按 IPv4 再判一次。有一个落在里面就拒(防「多地址里夹一个内网」)。
11
+ * - DNS 钉住:连接用 `pinnedLookup()` 只返回已检查过的地址,防检查与连接之间被重新解析(DNS 重绑定)。
12
+ * - 重定向:同主机(主机名与端口相同、不从 https 降到 http)由调用方跟随并逐跳重检;跨主机不跟。
13
+ */
14
+ import { lookup as dnsLookup } from "node:dns";
15
+ import { BlockList, isIP } from "node:net";
16
+ export const MAX_URL_LENGTH = 2048;
17
+ export const MAX_QUERY_LENGTH = 1024;
18
+ export const MAX_SAME_HOST_REDIRECTS = 5;
19
+ const V4_BLOCKED = [
20
+ ["0.0.0.0", 8],
21
+ ["10.0.0.0", 8],
22
+ ["100.64.0.0", 10],
23
+ ["127.0.0.0", 8],
24
+ ["169.254.0.0", 16],
25
+ ["172.16.0.0", 12],
26
+ ["192.0.0.0", 24],
27
+ ["192.0.2.0", 24],
28
+ ["192.168.0.0", 16],
29
+ ["198.18.0.0", 15],
30
+ ["198.51.100.0", 24],
31
+ ["203.0.113.0", 24],
32
+ ["224.0.0.0", 4],
33
+ ["240.0.0.0", 4],
34
+ ];
35
+ const V6_BLOCKED = [
36
+ ["::", 96],
37
+ ["::1", 128],
38
+ ["100::", 64],
39
+ ["2001:db8::", 32],
40
+ ["fc00::", 7],
41
+ ["fe80::", 10],
42
+ ["fec0::", 10],
43
+ ["ff00::", 8],
44
+ ];
45
+ /** 常见云元数据地址:错误说明里单独点名。 */
46
+ const METADATA = new Set(["169.254.169.254", "100.100.100.200", "fd00:ec2::254"]);
47
+ const blocked = new BlockList();
48
+ for (const [net, prefix] of V4_BLOCKED)
49
+ blocked.addSubnet(net, prefix, "ipv4");
50
+ for (const [net, prefix] of V6_BLOCKED)
51
+ blocked.addSubnet(net, prefix, "ipv6");
52
+ /** IPv6 文本 → 8 个 16 位组;不合法返回 undefined。支持 `::` 与末尾点分 IPv4。 */
53
+ export function ipv6Groups(address) {
54
+ let text = address.replace(/^\[|\]$/g, "").replace(/%.*$/, "");
55
+ const v4 = /(\d+\.\d+\.\d+\.\d+)$/.exec(text);
56
+ if (v4 !== null) {
57
+ const parts = v4[1].split(".").map(Number);
58
+ if (parts.some((p) => p > 255))
59
+ return undefined;
60
+ const [a, b, c, d] = parts;
61
+ text = `${text.slice(0, v4.index)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;
62
+ }
63
+ const halves = text.split("::");
64
+ if (halves.length > 2)
65
+ return undefined;
66
+ const parse = (part) => part === ""
67
+ ? []
68
+ : part.split(":").map((g) => (/^[0-9a-f]{1,4}$/i.test(g) ? parseInt(g, 16) : NaN));
69
+ const head = parse(halves[0]);
70
+ const tail = halves.length === 2 ? parse(halves[1]) : [];
71
+ const fill = 8 - head.length - tail.length;
72
+ if (halves.length === 1 ? fill !== 0 : fill < 1)
73
+ return undefined;
74
+ const groups = [...head, ...new Array(halves.length === 2 ? fill : 0).fill(0), ...tail];
75
+ return groups.some((g) => Number.isNaN(g)) ? undefined : groups;
76
+ }
77
+ function v4FromGroups(hi, lo) {
78
+ return `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
79
+ }
80
+ /** IPv6 里嵌的 IPv4(映射 / 兼容 / NAT64 / 6to4);没有返回 undefined。 */
81
+ export function embeddedIPv4(address) {
82
+ const g = ipv6Groups(address);
83
+ if (g === undefined)
84
+ return undefined;
85
+ const zeros = (from, to) => g.slice(from, to).every((x) => x === 0);
86
+ // 映射 ::ffff:a.b.c.d;兼容 ::a.b.c.d 只认首段非 0 的(`::`、`::1` 留给 IPv6 段表)
87
+ if (zeros(0, 5) && (g[5] === 0xffff || (g[5] === 0 && g[6] >= 0x100)))
88
+ return v4FromGroups(g[6], g[7]);
89
+ if (g[0] === 0x64 && g[1] === 0xff9b && zeros(2, 6))
90
+ return v4FromGroups(g[6], g[7]);
91
+ if (g[0] === 0x2002)
92
+ return v4FromGroups(g[1], g[2]);
93
+ return undefined;
94
+ }
95
+ /**
96
+ * 地址是否落在禁止段;落在里面返回英文原因(进工具结果),否则 undefined。
97
+ * 不是 IP 文本的一律按禁止(调用方只该传解析结果)。
98
+ */
99
+ export function blockedAddressReason(address) {
100
+ const bare = address.replace(/^\[|\]$/g, "").replace(/%.*$/, "");
101
+ const family = isIP(bare);
102
+ if (family === 0)
103
+ return `${address} is not an IP address`;
104
+ const lower = bare.toLowerCase();
105
+ if (METADATA.has(lower))
106
+ return `${bare} is a cloud metadata address`;
107
+ if (family === 4) {
108
+ if (bare === "255.255.255.255" || blocked.check(bare, "ipv4"))
109
+ return `${bare} is a private, loopback, link-local or reserved address`;
110
+ return undefined;
111
+ }
112
+ const inner = embeddedIPv4(lower);
113
+ if (inner !== undefined) {
114
+ const reason = blockedAddressReason(inner);
115
+ if (reason !== undefined)
116
+ return `${bare} embeds ${inner}: ${reason}`;
117
+ }
118
+ if (blocked.check(lower, "ipv6"))
119
+ return `${bare} is a private, loopback, link-local or multicast address`;
120
+ return undefined;
121
+ }
122
+ /** 主机名:去方括号与末尾点,小写。 */
123
+ export function bareHostname(url) {
124
+ return url.hostname
125
+ .replace(/^\[|\]$/g, "")
126
+ .replace(/\.$/, "")
127
+ .toLowerCase();
128
+ }
129
+ /** 静态校验(不查 DNS):协议、userinfo、长度、主机名形状、IP 字面量。 */
130
+ export function checkFetchUrl(raw) {
131
+ if (typeof raw !== "string" || raw.trim() === "")
132
+ return { ok: false, reason: "url is empty" };
133
+ const text = raw.trim();
134
+ if (text.length > MAX_URL_LENGTH)
135
+ return { ok: false, reason: `URL is longer than ${MAX_URL_LENGTH} characters` };
136
+ let url;
137
+ try {
138
+ url = new URL(text);
139
+ }
140
+ catch {
141
+ return { ok: false, reason: "not a valid absolute URL" };
142
+ }
143
+ if (url.protocol !== "http:" && url.protocol !== "https:")
144
+ return { ok: false, reason: `only http and https URLs are supported (got ${url.protocol})` };
145
+ if (url.username !== "" || url.password !== "")
146
+ return { ok: false, reason: "URLs with credentials (user:pass@) are not allowed" };
147
+ if (url.search.length > MAX_QUERY_LENGTH + 1)
148
+ return { ok: false, reason: `query string is longer than ${MAX_QUERY_LENGTH} characters` };
149
+ const hostname = bareHostname(url);
150
+ if (isIP(hostname) !== 0) {
151
+ const reason = blockedAddressReason(hostname);
152
+ return reason === undefined ? { ok: true, url, hostname } : { ok: false, reason };
153
+ }
154
+ if (!hostname.includes(".") || hostname === "localhost" || hostname.endsWith(".localhost"))
155
+ return {
156
+ ok: false,
157
+ reason: `${hostname} is a local host name; web_fetch only reaches public hosts`,
158
+ };
159
+ return { ok: true, url, hostname };
160
+ }
161
+ /** 缺省解析:系统解析器,返回全部地址。 */
162
+ export const systemLookup = (hostname) => new Promise((resolve, reject) => {
163
+ dnsLookup(hostname, { all: true, verbatim: true }, (err, addresses) => {
164
+ if (err)
165
+ reject(err);
166
+ else
167
+ resolve(addresses);
168
+ });
169
+ });
170
+ /** 解析主机名并检查全部地址;IP 字面量直接返回。 */
171
+ export async function resolvePublicAddresses(hostname, lookup = systemLookup) {
172
+ const family = isIP(hostname);
173
+ if (family !== 0) {
174
+ const reason = blockedAddressReason(hostname);
175
+ return reason === undefined
176
+ ? { ok: true, addresses: [{ address: hostname, family }] }
177
+ : { ok: false, reason };
178
+ }
179
+ let addresses;
180
+ try {
181
+ addresses = await lookup(hostname);
182
+ }
183
+ catch (err) {
184
+ const code = err.code;
185
+ return { ok: false, reason: `cannot resolve ${hostname}${code ? ` (${code})` : ""}` };
186
+ }
187
+ if (addresses.length === 0)
188
+ return { ok: false, reason: `cannot resolve ${hostname}` };
189
+ for (const { address } of addresses) {
190
+ const reason = blockedAddressReason(address);
191
+ if (reason !== undefined)
192
+ return { ok: false, reason: `${hostname} resolves to ${reason}` };
193
+ }
194
+ return { ok: true, addresses };
195
+ }
196
+ /** `http.request({ lookup })` 用:只返回已检查过的地址(兼容 `all: true` 与单地址两种调用)。 */
197
+ export function pinnedLookup(addresses) {
198
+ return (_hostname, options, callback) => {
199
+ const cb = (typeof options === "function" ? options : callback);
200
+ const all = typeof options === "object" && options !== null && options.all;
201
+ const first = addresses[0];
202
+ if (all === true)
203
+ cb(null, [...addresses]);
204
+ else
205
+ cb(null, first.address, first.family);
206
+ };
207
+ }
208
+ /**
209
+ * 重定向能否自动跟:同主机名,且同协议同端口或 http → https 升级。其余(跨主机、换端口、
210
+ * https 降到 http)不跟:交给模型再调一次 web_fetch,下一次调用重新过权限。
211
+ */
212
+ export function redirectPolicy(from, to) {
213
+ if (bareHostname(from) !== bareHostname(to))
214
+ return "cross-host";
215
+ if (from.protocol === "http:" && to.protocol === "https:")
216
+ return "follow";
217
+ return from.protocol === to.protocol && portOf(from) === portOf(to) ? "follow" : "cross-host";
218
+ }
219
+ function portOf(url) {
220
+ return url.port !== "" ? url.port : url.protocol === "https:" ? "443" : "80";
221
+ }
@@ -39,7 +39,7 @@ Read-only commands are a static list (next section) shared by `plan` and `allowl
39
39
 
40
40
  ### Origin labels in the approval dialog
41
41
 
42
- Approvals do not only come from the main session. The dialog (and RPC `permission_request.context`) shows where a request comes from; the options are always just "allow / allow this kind for the session / deny":
42
+ Approvals do not only come from the main session. The dialog (and RPC `permission_request.context`) shows where a request comes from; the options are always just "allow / allow this kind for the session / deny" (for `web_fetch` the second one is "allow `<host>` for this session"):
43
43
 
44
44
  | Origin | Title prefix | Body | Who decides |
45
45
  | --------------------------------------------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
@@ -71,7 +71,28 @@ Read-only commands (`src/permissions/readonly-bash.ts`): first they pass auto's
71
71
  | `tree`, `file`, `jq` | `tree -o`, `file -C`, `jq -i` do not count |
72
72
  | `git status / log / show / diff / rev-parse / blame / ls-files / branch` | Only the git global options `-C` and `--no-pager` are allowed; `--output`, `--ext-diff`, `--textconv` and branch-changing options do not count |
73
73
 
74
- This is narrower than auto's safe list: no test / build runners (`npm test` and `cargo build` run project scripts), and no `env` / `printenv` (they would print secrets in environment variables into the context). Reading secret paths (`cat .env`) is not read-only.
74
+ This is narrower than auto's safe list: no test / build runners (`npm test` and `cargo build` run project scripts). `env` / `printenv` (they would print secrets in environment variables into the context) are on neither list. Reading secret paths (`cat .env`) is not read-only.
75
+
76
+ ## Network class: web_fetch
77
+
78
+ `web_fetch` is the only network tool (it reads one URL; there is no search), with permission class `network`:
79
+
80
+ | Mode | `web_fetch` |
81
+ | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
82
+ | `default`, `auto-edit` | Ask; the second option is "allow `<host>` for this session", after which the same host (any path, port or letter case) no longer asks |
83
+ | `plan` | Ask (read-only, but it can carry data out; research often needs docs, so it is not denied). Denied inside read-only sub-agents |
84
+ | `allowlist` | Only calls matched by an allow rule; everything else is denied |
85
+ | `auto` | Rule tier: a URL that is not public http(s) (local names, private / metadata IPs, `file:`, credentials) is denied; allow rules, hook allow and session grants allow; the rest goes to the classifier (public docs and package-registry pages are routine; a query string that looks like a key or a long random token asks) |
86
+ | `full-auto` | Allow |
87
+
88
+ Unattended (`-p`), every ask becomes a deny; use `--allow 'web_fetch(<domain>)'` to let it through.
89
+
90
+ - **Rules** `web_fetch(<pattern>)` match `host/path` (no scheme, port or query string; lower-case host). A pattern without `/` compares the host only: a bare domain `example.com` matches itself and all subdomains, `*.example.com` only subdomains. A pattern with `/` is a glob over the whole `host/path`, where `*` does not cross `/` and `**` does (`docs.example.com/api/**`). Deny rules are checked first in every mode: `permission.deny: ["web_fetch(*.internal.example.com)"]`.
91
+ - **Execution guard** (every mode, `full-auto` included): only `http:` / `https:`, no URLs with credentials; URL ≤ 2048 characters, query string ≤ 1024; the host name must contain a `.` (`localhost`, `*.localhost` and dotless hosts are refused; reach local services through an approved bash call). **Every** resolved address must be outside loopback, `0.0.0.0/8`, RFC 1918, `100.64/10`, link-local (including the cloud metadata addresses `169.254.169.254`, `100.100.100.200`, `fd00:ec2::254`), reserved and multicast ranges, IPv6 ULA `fc00::/7`, `fe80::/10`, `fec0::/10`, `ff00::/8`; IPv4 embedded in mapped / NAT64 / 6to4 addresses is checked as IPv4. The connection is pinned to the checked addresses so a re-resolution cannot swap in an internal one (DNS rebinding).
92
+ - **Redirects**: same host (same scheme and port, or an http → https upgrade) is followed up to 5 hops, re-checked at each hop; a cross-host redirect is not followed — the result tells the model the target URL, and calling it again goes through permissions again.
93
+ - **Limits**: 30 s total, 15 s to the first byte, response body (after decompression) ≤ 5 MB; GET only, no cookies or custom headers. Output is cut at 50 000 characters by default (200 000 at most); when cut, the full text goes to the session's `outputs/<call id>.txt` and the model continues with `offset`. The first line is always `Content fetched from <URL> (untrusted):` to mark external content.
94
+ - **Turning it off**: `tools.disabled: ["web_fetch"]` (user, profile or project config), `tools.default: ["-web_fetch"]`, host `tools.disable("web_fetch")`, or `permission.deny: ["web_fetch"]`. No new config keys or environment variables.
95
+ - `web_fetch` runs inside the ama process: the bash OS sandbox and `sandbox.network` do not apply, and it does not use `HTTPS_PROXY` (a proxy resolves host names itself, so the address could not be pinned); see [sandbox.md](../../guides/sandbox.md) "Known bypasses" (Chinese).
75
96
 
76
97
  ## Decision order
77
98
 
@@ -133,6 +154,7 @@ No model call; a match asks (denied when unattended):
133
154
  | Secret paths | Reading or writing `.env`, `.env.*` (except `.env.example` / `.sample` / `.template` / `.dist`), `.ssh/`, `.gnupg/`, `.aws/`, `.kube/config`, `.docker/config.json`, `.netrc`, `.pgpass`, private keys (`id_rsa` etc., `*.pem`, `*.key`, `*.p12`, `*.pfx`, `*.jks`), ama's `auth.json` |
134
155
  | Protected writes | Writing inside `.git/`, the project's `.ama/` (could change hooks and config), or any path outside the project directory (except `/dev/null` and the like) |
135
156
  | Network commands | `curl`, `wget`, `ssh`, `scp`, `rsync`, `nc`, `gh`; `git push / pull / fetch / clone`; `npm / pnpm / yarn / bun install / add / ci / update / publish / dlx`, `npx`; `pip install`, `cargo install / publish`, `go get / install`, `brew / apt install`, `docker pull / push / login` and so on |
157
+ | Web fetch | A `web_fetch` URL that is not public http(s) is **denied** outright (no prompt; see [Network class](#network-class-web_fetch)) |
136
158
  | Deletion and rollback | `rm -r` / `rm -f`, `find -delete`, `git clean`, `git checkout -- …` / `git restore`, `git stash drop / clear`, `shred`, `truncate` |
137
159
  | Paths in bash | Redirect targets (`>`, `>>`, `&>`, `tee`), and targets of `cp` / `mv` / `mkdir` / `touch` / `ln` / `chmod` outside the project or protected; secret paths among command arguments (`cat .env`) |
138
160
 
@@ -153,15 +175,15 @@ No model call; allowed when satisfied:
153
175
 
154
176
  The safe list (`src/permissions/auto-safe.ts`, each entry with positive and negative tests):
155
177
 
156
- | Command | Restrictions |
157
- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
158
- | `ls`, `cat`, `head`, `tail`, `wc`, `grep`, `egrep`, `fgrep`, `rg`, `pwd`, `echo`, `printf`, `which`, `env` and `printenv` (printing only), `true`, `false`, `sort`, `cut`, `tr`, `diff`, `basename`, `dirname`, `realpath`, `stat`, `file`, `du`, `df`, `date`, `whoami`, `uname`, `cd` / `pushd` / `popd` (later relative paths resolve against the new directory) | `env FOO=1 cmd` is judged as `cmd` |
159
- | `find` | Without `-exec`, `-execdir`, `-ok`, `-okdir`, `-delete`, `-fprint*`, `-fls` |
160
- | `git status / diff / log / show / rev-parse / blame / ls-files` (`diff / log / show` without `--output`, `--ext-diff`, `--textconv`; `rg` without `--pre`; `sort` without `-o`; `date` without `-s`) | Only `-C dir` and `--no-pager` before the subcommand (`-c` can change the pager and is not safe) |
161
- | `git branch` | Listing only: without `-d / -D / --delete / -m / -M / -c / -C / -f / -u` etc. |
162
- | `npm / pnpm / yarn test`, `… run test / lint / typecheck / build`, `pnpm / yarn lint / typecheck / build`, `npm t` | |
163
- | `node --test`, `tsc --noEmit`, `vitest run`, `pnpm vitest run`, `pnpm exec vitest run`, `pytest`, `python -m pytest` | |
164
- | `cargo test / check / build / clippy`, `go test / build / vet`, `make test / check / lint / build` | |
178
+ | Command | Restrictions |
179
+ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
180
+ | `ls`, `cat`, `head`, `tail`, `wc`, `grep`, `egrep`, `fgrep`, `rg`, `pwd`, `echo`, `printf`, `which`, `true`, `false`, `sort`, `cut`, `tr`, `diff`, `basename`, `dirname`, `realpath`, `stat`, `file`, `du`, `df`, `date`, `whoami`, `uname`, `cd` / `pushd` / `popd` (later relative paths resolve against the new directory) | `env FOO=1 cmd` is judged as `cmd`; `env` / `printenv` print the whole environment (secrets included) and are not on the list |
181
+ | `find` | Without `-exec`, `-execdir`, `-ok`, `-okdir`, `-delete`, `-fprint*`, `-fls` |
182
+ | `git status / diff / log / show / rev-parse / blame / ls-files` (`diff / log / show` without `--output`, `--ext-diff`, `--textconv`; `rg` without `--pre`; `sort` without `-o`; `date` without `-s`) | Only `-C dir` and `--no-pager` before the subcommand (`-c` can change the pager and is not safe) |
183
+ | `git branch` | Listing only: without `-d / -D / --delete / -m / -M / -c / -C / -f / -u` etc. |
184
+ | `npm / pnpm / yarn test`, `… run test / lint / typecheck / build`, `pnpm / yarn lint / typecheck / build`, `npm t` | |
185
+ | `node --test`, `tsc --noEmit`, `vitest run`, `pnpm vitest run`, `pnpm exec vitest run`, `pytest`, `python -m pytest` | |
186
+ | `cargo test / check / build / clippy`, `go test / build / vet`, `make test / check / lint / build` | |
165
187
 
166
188
  Extending it: the user-level config `permission.autoSafeCommands` adds entries, e.g. `["just test", "bun test", "make fmt"]`, matched by word prefix (`just test` matches `just test --verbose`); entries containing `*` match the whole segment as a glob (`bun run test*`). Project config cannot add entries (that would loosen). Added commands still pass ① first: network, deletion and writes outside the project still ask.
167
189
 
@@ -147,9 +147,9 @@ These are deliberate choices, not omissions:
147
147
 
148
148
  The model uses ACP agents through `task(agent="acp:<program>")` (ama itself is `task(agent="acp:ama")`; see "Using them in task" in [agents.md](../../guides/agents.md), Chinese).
149
149
 
150
- `AcpClient` (`@armadra/agent/acp`): `initialize`, `newSession`, `resumeSession` (preferred, no replay), `loadSession`, `listSessions`, `closeSession`, `prompt`, `setMode`, `cancel`.
150
+ `AcpClient` (`@armadra/agent/acp`): `initialize`, `authenticate`, `logout`, `newSession`, `resumeSession` (preferred, no replay), `loadSession`, `listSessions`, `listAllSessions`, `closeSession`, `prompt`, `setMode`, `cancel`.
151
151
 
152
- - Declared client capabilities: `session.configOptions: {}` (select options; boolean is not declared); `fs` is not declared (`fs/*` requests from the agent answer method not found) and `terminal` only when `onTerminal` is given (see below), and neither is `auth.terminal` (ama has no interactive terminal to lend to the agent).
152
+ - Declared client capabilities: `session.configOptions: {}` (select options; boolean is not declared); `fs` is not declared (`fs/*` requests from the agent answer method not found) and `terminal` only when `onTerminal` is given (see below), and `auth.terminal` is not declared by default either (ama has no interactive terminal to lend to the agent; a host that has one passes `authTerminal: true`, see below).
153
153
  - `$/cancel_request` is on in both directions: aborting the `signal` of a request after it was sent tells the agent to withdraw it; when the agent withdraws a pending `session/request_permission` / `elicitation/create`, the handler's `signal` aborts and the answer is `cancelled` / `cancel` (a choice the handler makes afterwards does not count).
154
154
  - `session/request_permission` goes to `onPermission`; without a handler the first `reject_once` is answered (unattended).
155
155
  - `cancel(sessionId)` sends `session/cancel` and answers `cancelled` to that session's pending permission requests (required by the spec).
@@ -158,7 +158,11 @@ The model uses ACP agents through `task(agent="acp:<program>")` (ama itself is `
158
158
  - `elicitation/create` (the agent asking a human for structured input): only when the constructor is given `onElicitation(params, signal)` does `initialize` declare `clientCapabilities.elicitation` and accept this request; otherwise it is not declared and the request answers method not found (as in older versions). Answers are normalized to `{ action: "accept" | "decline" | "cancel", content? }` (`content` only with accept; unknown actions count as cancel); pending ones answer `{ action: "cancel" }` on `cancel(sessionId)` and when the connection closes. ama itself, as a client, gives no handler and never fills in forms for a human.
159
159
  - Session config options: the `configOptions` of session-open answers (new / load / resume) are passed through; `setConfigOption(sessionId, configId, value)` sends `session/set_config_option`, and the answer is the new state of all options.
160
160
  - `terminal/*` (the agent asking the host for a terminal): only when the constructor is given `onTerminal(method, params, signal)` does `initialize` declare `clientCapabilities.terminal: true`; `terminal/create|output|wait_for_exit|kill|release` are then handed to it with the params untouched, its return value is the answer and throwing `RpcError` answers with that error. Without it `terminal: false` is declared and the requests answer method not found (as in older versions). Permissions, cwd restriction and output limits are the host's job; ama itself, as a client, gives no handler.
161
- - `AcpClient.features`: `{ mcpServers, elicitation, configOptions, terminal }`, for feature detection by hosts.
161
+ - Authentication: `authenticate(methodId, signal?)` sends `authenticate { methodId }`; `logout(signal?)` sends `logout` (available when the agent declares `agentCapabilities.auth.logout`, see `supportsLogout()`). `authMethods` keeps the id, name, description and type from `initialize`, plus `args` and `env` for terminal methods (`isTerminalAuthMethod` tells them apart). The spec forbids passing a terminal method to `authenticate`: when the method is known to be a terminal one, `authenticate` throws -32602 locally without sending anything, and the host runs the agent command plus the method's `args` and `env` in a terminal (exit code 0 is success). Only the constructor option `authTerminal: true` declares `clientCapabilities.auth.terminal`, which is what makes the agent offer terminal methods. On -32000 (auth_required) when opening a session or sending a prompt, `isAuthRequiredError(error)` recognizes it and the host shows `authMethods` to the user. ama itself, as a client, passes no `authTerminal` and never signs in for a human.
162
+ - `elicitation/complete` (agent → client notification: a URL elicitation finished out of band): only when the constructor is given `onElicitationComplete({ elicitationId })` is it handled, and `initialize` then declares `elicitation` as `{ form: {}, url: {} }` (with only `onElicitation` it stays `{}`). If a request with the same `elicitationId` is still unanswered, its handler's `signal` aborts and it answers `{ action: "cancel" }`, so a host can dismiss the card; notifications without a string `elicitationId` are ignored.
163
+ - Form fields: besides `string` / `number` / `integer` / `boolean`, `type: "array"` is a string multi-select (`items: { type: "string", enum }` or titled `items: { anyOf: [{ const, title }] }`, optionally with `minItems` / `maxItems`), and a `string` field may carry `oneOf: [{ const, title }]` (titled single select). In the answer's `content` a multi-select value is an array of strings. `multiSelectOptions(field)` / `singleSelectOptions(field)` normalize those forms into a `{ const, title }` list (undefined for other controls); unknown `type`s are kept as is and must not be rendered as a known control.
164
+ - Session management: `listSessions(cwd?, cursor?, signal?)` sends `session/list` (`supportsList()`), `listAllSessions(cwd?, signal?, maxPages = 100)` follows `nextCursor` to fetch them all; `closeSession(sessionId, signal?)` sends `session/close` (`supportsClose()`). The spec makes closing equivalent to cancelling first, so `closeSession` first answers that session's pending permission requests with `cancelled` and its elicitations with `cancel`; other sessions are untouched. An agent that did not declare the capability answers -32601 and one that needs sign-in answers -32000; both codes are thrown as they are.
165
+ - `AcpClient.features`: `{ mcpServers, elicitation, configOptions, terminal, auth, elicitationComplete }`, for feature detection by hosts.
162
166
 
163
167
  `AcpDriver` implements the driver contract (`AgentDriver`) on top of the client:
164
168
 
@@ -5,7 +5,7 @@ English · [简体中文](../../reference/host-api.md)
5
5
  > Translated from the Chinese [docs/reference/host-api.md](../../reference/host-api.md) as of commit `979608b`. When the two differ, the Chinese
6
6
  > version is authoritative.
7
7
 
8
- A host adapter is a local JS module that ama loads at startup and hands a `HostApi`. With it the adapter can register tools, append to the system prompt, observe events, answer approvals, inject user messages and show notifications and status in the interface. The Armadra canvas plugs in as a host adapter (the canvas tools `canvas_*` / `context_*` are all registered by the adapter). The types are defined in `src/host/types.ts` and exported from `@armadra/agent/host`; `HOST_API_VERSION = 1`. The design rationale is in [design.md](../../design/design.md) §6.2, §6.3 and §11.1 step 13 (Chinese).
8
+ A host adapter is a local JS module that ama loads at startup and hands a `HostApi`. With it the adapter can register tools, append to the system prompt, observe events, answer approvals, inject user messages and show notifications and status in the interface. The Armadra canvas plugs in as a host adapter (the canvas tools `canvas_*` / `context_*` are all registered by the adapter). The types are defined in `src/host/types.ts` and exported from `@armadra/agent/host`; `HOST_API_VERSION = 1`, `HOST_API_MINOR = 1` (host-api 1.1, see "Versioning"). The design rationale is in [design.md](../../design/design.md) §6.2, §6.3 and §11.1 step 13 (Chinese).
9
9
 
10
10
  ## Module shape
11
11
 
@@ -51,26 +51,40 @@ export function create(api) {
51
51
  | `create()` throws or does not return within 10 seconds | 6 |
52
52
  | The returned adapter has no `id` | 6 |
53
53
 
54
+ ## Versioning
55
+
56
+ - **Major** `HOST_API_VERSION` (`api.version`): the host's exported `hostApi` must equal it exactly. It changes only for breaking changes (removing or changing existing members or semantics) and then requires a breaking ama release (`pnpm release:check`).
57
+ - **Minor** `HOST_API_MINOR` (`api.minor`): bumped once per batch of additive optional members; existing hosts are unaffected and the major version stays. Older runtimes have no `api.minor`, which reads as 0.
58
+ - Hosts feature-detect by member presence (for example `typeof api.runners?.onDone === "function"`), which is more reliable than comparing `api.agent.version`.
59
+
60
+ | Minor | First ama version | Added |
61
+ | ----- | ----------------- | -------------------------------------------------------------------------------------------------- |
62
+ | 0 | — | Every existing member (optional facets such as `cache?` and `runners?.provide` still need a check) |
63
+ | 1 | Unreleased | `minor`, `runners?.onDone` (see "Runner done event") |
64
+
54
65
  ## HostApi
55
66
 
56
- | Member | Description |
57
- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
58
- | `version` | `HOST_API_VERSION` |
59
- | `agent` | `{ name: "ama", version }` |
60
- | `env` | A frozen copy of the environment variables at startup |
61
- | `mode` | `interactive` / `line` / `print` / `rpc` |
62
- | `session.id()` / `file()` / `cwd()` / `model()` | The current session (follows the new session after a switch); `file()` is `undefined` until the first request is written to disk |
63
- | `tools.register(tool)` | Register a tool (shape below); the name must match `^[a-z][a-z0-9_]{1,63}$`, and an existing name throws `tool_exists`. A prefix is recommended (`canvas_*`) |
64
- | `tools.disable(name)` | Hide a built-in tool (Armadra disables `task`, for example); the tools section of the system prompt stops listing it |
65
- | `tools.list()` | All current tool names |
66
- | `instructions.add(source)` | Append to the final `host` section of the system prompt; `{ kind: "file", path }` or `{ kind: "text", text, name? }` |
67
- | `events.on(name, handler)` | Observe events (table below); returns an unsubscribe function |
68
- | `approvals.setBroker(broker)` | Set the approval answerer (see "Approvals") |
69
- | `messages.sendUser(text, origin?)` | Inject a user message: when idle it starts a run (`"started"`), while running it is queued as a steer (`"queued"`); `origin` defaults to `"host"`, is persisted on the message and shown as `↳ host` |
70
- | `ui.notify(message, level?)` | Goes to the message area in interactive / line mode; becomes a `notification` event in rpc mode (with a copy on stderr); written to stderr in print mode |
71
- | `ui.setStatus(key, text?)` | A host item in the status bar; an empty or missing `text` removes the key |
72
- | `log(level, message, detail?)` | Logging; `warn` / `error` go to stderr |
73
- | `cache?.onWarmingDecision(handler)` | Veto hook for cache warming (see "Cache warming"); an optional facet missing in older runtimes, so check `api.cache !== undefined` before use |
67
+ | Member | Description |
68
+ | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
69
+ | `version` | `HOST_API_VERSION` |
70
+ | `agent` | `{ name: "ama", version }` |
71
+ | `env` | A frozen copy of the environment variables at startup |
72
+ | `mode` | `interactive` / `line` / `print` / `rpc` |
73
+ | `session.id()` / `file()` / `cwd()` / `model()` | The current session (follows the new session after a switch); `file()` is `undefined` until the first request is written to disk |
74
+ | `tools.register(tool)` | Register a tool (shape below); the name must match `^[a-z][a-z0-9_]{1,63}$`, and an existing name throws `tool_exists`. A prefix is recommended (`canvas_*`) |
75
+ | `tools.disable(name)` | Hide a built-in tool (Armadra disables `task`, for example); the tools section of the system prompt stops listing it |
76
+ | `tools.list()` | All current tool names |
77
+ | `instructions.add(source)` | Append to the final `host` section of the system prompt; `{ kind: "file", path }` or `{ kind: "text", text, name? }` |
78
+ | `events.on(name, handler)` | Observe events (table below); returns an unsubscribe function |
79
+ | `approvals.setBroker(broker)` | Set the approval answerer (see "Approvals") |
80
+ | `messages.sendUser(text, origin?)` | Inject a user message: when idle it starts a run (`"started"`), while running it is queued as a steer (`"queued"`); `origin` defaults to `"host"`, is persisted on the message and shown as `↳ host` |
81
+ | `ui.notify(message, level?)` | Goes to the message area in interactive / line mode; becomes a `notification` event in rpc mode (with a copy on stderr); written to stderr in print mode |
82
+ | `ui.setStatus(key, text?)` | A host item in the status bar; an empty or missing `text` removes the key |
83
+ | `log(level, message, detail?)` | Logging; `warn` / `error` go to stderr |
84
+ | `cache?.onWarmingDecision(handler)` | Veto hook for cache warming (see "Cache warming"); an optional facet missing in older runtimes, so check `api.cache !== undefined` before use |
85
+ | `runners?.provide(runner)` | Injects a sub-agent runner: `id` is the name in `task(agent=<id>)` and `description` goes into the task tool description; with a host, ama starts no external CLI of its own and only uses runners injected here; returns an unsubscribe function |
86
+ | `runners?.onDone(handler)` | Called whenever a round of a host runner ends (see "Runner done event"); returns an unsubscribe function. Since host-api 1.1, so check `typeof api.runners?.onDone === "function"` before use |
87
+ | `minor?` | `HOST_API_MINOR` (see "Versioning"); missing in older runtimes |
74
88
 
75
89
  During `create()` the session is not assembled yet: `session.*` returns the values fixed at startup, and `sendUser` is rejected with `busy`. To send a message at startup, wait for the `session_start` event.
76
90
 
@@ -104,6 +118,8 @@ interface ToolResult {
104
118
 
105
119
  Host tools take the same path as built-in tools: schema validation → command hook PreToolUse → permission pipeline (classified by `permission`) → approval → execution → PostToolUse. They can also be called from codemode scripts as `tools.<name>()`.
106
120
 
121
+ The exported `ToolPermission` type gains `"network"` (used by the built-in `web_fetch`; judged as execute and asks in plan, see [permissions.md](../guides/permissions.md) "Network class"); tools registered by a host through `tools.register` still accept only `read` / `write` / `execute`, and `HOST_API_VERSION` is unchanged.
122
+
107
123
  ## Events
108
124
 
109
125
  `events.on` handlers only observe: a throw is just logged and does not affect the run; handlers are called and awaited in order, and `session_shutdown` is awaited (so you can clean up before exit).
@@ -157,6 +173,32 @@ interface WarmDecision {
157
173
 
158
174
  Return `"warm"` / `"stop"` (a Promise is fine). `"stop"` skips the request and stops this warming round (when the built-in decision was warm, the stop reason is recorded as `declined`); `"warm"` can override a built-in stop. If the handler fails, the built-in decision applies. With several handlers, the last one registered and not unsubscribed wins; the return value is an unsubscribe function. The warming mechanism itself is described in [providers.md](../guides/providers.md) "Caching".
159
175
 
176
+ ## Runner done event
177
+
178
+ `api.runners.onDone(handler)` (host-api 1.1): every time a runner injected through `runners.provide` finishes a round, `handler` is called once with the payload below. A round is the first one after `start()`, or the next one after `send` / `interrupt`; it ends when ama's `wait()` for it settles (returns, throws, or the task is aborted).
179
+
180
+ ```ts
181
+ interface RunnerDoneEvent {
182
+ runner: string; // host runner id (the name in task(agent=<id>))
183
+ taskId?: string; // ama task id (SubagentRunRequest.taskId); missing when the runner is called directly
184
+ sessionId: string; // the runner's own session id (RunnerHandle.id)
185
+ round: number; // round number, starting at 1
186
+ status: "completed" | "failed" | "aborted" | "max_turns" | "interrupted";
187
+ isError: boolean; // status is failed or aborted
188
+ text?: string; // result text returned by wait()
189
+ error?: string; // error message thrown by wait()
190
+ startedAt: number; // when the round began (start / send / interrupt), epoch milliseconds
191
+ endedAt: number; // when it settled
192
+ durationMs: number;
193
+ }
194
+ ```
195
+
196
+ - `status` follows the task registry: the task's `signal` aborted or `stop()` called → `aborted`; otherwise the terminal status the result reports (`max_turns`, `interrupted`, …); otherwise `failed` / `completed` by `isError`. A `wait()` that throws without an abort → `failed` with `error`.
197
+ - Observe-only: handlers are not awaited; a throw (including a rejected Promise) is logged once at error level through `log` and affects neither the `wait()` result nor other handlers.
198
+ - ama may call `wait()` several times in one round (the task entry and the registry each once); the event is sent once. Calling `wait()` again after the round settled, without a new `send` / `interrupt`, sends nothing. An `interrupt` while running is followed to the end by the current round instead of starting another.
199
+ - Only host-injected runners are covered; ama's own sub-sessions (`general` / `explore` / `plan`) still report through `subagent_start` / `subagent_end`.
200
+ - Use: the host no longer needs to poll whether the coordinator is still waiting on a task — the event says ama's side of it has ended.
201
+
160
202
  ## Exit
161
203
 
162
204
  - Process exit: `session_shutdown` event (awaited) → SessionEnd hook (`reason: "exit"`) → `adapter.dispose()` → session dispose. A throw from `dispose` is only logged as a warning.
@@ -124,13 +124,13 @@ After a session switch the server re-subscribes to events and sends `session_sta
124
124
 
125
125
  ### Tools, permissions, discovery
126
126
 
127
- | Command | Parameters | `data` |
128
- | --------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
129
- | `get_tools` | — | `{ tools: { name, description, parameters, permission, active }[] }` (every tool in the registry; `active` means the model sees it now) |
130
- | `set_active_tools` | `names: string[]` | `{ names }` (active tool names after the change) |
131
- | `set_permission_mode` | `mode: plan \| allowlist \| default \| auto-edit \| auto \| full-auto` | `{ mode }` (unknown mode → `invalid_arguments`) |
132
- | `get_commands` | — | `{ commands: { name, description?, source: "builtin" \| "template" \| "skill" }[] }`; Skill names are written `skill:<name>` |
133
- | `get_skills` | — | `{ skills: { name, description, location, … }[] }` (discovered Skills; `location` is the SKILL.md path) |
127
+ | Command | Parameters | `data` |
128
+ | --------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
129
+ | `get_tools` | — | `{ tools: { name, description, parameters, permission, active }[] }` (every tool in the registry; `active` means the model sees it now) |
130
+ | `set_active_tools` | `names: string[]` | `{ names }` (active tool names after the change) |
131
+ | `set_permission_mode` | `mode: plan \| allowlist \| default \| auto-edit \| auto \| full-auto` | `{ mode }` (unknown mode → `invalid_arguments`) |
132
+ | `get_commands` | — | `{ commands: { name, description?, source: "builtin" \| "template" \| "skill" }[] }`; Skill names are written `skill:<name>`; only built-in commands that are currently available are listed (no `memory` while memory is off, no `tasks` when task cannot be called) |
133
+ | `get_skills` | — | `{ skills: { name, description, location, … }[] }` (discovered Skills; `location` is the SKILL.md path) |
134
134
 
135
135
  ### Plans and tasks (wave 5)
136
136
 
@@ -86,7 +86,8 @@ ama 再跑一轮、在消息里要求「不要调用工具、直接给最终报
86
86
 
87
87
  是否后台的优先级:调用参数 `background` > 类型定义的 `background:` > config `subagents.background`。
88
88
  `subagents.background` 缺省 `auto`:交互界面、RPC、ACP 下后台(模型需要结果才写 `background: false`),`-p` 下前台;
89
- `always` / `never` 固定。两种缺省对应两版 `task` 工具描述,会话内不变,不影响缓存前缀稳定。
89
+ `always` / `never` 固定。
90
+ `coordinator` 工具预设(协调者只分派、不阻塞)下缺省固定为后台,不受 `-p` 与 `subagents.background` 影响。两种缺省对应两版 `task` 工具描述,会话内不变,不影响缓存前缀稳定。
90
91
 
91
92
  前台任务运行中可以转后台,任务不中断,`task` 工具调用立即返回一段固定英文结果(含 `taskId` 与输出文件),完成后照常发
92
93
  `<task-notification>`:
@@ -128,6 +129,8 @@ ama 再跑一轮、在消息里要求「不要调用工具、直接给最终报
128
129
  | `output` | 运行中返回已有输出,结束后返回最终文本(同样有 50 KB 上限) |
129
130
  | `send` | 向任务追加一条消息并放到后台运行(等价于 `task{taskId, prompt, background:true}`) |
130
131
 
132
+ `coordinator` 预设下 `wait` 的超时封顶 5 000 毫秒(含缺省与更大的 `timeoutMs`),超时回复里说明被封顶、完成后会有通知;其它预设不变。
133
+
131
134
  ### 续聊、保留与 resume
132
135
 
133
136
  每个任务有会话内唯一的 `taskId`(`t1`、`t2` …)。子会话结束后不立即释放,最多保留 4 个(`subagents.retainSessions`;
@@ -208,6 +211,20 @@ RPC `get_tasks` / `get_agents` 返回任务快照与可用类型(来源、定
208
211
  | `agents.<类型>.model` | 某个类型的模型(如让 `explore` 用便宜模型) |
209
212
  | `models.aliases.fast` / `.strong` | 定义文件里 `model: fast / strong` 的映射 |
210
213
 
214
+ 按角色选模型只要三行配置(`config.json`):别名 `fast` 指向便宜模型,`explore` 类型用它,外部 Agent `claude` 用它自己的模型名(原样交给该 CLI):
215
+
216
+ ```json
217
+ {
218
+ "models": { "aliases": { "fast": "<provider>/<cheap-model>" } },
219
+ "agents": {
220
+ "explore": { "model": "fast" },
221
+ "claude": { "model": "sonnet" }
222
+ }
223
+ }
224
+ ```
225
+
226
+ 内置类型不带缺省的便宜模型;不配置时按上面的顺序回落,最终继承父会话的模型。
227
+
211
228
  ### 限制
212
229
 
213
230
  - 子 Agent 不能再委派(深度 1);协调多个 Agent 的场景交给宿主(如 Armadra 画布)。