@armadra/agent 0.6.4 → 0.6.6

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/CHANGELOG.md CHANGED
@@ -5,6 +5,25 @@ English · [简体中文](CHANGELOG.zh-CN.md)
5
5
  > This file is in English starting with 0.6.0. Release notes for 0.1 through 0.5.1 are in Chinese in
6
6
  > [CHANGELOG.zh-CN.md](CHANGELOG.zh-CN.md). New entries go into both files.
7
7
 
8
+ ## 0.6.6 (2026-10-03)
9
+
10
+ - **Local search hints**: when both `grep` and `glob` are directly available, the system prompt's rules gain one line
11
+ ("Locate code with grep/glob before reading; do not guess file paths."); the `grep` / `glob` descriptions now say when
12
+ to use them; `grep` takes `filesOnly: true` to list only the matching files (deduplicated, sorted by path, `limit` counts
13
+ files). `read` on a directory now points at a tool the model can actually call: `ls` when active, otherwise `glob`
14
+ (e.g. `pattern "src/*"`), otherwise a file inside it. In `-p` with the `minimal` / `coordinator` preset, a denied bash
15
+ `grep` / `rg` / `find` adds one stderr line on how to bring the tools back (`tools.default: ["+grep","+glob"]`). Tools
16
+ get the session's active tool set as the optional, read-only `ToolContext.activeTools`. This changes the `default`
17
+ prefix (about +35 tokens), so the first request after upgrading misses the cache once. Docs: docs/design.md §5.6,
18
+ docs/codemode.md.
19
+
20
+ ## 0.6.5 (2026-10-03)
21
+
22
+ - **`AcpClient` can pass MCP servers when opening a session**: `newSession`, `resumeSession` and `loadSession` take an
23
+ optional third argument `{ mcpServers }` that is forwarded as-is in `session/new|resume|load` (default still `[]`, the
24
+ wire is unchanged when it is omitted). `AcpClient.features.mcpServers` lets a host detect support. ama itself still
25
+ sends none. Docs: docs/acp.md "As a client".
26
+
8
27
  ## 0.6.4 (2026-10-03)
9
28
 
10
29
  - **Interrupt and send now**: while a run is in progress, Enter still queues the message as a steer (delivered at the next
@@ -4,6 +4,21 @@
4
4
 
5
5
  > 从 0.6.0 起 [CHANGELOG.md](CHANGELOG.md) 为英文,本文件保留中文记录(0.1–0.5.1 的完整历史在此)。新条目两份都要加。
6
6
 
7
+ ## 0.6.6(2026-10-03)
8
+
9
+ - **本地检索提示**:`grep` 与 `glob` 都直接可用时,系统提示的规则节多一句「先 grep / glob 定位再 read,不要猜路径」;`grep` /
10
+ `glob` 的描述写明何时用;`grep` 新增 `filesOnly: true`,只列命中文件(去重、按路径排序,`limit` 按文件数计)。`read` 收到目录时
11
+ 指向模型当前真能调用的工具:有 `ls` 说用 ls,否则有 `glob` 说用 glob(例如 `pattern "src/*"`),都没有则说读目录里的文件。`-p`
12
+ 下 `minimal` / `coordinator` 预设里 bash 的 `grep` / `rg` / `find` 被拒时,stderr 补一行怎么加回
13
+ (`tools.default: ["+grep","+glob"]`)。工具经可选、只读的 `ToolContext.activeTools` 拿到会话活动集。`default` 前缀因此
14
+ 变化(约 +35 token),升级后首个请求缓存未命中一次。文档:docs/design.md §5.6、docs/codemode.md。
15
+
16
+ ## 0.6.5(2026-10-03)
17
+
18
+ - **`AcpClient` 开会话可传 MCP 服务器**:`newSession`、`resumeSession`、`loadSession` 新增可选的第三个参数
19
+ `{ mcpServers }`,原样随 `session/new|resume|load` 发出(缺省仍是 `[]`,不传时线路不变);宿主用
20
+ `AcpClient.features.mcpServers` 检测是否支持。ama 自己仍不传。文档:docs/acp.md「作为客户端」。
21
+
7
22
  ## 0.6.4(2026-10-03)
8
23
 
9
24
  - **打断并立即发送**:运行中按 Enter 仍是排队插话(等下一个投递点送达),新增 `Ctrl+X`(键位动作 `app.message.interrupt`)立即中止
@@ -5,8 +5,10 @@
5
5
  * 保持一句、英文、只写模型确实会做错的事;预算见 `src/cli/prompt-budget.test.ts`。
6
6
  * 「edit 的多处修改」写在 edit 工具描述里(只在 edit 可用时出现);并行批量在 codemode 独占时
7
7
  * 由它自己的说明负责,所以只在 read 直接可用时给。
8
+ * 「先定位再读」只在 grep 与 glob 都直接可用时给(docs/search-plan.md §4.2:`minimal` 下模型会连猜文件名)。
8
9
  */
9
10
  import type { ToolDefinition } from "../tools/types.js";
10
11
  export declare const DESTRUCTIVE_RULE = "Ask before destructive commands (rm -rf, git reset --hard, force push, deleting branches) unless the user requested them.";
11
12
  export declare const PARALLEL_READS_RULE = "Batch independent read-only tool calls into one turn.";
13
+ export declare const LOCATE_RULE = "Locate code with grep/glob before reading; do not guess file paths.";
12
14
  export declare function baseRules(tools: readonly Pick<ToolDefinition, "name">[]): string[];
@@ -5,14 +5,19 @@
5
5
  * 保持一句、英文、只写模型确实会做错的事;预算见 `src/cli/prompt-budget.test.ts`。
6
6
  * 「edit 的多处修改」写在 edit 工具描述里(只在 edit 可用时出现);并行批量在 codemode 独占时
7
7
  * 由它自己的说明负责,所以只在 read 直接可用时给。
8
+ * 「先定位再读」只在 grep 与 glob 都直接可用时给(docs/search-plan.md §4.2:`minimal` 下模型会连猜文件名)。
8
9
  */
9
10
  export const DESTRUCTIVE_RULE = "Ask before destructive commands (rm -rf, git reset --hard, force push, deleting branches) unless the user requested them.";
10
11
  export const PARALLEL_READS_RULE = "Batch independent read-only tool calls into one turn.";
12
+ export const LOCATE_RULE = "Locate code with grep/glob before reading; do not guess file paths.";
11
13
  export function baseRules(tools) {
12
14
  if (tools.length === 0)
13
15
  return [];
14
16
  const rules = [DESTRUCTIVE_RULE];
15
- if (tools.some((tool) => tool.name === "read"))
17
+ const has = (name) => tools.some((tool) => tool.name === name);
18
+ if (has("read"))
16
19
  rules.push(PARALLEL_READS_RULE);
20
+ if (has("grep") && has("glob"))
21
+ rules.push(LOCATE_RULE);
17
22
  return rules;
18
23
  }
@@ -266,6 +266,7 @@ export function createToolContext(core, runner, call, signal, onUpdate) {
266
266
  markRead: (path) => {
267
267
  core.readFiles.add(path);
268
268
  },
269
+ activeTools: new Set(core.activeToolNames()),
269
270
  tools: {
270
271
  executeTool: (name, input, options) => {
271
272
  nested++;
@@ -41,7 +41,7 @@ var AMA_VERSION;
41
41
  var init_version = __esm({
42
42
  "src/version.ts"() {
43
43
  "use strict";
44
- AMA_VERSION = true ? "0.6.4" : readPackageVersion();
44
+ AMA_VERSION = true ? "0.6.6" : readPackageVersion();
45
45
  }
46
46
  });
47
47
 
@@ -3475,7 +3475,8 @@ var init_print = __esm({
3475
3475
  planPending: (version, where) => `ama: plan v${version} saved, awaiting approval (not executed): ${where}; -p does not approve on anyone's behalf — approve it in the interactive UI or via RPC plan_response, or set plan.unattended: approve to let -p approve and continue`,
3476
3476
  limitTurns: (limit) => `ama: reached the turn limit ${limit} (--max-turns / limits.maxTurns); run ended before finishing`,
3477
3477
  limitCost: (limit, spent) => `ama: reached the cost limit ${limit} (spent ${spent}; --max-cost / limits.maxCostUsd); run ended before finishing`,
3478
- denied: (count2, tools, reason) => `ama: ${plural(count2, "tool call")} denied: ${tools.join(", ")}${reason !== "" ? ` (${reason})` : ""}; -p has no one to approve; to allow them use --permission-mode auto-edit|auto or --allow <rule>`
3478
+ denied: (count2, tools, reason) => `ama: ${plural(count2, "tool call")} denied: ${tools.join(", ")}${reason !== "" ? ` (${reason})` : ""}; -p has no one to approve; to allow them use --permission-mode auto-edit|auto or --allow <rule>`,
3479
+ searchToolsMissing: (preset, missing) => `ama: the ${preset} preset has no ${missing.join(" / ")}; add ${missing.length === 1 ? "it" : "them"} with tools.default: [${missing.map((name) => `"+${name}"`).join(",")}]`
3479
3480
  },
3480
3481
  /** RPC 响应的 `error`(人读)。 */
3481
3482
  rpc: {
@@ -3520,7 +3521,8 @@ var init_print = __esm({
3520
3521
  planPending: (version, where) => `ama: 计划 v${version} 已落盘、待审批(未执行):${where};-p 不替人批准——在交互界面或 RPC plan_response 里审批,或设 plan.unattended: approve 让 -p 批准后接着执行`,
3521
3522
  limitTurns: (limit) => `ama: 已达到回合上限 ${limit}(--max-turns / limits.maxTurns),运行在完成前结束`,
3522
3523
  limitCost: (limit, spent) => `ama: 已达到费用上限 ${limit}(累计 ${spent};--max-cost / limits.maxCostUsd),运行在完成前结束`,
3523
- denied: (count2, tools, reason) => `ama: ${count2} 次工具调用被拒:${tools.join("、")}${reason !== "" ? `(${reason})` : ""};-p 没有人审批,需要放行时用 --permission-mode auto-edit|auto 或 --allow <规则>`
3524
+ denied: (count2, tools, reason) => `ama: ${count2} 次工具调用被拒:${tools.join("、")}${reason !== "" ? `(${reason})` : ""};-p 没有人审批,需要放行时用 --permission-mode auto-edit|auto 或 --allow <规则>`,
3525
+ searchToolsMissing: (preset, missing) => `ama: ${preset} 预设没有 ${missing.join(" / ")},可用 tools.default: [${missing.map((name) => `"+${name}"`).join(",")}] 加上`
3524
3526
  },
3525
3527
  rpc: {
3526
3528
  badTaskId: "taskId 应为字符串",
@@ -8090,7 +8092,7 @@ function createGlobTool() {
8090
8092
  return {
8091
8093
  name: "glob",
8092
8094
  label: "Glob",
8093
- description: "Find files by glob (`**`, `*`, `?`, `[...]`, `{a,b}`, leading `!` excludes). Patterns without `/` match names at any depth. Respects .gitignore/.ignore; newest first.",
8095
+ description: "Find files by glob (`**`, `*`, `?`, `[...]`, `{a,b}`, leading `!` excludes). Patterns without `/` match names at any depth. Respects .gitignore/.ignore; newest first. Use to discover files before read.",
8094
8096
  parameters: {
8095
8097
  type: "object",
8096
8098
  properties: {
@@ -14456,6 +14458,18 @@ function isBinary(buf) {
14456
14458
  function error(message) {
14457
14459
  return { content: message, isError: true };
14458
14460
  }
14461
+ function directoryHint(shown2, ctx) {
14462
+ const active = ctx.activeTools;
14463
+ if (active === void 0 || active.has("ls")) return "use the ls tool instead";
14464
+ if (active.has("glob")) {
14465
+ if (shown2 === ".") return 'use glob (e.g. pattern "*")';
14466
+ if (shown2.startsWith("/") || /^[A-Za-z]:/.test(shown2) || /[*?[\]{}!\\]/.test(shown2)) {
14467
+ return `use glob (e.g. path ${JSON.stringify(shown2)}, pattern "*")`;
14468
+ }
14469
+ return `use glob (e.g. pattern ${JSON.stringify(`${shown2}/*`)})`;
14470
+ }
14471
+ return "read a file inside it";
14472
+ }
14459
14473
  function numberLines(lines2, firstLine3) {
14460
14474
  return lines2.map((line2, i) => `${String(firstLine3 + i).padStart(6)} ${line2}`).join("\n");
14461
14475
  }
@@ -14495,7 +14509,7 @@ async function executeRead(input2, ctx, options = {}) {
14495
14509
  } catch {
14496
14510
  return error(`File not found: ${shown2}`);
14497
14511
  }
14498
- if (info2.isDirectory()) return error(`${shown2} is a directory; use the ls tool instead`);
14512
+ if (info2.isDirectory()) return error(`${shown2} is a directory; ${directoryHint(shown2, ctx)}`);
14499
14513
  if (imageMimeFromPath(abs) !== void 0) return readImage(abs, shown2, ctx, options);
14500
14514
  const buf = await (0, import_promises5.readFile)(abs);
14501
14515
  if (isBinary(buf)) return error(`${shown2} appears to be a binary file; refusing to read it`);
@@ -15861,7 +15875,7 @@ function compilePattern(input2) {
15861
15875
  const source = input2.literal ? escapeRegExp(input2.pattern) : input2.pattern;
15862
15876
  return new RegExp(source, input2.ignoreCase ? "i" : "");
15863
15877
  }
15864
- async function searchFile(abs, regex) {
15878
+ async function searchFile(abs, regex, firstOnly = false) {
15865
15879
  try {
15866
15880
  const info2 = await (0, import_promises9.stat)(abs);
15867
15881
  if (!info2.isFile() || info2.size > GREP_MAX_FILE_BYTES) return void 0;
@@ -15870,9 +15884,11 @@ async function searchFile(abs, regex) {
15870
15884
  const lines2 = buf.toString("utf8").replace(/\r\n/g, "\n").split("\n");
15871
15885
  if (lines2[lines2.length - 1] === "") lines2.pop();
15872
15886
  const hits = [];
15873
- lines2.forEach((line2, i) => {
15874
- if (regex.test(line2)) hits.push(i);
15875
- });
15887
+ for (let i = 0; i < lines2.length; i++) {
15888
+ if (!regex.test(lines2[i])) continue;
15889
+ hits.push(i);
15890
+ if (firstOnly) break;
15891
+ }
15876
15892
  return hits.length > 0 ? { lines: lines2, hits } : void 0;
15877
15893
  } catch {
15878
15894
  return void 0;
@@ -15929,6 +15945,7 @@ async function executeGrep(input2, ctx) {
15929
15945
  return { content: `Path not found: ${displayPath(root, ctx.cwd)}`, isError: true };
15930
15946
  }
15931
15947
  const files = await candidateFiles(root, rootIsFile, input2.glob, ctx.signal);
15948
+ if (input2.filesOnly === true) return grepFilesOnly(files, regex, limit, ctx);
15932
15949
  const blocks = [];
15933
15950
  let matches2 = 0;
15934
15951
  let matchedFiles = 0;
@@ -15974,21 +15991,49 @@ async function executeGrep(input2, ctx) {
15974
15991
  details: { matches: matches2, files: matchedFiles, limited, truncated: cut.truncated }
15975
15992
  };
15976
15993
  }
15994
+ async function grepFilesOnly(files, regex, limit, ctx) {
15995
+ const sorted = [...new Set(files)].sort();
15996
+ const shown2 = [];
15997
+ let limited = false;
15998
+ outer: for (let i = 0; i < sorted.length; i += GREP_CONCURRENCY) {
15999
+ if (ctx.signal.aborted) return { content: "aborted by user", isError: true };
16000
+ const batch = sorted.slice(i, i + GREP_CONCURRENCY);
16001
+ const results = await Promise.all(batch.map((f) => searchFile(f, regex, true)));
16002
+ for (let j = 0; j < batch.length; j++) {
16003
+ if (!results[j]) continue;
16004
+ if (shown2.length >= limit) {
16005
+ limited = true;
16006
+ break outer;
16007
+ }
16008
+ shown2.push(displayPath(batch[j], ctx.cwd));
16009
+ }
16010
+ }
16011
+ if (shown2.length === 0) {
16012
+ return { content: "No matches found", details: { matches: 0, files: 0, filesOnly: true } };
16013
+ }
16014
+ const unique3 = [...new Set(shown2)].sort();
16015
+ let content = unique3.join("\n");
16016
+ if (limited) content += `
16017
+
16018
+ [Stopped at ${limit} files. Narrow the search or raise limit.]`;
16019
+ return { content, details: { files: unique3.length, filesOnly: true, limited } };
16020
+ }
15977
16021
  function createGrepTool() {
15978
16022
  return {
15979
16023
  name: "grep",
15980
16024
  label: "Grep",
15981
- description: "Search file contents by JS regex. Respects .gitignore/.ignore; skips binary and >2 MB files. Output: `path:line: text`.",
16025
+ description: "Use to find where a symbol or text appears (JS regex). Skips ignored, binary, >2 MB files. Output `path:line: text`; filesOnly lists matching files.",
15982
16026
  parameters: {
15983
16027
  type: "object",
15984
16028
  properties: {
15985
16029
  pattern: { type: "string" },
15986
- path: { type: "string", description: "File or dir (default: cwd)" },
15987
- glob: { type: "string", description: "File filter, e.g. *.ts" },
16030
+ path: { type: "string", description: "File or dir" },
16031
+ glob: { type: "string", description: "e.g. *.ts" },
15988
16032
  ignoreCase: { type: "boolean" },
15989
16033
  literal: { type: "boolean" },
15990
16034
  context: { type: "integer", description: "0-5 lines" },
15991
- limit: { type: "integer", description: `Default ${DEFAULT_GREP_LIMIT}` }
16035
+ limit: { type: "integer", description: `Default ${DEFAULT_GREP_LIMIT}` },
16036
+ filesOnly: { type: "boolean" }
15992
16037
  },
15993
16038
  required: ["pattern"],
15994
16039
  additionalProperties: false
@@ -24309,15 +24354,18 @@ var init_session_subagent = __esm({
24309
24354
  function baseRules(tools) {
24310
24355
  if (tools.length === 0) return [];
24311
24356
  const rules = [DESTRUCTIVE_RULE];
24312
- if (tools.some((tool) => tool.name === "read")) rules.push(PARALLEL_READS_RULE);
24357
+ const has = (name) => tools.some((tool) => tool.name === name);
24358
+ if (has("read")) rules.push(PARALLEL_READS_RULE);
24359
+ if (has("grep") && has("glob")) rules.push(LOCATE_RULE);
24313
24360
  return rules;
24314
24361
  }
24315
- var DESTRUCTIVE_RULE, PARALLEL_READS_RULE;
24362
+ var DESTRUCTIVE_RULE, PARALLEL_READS_RULE, LOCATE_RULE;
24316
24363
  var init_prompt_rules = __esm({
24317
24364
  "src/agent/prompt-rules.ts"() {
24318
24365
  "use strict";
24319
24366
  DESTRUCTIVE_RULE = "Ask before destructive commands (rm -rf, git reset --hard, force push, deleting branches) unless the user requested them.";
24320
24367
  PARALLEL_READS_RULE = "Batch independent read-only tool calls into one turn.";
24368
+ LOCATE_RULE = "Locate code with grep/glob before reading; do not guess file paths.";
24321
24369
  }
24322
24370
  });
24323
24371
 
@@ -25493,6 +25541,7 @@ function createToolContext(core, runner, call, signal, onUpdate) {
25493
25541
  markRead: (path) => {
25494
25542
  core.readFiles.add(path);
25495
25543
  },
25544
+ activeTools: new Set(core.activeToolNames()),
25496
25545
  tools: {
25497
25546
  executeTool: (name, input2, options) => {
25498
25547
  nested++;
@@ -30738,6 +30787,9 @@ var init_jsonrpc = __esm({
30738
30787
  });
30739
30788
 
30740
30789
  // src/drivers/acp/client.ts
30790
+ function mcpServersOf(options) {
30791
+ return [...options?.mcpServers ?? []];
30792
+ }
30741
30793
  function unattendedOutcome(params) {
30742
30794
  const reject2 = params.options.find((o) => o.kind === "reject_once");
30743
30795
  return {
@@ -30769,6 +30821,11 @@ var init_client = __esm({
30769
30821
  });
30770
30822
  }
30771
30823
  options;
30824
+ /**
30825
+ * 本版客户端支持的可选能力,供宿主做特性检测(旧版没有这个字段):
30826
+ * - `mcpServers`:开会话时可经 {@link AcpSessionOptions} 传 MCP 服务器。
30827
+ */
30828
+ static features = { mcpServers: true };
30772
30829
  peer;
30773
30830
  /** sessionId → 挂起权限请求的取消器。 */
30774
30831
  pendingPermissions = /* @__PURE__ */ new Map();
@@ -30818,16 +30875,28 @@ var init_client = __esm({
30818
30875
  supportsImages() {
30819
30876
  return this.agentCapabilities.promptCapabilities?.image === true;
30820
30877
  }
30821
- newSession(cwd, signal) {
30822
- return this.peer.request(ACP_METHODS.sessionNew, { cwd, mcpServers: [] }, signal);
30878
+ newSession(cwd, signal, options) {
30879
+ return this.peer.request(
30880
+ ACP_METHODS.sessionNew,
30881
+ { cwd, mcpServers: mcpServersOf(options) },
30882
+ signal
30883
+ );
30823
30884
  }
30824
30885
  /** 不回放历史(优先,R13:v2 计划取消 load)。 */
30825
- resumeSession(sessionId, cwd, signal) {
30826
- return this.peer.request(ACP_METHODS.sessionResume, { sessionId, cwd, mcpServers: [] }, signal);
30886
+ resumeSession(sessionId, cwd, signal, options) {
30887
+ return this.peer.request(
30888
+ ACP_METHODS.sessionResume,
30889
+ { sessionId, cwd, mcpServers: mcpServersOf(options) },
30890
+ signal
30891
+ );
30827
30892
  }
30828
30893
  /** 回放历史(以 session/update 通知)。 */
30829
- loadSession(sessionId, cwd, signal) {
30830
- return this.peer.request(ACP_METHODS.sessionLoad, { sessionId, cwd, mcpServers: [] }, signal);
30894
+ loadSession(sessionId, cwd, signal, options) {
30895
+ return this.peer.request(
30896
+ ACP_METHODS.sessionLoad,
30897
+ { sessionId, cwd, mcpServers: mcpServersOf(options) },
30898
+ signal
30899
+ );
30831
30900
  }
30832
30901
  listSessions(cwd, cursor) {
30833
30902
  return this.peer.request(ACP_METHODS.sessionList, {
@@ -45619,6 +45688,7 @@ function resultSummary(input2, theme) {
45619
45688
  case "grep": {
45620
45689
  const matches2 = num9(details["matches"]);
45621
45690
  if (matches2 === 0) return muted(m.noMatch);
45691
+ if (details["filesOnly"] === true) return muted(m.files(num9(details["files"]) ?? count2));
45622
45692
  if (matches2 === void 0) return muted(m.lines(count2));
45623
45693
  return muted(m.matches(matches2, num9(details["files"])));
45624
45694
  }
@@ -47890,6 +47960,27 @@ var init_json_event = __esm({
47890
47960
  }
47891
47961
  });
47892
47962
 
47963
+ // src/modes/print/search-hint.ts
47964
+ function isSearchCommand(command3) {
47965
+ return SEARCH_COMMAND.test(command3);
47966
+ }
47967
+ function searchToolsHint(deniedBashCommands, preset, activeTools) {
47968
+ if (!HINT_PRESETS.has(preset)) return void 0;
47969
+ const missing = ["grep", "glob"].filter((name) => !activeTools.includes(name));
47970
+ if (missing.length === 0) return void 0;
47971
+ if (!deniedBashCommands.some(isSearchCommand)) return void 0;
47972
+ return msg().print.print.searchToolsMissing(preset, missing);
47973
+ }
47974
+ var SEARCH_COMMAND, HINT_PRESETS;
47975
+ var init_search_hint = __esm({
47976
+ "src/modes/print/search-hint.ts"() {
47977
+ "use strict";
47978
+ init_i18n();
47979
+ SEARCH_COMMAND = /(?:^|[;&|(`]|\$\()\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(?:sudo\s+)?(?:\S*\/)?(?:grep|egrep|fgrep|rg|ag|ack|find|fd)(?=\s|$)/;
47980
+ HINT_PRESETS = /* @__PURE__ */ new Set(["minimal", "coordinator"]);
47981
+ }
47982
+ });
47983
+
47893
47984
  // src/modes/print/print-mode.ts
47894
47985
  var print_mode_exports = {};
47895
47986
  __export(print_mode_exports, {
@@ -47982,6 +48073,8 @@ async function runPrintMode(runtime, context) {
47982
48073
  return ExitCode.Usage;
47983
48074
  }
47984
48075
  const denied = [];
48076
+ const bashCommands = /* @__PURE__ */ new Map();
48077
+ const deniedBash = [];
47985
48078
  let limit;
47986
48079
  let plan2;
47987
48080
  let stopWaiting = () => void 0;
@@ -47994,12 +48087,19 @@ async function runPrintMode(runtime, context) {
47994
48087
  stopWaiting();
47995
48088
  } else if (event.type === "plan_proposed") plan2 = event;
47996
48089
  else if (event.type === "plan_resolved" && event.planId === plan2?.planId) plan2 = void 0;
47997
- if (event.type === "tool_execution_end" && event.denied === true)
48090
+ if (event.type === "tool_execution_start" && event.toolName === "bash") {
48091
+ const command3 = event.args?.command;
48092
+ if (typeof command3 === "string") bashCommands.set(event.toolCallId, command3);
48093
+ }
48094
+ if (event.type === "tool_execution_end" && event.denied === true) {
47998
48095
  denied.push({
47999
48096
  toolCallId: event.toolCallId,
48000
48097
  toolName: event.toolName,
48001
48098
  reason: textOf5(event)
48002
48099
  });
48100
+ const command3 = bashCommands.get(event.toolCallId);
48101
+ if (event.toolName === "bash" && command3 !== void 0) deniedBash.push(command3);
48102
+ }
48003
48103
  if (format === "stream-json") io.stdout(`${toJsonLine(toWireEvent(event))}
48004
48104
  `);
48005
48105
  else if (event.type === "auto_retry_start")
@@ -48078,6 +48178,16 @@ async function runPrintMode(runtime, context) {
48078
48178
  `
48079
48179
  );
48080
48180
  }
48181
+ const deniedText = () => {
48182
+ const hint = searchToolsHint(
48183
+ deniedBash,
48184
+ runtime.config.tools?.preset ?? "default",
48185
+ runtime.tools.active().map((tool) => tool.name)
48186
+ );
48187
+ return `${describeDenied(denied)}
48188
+ ${hint === void 0 ? "" : `${hint}
48189
+ `}`;
48190
+ };
48081
48191
  if (signalled !== void 0) return signalled;
48082
48192
  if (stdoutClosed) return ExitCode.Ok;
48083
48193
  if (failure2 !== void 0) {
@@ -48086,8 +48196,7 @@ async function runPrintMode(runtime, context) {
48086
48196
  return ExitCode.RuntimeError;
48087
48197
  }
48088
48198
  if (limit !== void 0) {
48089
- if (denied.length > 0) io.stderr(`${describeDenied(denied)}
48090
- `);
48199
+ if (denied.length > 0) io.stderr(deniedText());
48091
48200
  io.stderr(`${describeLimit(limit)}
48092
48201
  `);
48093
48202
  return ExitCode.LimitReached;
@@ -48097,8 +48206,7 @@ async function runPrintMode(runtime, context) {
48097
48206
  `);
48098
48207
  return ExitCode.RuntimeError;
48099
48208
  }
48100
- if (denied.length > 0) io.stderr(`${describeDenied(denied)}
48101
- `);
48209
+ if (denied.length > 0) io.stderr(deniedText());
48102
48210
  if (plan2 !== void 0) {
48103
48211
  io.stderr(`${describePlanPending(plan2)}
48104
48212
  `);
@@ -48145,6 +48253,7 @@ var init_print_mode = __esm({
48145
48253
  init_subagent_registry();
48146
48254
  init_image_input();
48147
48255
  init_i18n();
48256
+ init_search_hint();
48148
48257
  STDIN_WAIT_HINT_MS = 3e3;
48149
48258
  STDIN_FIRST_BYTE_MS = 2e3;
48150
48259
  }
@@ -8,8 +8,10 @@
8
8
  * 没有处理器时按无人值守回首个 `reject_once`(没有则 cancelled)。
9
9
  * - `cancel(sessionId)` 发 `session/cancel` 通知,并让该会话所有挂起的权限请求回 `cancelled`
10
10
  * (规范要求:客户端取消回合后必须以 cancelled 回答挂起的请求)。
11
+ * - 开会话(new / resume / load)的 `mcpServers` 缺省为空数组;宿主可经 {@link AcpSessionOptions}
12
+ * 传入,原样转发。
11
13
  */
12
- import { type AcpAgentCapabilities, type AcpContentBlock, type AcpImplementationInfo, type AcpInitializeResult, type AcpListSessionsResult, type AcpLoadSessionResult, type AcpNewSessionResult, type AcpPromptResult, type AcpRequestPermissionParams, type AcpRequestPermissionResult, type AcpSessionNotification } from "./types.js";
14
+ import { type AcpAgentCapabilities, type AcpContentBlock, type AcpImplementationInfo, type AcpInitializeResult, type AcpListSessionsResult, type AcpLoadSessionResult, type AcpNewSessionResult, type AcpPromptResult, type AcpRequestPermissionParams, type AcpRequestPermissionResult, type AcpSessionNotification, type AcpSessionOptions } from "./types.js";
13
15
  export interface AcpClientHandlers {
14
16
  onUpdate?(notification: AcpSessionNotification): void;
15
17
  /** `signal`:该会话被 cancel、连接关闭时 abort(此时应尽快回 cancelled)。 */
@@ -26,6 +28,13 @@ export interface AcpClientOptions extends AcpClientHandlers {
26
28
  export declare function unattendedOutcome(params: AcpRequestPermissionParams): AcpRequestPermissionResult;
27
29
  export declare class AcpClient {
28
30
  private readonly options;
31
+ /**
32
+ * 本版客户端支持的可选能力,供宿主做特性检测(旧版没有这个字段):
33
+ * - `mcpServers`:开会话时可经 {@link AcpSessionOptions} 传 MCP 服务器。
34
+ */
35
+ static readonly features: {
36
+ readonly mcpServers: true;
37
+ };
29
38
  private readonly peer;
30
39
  /** sessionId → 挂起权限请求的取消器。 */
31
40
  private readonly pendingPermissions;
@@ -41,11 +50,11 @@ export declare class AcpClient {
41
50
  supportsList(): boolean;
42
51
  supportsClose(): boolean;
43
52
  supportsImages(): boolean;
44
- newSession(cwd: string, signal?: AbortSignal): Promise<AcpNewSessionResult>;
53
+ newSession(cwd: string, signal?: AbortSignal, options?: AcpSessionOptions): Promise<AcpNewSessionResult>;
45
54
  /** 不回放历史(优先,R13:v2 计划取消 load)。 */
46
- resumeSession(sessionId: string, cwd: string, signal?: AbortSignal): Promise<AcpLoadSessionResult>;
55
+ resumeSession(sessionId: string, cwd: string, signal?: AbortSignal, options?: AcpSessionOptions): Promise<AcpLoadSessionResult>;
47
56
  /** 回放历史(以 session/update 通知)。 */
48
- loadSession(sessionId: string, cwd: string, signal?: AbortSignal): Promise<AcpLoadSessionResult>;
57
+ loadSession(sessionId: string, cwd: string, signal?: AbortSignal, options?: AcpSessionOptions): Promise<AcpLoadSessionResult>;
49
58
  listSessions(cwd?: string, cursor?: string): Promise<AcpListSessionsResult>;
50
59
  closeSession(sessionId: string): Promise<unknown>;
51
60
  prompt(sessionId: string, prompt: AcpContentBlock[], signal?: AbortSignal): Promise<AcpPromptResult>;
@@ -8,9 +8,15 @@
8
8
  * 没有处理器时按无人值守回首个 `reject_once`(没有则 cancelled)。
9
9
  * - `cancel(sessionId)` 发 `session/cancel` 通知,并让该会话所有挂起的权限请求回 `cancelled`
10
10
  * (规范要求:客户端取消回合后必须以 cancelled 回答挂起的请求)。
11
+ * - 开会话(new / resume / load)的 `mcpServers` 缺省为空数组;宿主可经 {@link AcpSessionOptions}
12
+ * 传入,原样转发。
11
13
  */
12
14
  import { JsonRpcPeer, RpcError } from "../jsonrpc.js";
13
15
  import { ACP_METHODS, ACP_PROTOCOL_VERSION, RPC_ERRORS, } from "./types.js";
16
+ /** 开会话参数里的 `mcpServers`:拷一份,缺省空数组。 */
17
+ function mcpServersOf(options) {
18
+ return [...(options?.mcpServers ?? [])];
19
+ }
14
20
  /** 无人值守的回答:首个 reject_once,没有就 cancelled。 */
15
21
  export function unattendedOutcome(params) {
16
22
  const reject = params.options.find((o) => o.kind === "reject_once");
@@ -22,6 +28,11 @@ export function unattendedOutcome(params) {
22
28
  }
23
29
  export class AcpClient {
24
30
  options;
31
+ /**
32
+ * 本版客户端支持的可选能力,供宿主做特性检测(旧版没有这个字段):
33
+ * - `mcpServers`:开会话时可经 {@link AcpSessionOptions} 传 MCP 服务器。
34
+ */
35
+ static features = { mcpServers: true };
25
36
  peer;
26
37
  /** sessionId → 挂起权限请求的取消器。 */
27
38
  pendingPermissions = new Map();
@@ -85,16 +96,16 @@ export class AcpClient {
85
96
  supportsImages() {
86
97
  return this.agentCapabilities.promptCapabilities?.image === true;
87
98
  }
88
- newSession(cwd, signal) {
89
- return this.peer.request(ACP_METHODS.sessionNew, { cwd, mcpServers: [] }, signal);
99
+ newSession(cwd, signal, options) {
100
+ return this.peer.request(ACP_METHODS.sessionNew, { cwd, mcpServers: mcpServersOf(options) }, signal);
90
101
  }
91
102
  /** 不回放历史(优先,R13:v2 计划取消 load)。 */
92
- resumeSession(sessionId, cwd, signal) {
93
- return this.peer.request(ACP_METHODS.sessionResume, { sessionId, cwd, mcpServers: [] }, signal);
103
+ resumeSession(sessionId, cwd, signal, options) {
104
+ return this.peer.request(ACP_METHODS.sessionResume, { sessionId, cwd, mcpServers: mcpServersOf(options) }, signal);
94
105
  }
95
106
  /** 回放历史(以 session/update 通知)。 */
96
- loadSession(sessionId, cwd, signal) {
97
- return this.peer.request(ACP_METHODS.sessionLoad, { sessionId, cwd, mcpServers: [] }, signal);
107
+ loadSession(sessionId, cwd, signal, options) {
108
+ return this.peer.request(ACP_METHODS.sessionLoad, { sessionId, cwd, mcpServers: mcpServersOf(options) }, signal);
98
109
  }
99
110
  listSessions(cwd, cursor) {
100
111
  return this.peer.request(ACP_METHODS.sessionList, {
@@ -111,8 +111,28 @@ export interface AcpInitializeResult {
111
111
  authMethods?: AcpAuthMethod[];
112
112
  agentInfo?: AcpImplementationInfo;
113
113
  }
114
- /** ama 不向外部 Agent 传 MCP 服务器(Skills 不做 MCP),总是空数组。 */
115
- export type AcpMcpServer = Record<string, unknown>;
114
+ /** `session/new.mcpServers[].env` 的一项。 */
115
+ export type AcpEnvVariable = {
116
+ name: string;
117
+ value: string;
118
+ };
119
+ /** stdio 传输的 MCP 服务器:ACP v1 要求每个 Agent 都支持这一种。 */
120
+ export type AcpStdioMcpServer = {
121
+ name: string;
122
+ command: string;
123
+ args: string[];
124
+ env: AcpEnvVariable[];
125
+ };
126
+ /**
127
+ * 交给外部 Agent 的 MCP 服务器。ama 自己不加(Skills 不做 MCP),缺省是空数组;宿主(如 Armadra)
128
+ * 经 {@link AcpSessionOptions} 传入的原样转发,http / sse 变体不在这里解释。
129
+ */
130
+ export type AcpMcpServer = AcpStdioMcpServer | Record<string, unknown>;
131
+ /** `AcpClient.newSession` / `resumeSession` / `loadSession` 的可选项。 */
132
+ export interface AcpSessionOptions {
133
+ /** 随 `session/new|resume|load` 发给 Agent;缺省空数组。 */
134
+ mcpServers?: readonly AcpMcpServer[];
135
+ }
116
136
  export interface AcpSessionMode {
117
137
  id: string;
118
138
  name: string;
@@ -1268,6 +1268,7 @@ declare const en: {
1268
1268
  limitTurns: (limit: number) => string;
1269
1269
  limitCost: (limit: string, spent: string) => string;
1270
1270
  denied: (count: number, tools: readonly string[], reason: string) => string;
1271
+ searchToolsMissing: (preset: string, missing: readonly string[]) => string;
1271
1272
  };
1272
1273
  rpc: {
1273
1274
  unknownCommand: (type: string) => string;
@@ -20,6 +20,7 @@ export declare const en: {
20
20
  limitTurns: (limit: number) => string;
21
21
  limitCost: (limit: string, spent: string) => string;
22
22
  denied: (count: number, tools: readonly string[], reason: string) => string;
23
+ searchToolsMissing: (preset: string, missing: readonly string[]) => string;
23
24
  };
24
25
  /** RPC 响应的 `error`(人读)。 */
25
26
  rpc: {
@@ -62,6 +63,7 @@ export declare const zh: {
62
63
  limitTurns: (limit: number) => string;
63
64
  limitCost: (limit: string, spent: string) => string;
64
65
  denied: (count: number, tools: readonly string[], reason: string) => string;
66
+ searchToolsMissing: (preset: string, missing: readonly string[]) => string;
65
67
  };
66
68
  rpc: {
67
69
  badTaskId: string;
@@ -23,6 +23,8 @@ export const en = {
23
23
  limitCost: (limit, spent) => `ama: reached the cost limit ${limit} (spent ${spent}; --max-cost / limits.maxCostUsd); run ended before finishing`,
24
24
  denied: (count, tools, reason) => `ama: ${plural(count, "tool call")} denied: ${tools.join(", ")}${reason !== "" ? ` (${reason})` : ""}; ` +
25
25
  "-p has no one to approve; to allow them use --permission-mode auto-edit|auto or --allow <rule>",
26
+ searchToolsMissing: (preset, missing) => `ama: the ${preset} preset has no ${missing.join(" / ")}; add ${missing.length === 1 ? "it" : "them"} with ` +
27
+ `tools.default: [${missing.map((name) => `"+${name}"`).join(",")}]`,
26
28
  },
27
29
  /** RPC 响应的 `error`(人读)。 */
28
30
  rpc: {
@@ -67,6 +69,8 @@ export const zh = {
67
69
  limitCost: (limit, spent) => `ama: 已达到费用上限 ${limit}(累计 ${spent};--max-cost / limits.maxCostUsd),运行在完成前结束`,
68
70
  denied: (count, tools, reason) => `ama: ${count} 次工具调用被拒:${tools.join("、")}${reason !== "" ? `(${reason})` : ""};` +
69
71
  "-p 没有人审批,需要放行时用 --permission-mode auto-edit|auto 或 --allow <规则>",
72
+ searchToolsMissing: (preset, missing) => `ama: ${preset} 预设没有 ${missing.join(" / ")},可用 ` +
73
+ `tools.default: [${missing.map((name) => `"+${name}"`).join(",")}] 加上`,
70
74
  },
71
75
  rpc: {
72
76
  badTaskId: "taskId 应为字符串",
@@ -191,6 +191,8 @@ export function resultSummary(input, theme) {
191
191
  const matches = num(details["matches"]);
192
192
  if (matches === 0)
193
193
  return muted(m.noMatch);
194
+ if (details["filesOnly"] === true)
195
+ return muted(m.files(num(details["files"]) ?? count));
194
196
  if (matches === undefined)
195
197
  return muted(m.lines(count));
196
198
  return muted(m.matches(matches, num(details["files"])));
@@ -14,7 +14,8 @@
14
14
  * `context_pressure`)。
15
15
  * - 重试:text / json 格式在 stderr 打一行 `↻ 重试 n/m`(stream-json 里本来就有事件),等待期间不再无声。
16
16
  * - 无人值守:ask → deny(bootstrap 已按 print 设 unattended)。被拒的调用(`tool_execution_end`
17
- * 带 `denied`)在 stderr 汇总一行(工具 ×次数、首个原因、放行办法),json 结果带 `deniedTools`。
17
+ * 带 `denied`)在 stderr 汇总一行(工具 ×次数、首个原因、放行办法),json 结果带 `deniedTools`;
18
+ * [S-A] minimal / coordinator 下被拒的 bash 形如 grep / rg / find 时再补一行怎么加回 grep / glob。
18
19
  * - [W5-H2] 预算(`--max-turns N` / `--max-cost USD` / config `limits.*`,agent/limits.ts):到限
19
20
  * (会话发 `limit_reached`)→ stderr 一行、json 带 `limitReached{kind, value, limit}`(回合到限另带
20
21
  * `maxTurnsReached: true`)、退出码 8。
@@ -14,7 +14,8 @@
14
14
  * `context_pressure`)。
15
15
  * - 重试:text / json 格式在 stderr 打一行 `↻ 重试 n/m`(stream-json 里本来就有事件),等待期间不再无声。
16
16
  * - 无人值守:ask → deny(bootstrap 已按 print 设 unattended)。被拒的调用(`tool_execution_end`
17
- * 带 `denied`)在 stderr 汇总一行(工具 ×次数、首个原因、放行办法),json 结果带 `deniedTools`。
17
+ * 带 `denied`)在 stderr 汇总一行(工具 ×次数、首个原因、放行办法),json 结果带 `deniedTools`;
18
+ * [S-A] minimal / coordinator 下被拒的 bash 形如 grep / rg / find 时再补一行怎么加回 grep / glob。
18
19
  * - [W5-H2] 预算(`--max-turns N` / `--max-cost USD` / config `limits.*`,agent/limits.ts):到限
19
20
  * (会话发 `limit_reached`)→ stderr 一行、json 带 `limitReached{kind, value, limit}`(回合到限另带
20
21
  * `maxTurnsReached: true`)、退出码 8。
@@ -36,6 +37,7 @@ import { formatUsd } from "../../agent/limits.js";
36
37
  import { registryOf } from "../../agent/subagent-registry.js";
37
38
  import { promptImages, sessionModel } from "../image-input.js";
38
39
  import { msg } from "../../i18n/index.js";
40
+ import { searchToolsHint } from "./search-hint.js";
39
41
  export function joinPrompt(argument, piped) {
40
42
  const parts = [argument ?? "", piped.replace(/\s+$/, "")].filter((p) => p.trim() !== "");
41
43
  return parts.join("\n\n");
@@ -121,6 +123,8 @@ export async function runPrintMode(runtime, context) {
121
123
  return ExitCode.Usage;
122
124
  }
123
125
  const denied = [];
126
+ const bashCommands = new Map();
127
+ const deniedBash = [];
124
128
  let limit;
125
129
  let plan;
126
130
  let stopWaiting = () => undefined;
@@ -136,12 +140,21 @@ export async function runPrintMode(runtime, context) {
136
140
  plan = event;
137
141
  else if (event.type === "plan_resolved" && event.planId === plan?.planId)
138
142
  plan = undefined;
139
- if (event.type === "tool_execution_end" && event.denied === true)
143
+ if (event.type === "tool_execution_start" && event.toolName === "bash") {
144
+ const command = event.args?.command;
145
+ if (typeof command === "string")
146
+ bashCommands.set(event.toolCallId, command);
147
+ }
148
+ if (event.type === "tool_execution_end" && event.denied === true) {
140
149
  denied.push({
141
150
  toolCallId: event.toolCallId,
142
151
  toolName: event.toolName,
143
152
  reason: textOf(event),
144
153
  });
154
+ const command = bashCommands.get(event.toolCallId);
155
+ if (event.toolName === "bash" && command !== undefined)
156
+ deniedBash.push(command);
157
+ }
145
158
  if (format === "stream-json")
146
159
  io.stdout(`${toJsonLine(toWireEvent(event))}\n`);
147
160
  else if (event.type === "auto_retry_start")
@@ -223,6 +236,10 @@ export async function runPrintMode(runtime, context) {
223
236
  entries: session.entries,
224
237
  })}\n`);
225
238
  }
239
+ const deniedText = () => {
240
+ const hint = searchToolsHint(deniedBash, runtime.config.tools?.preset ?? "default", runtime.tools.active().map((tool) => tool.name));
241
+ return `${describeDenied(denied)}\n${hint === undefined ? "" : `${hint}\n`}`;
242
+ };
226
243
  if (signalled !== undefined)
227
244
  return signalled;
228
245
  if (stdoutClosed)
@@ -233,7 +250,7 @@ export async function runPrintMode(runtime, context) {
233
250
  }
234
251
  if (limit !== undefined) {
235
252
  if (denied.length > 0)
236
- io.stderr(`${describeDenied(denied)}\n`);
253
+ io.stderr(deniedText());
237
254
  io.stderr(`${describeLimit(limit)}\n`);
238
255
  return ExitCode.LimitReached;
239
256
  }
@@ -242,7 +259,7 @@ export async function runPrintMode(runtime, context) {
242
259
  return ExitCode.RuntimeError;
243
260
  }
244
261
  if (denied.length > 0)
245
- io.stderr(`${describeDenied(denied)}\n`);
262
+ io.stderr(deniedText());
246
263
  if (plan !== undefined) {
247
264
  io.stderr(`${describePlanPending(plan)}\n`);
248
265
  return ExitCode.PlanPending;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * [S-A] `-p` 下 bash 搜索命令被拒时的一行提示(docs/search-plan.md §4.2)。
3
+ *
4
+ * `minimal` / `coordinator` 预设没有 grep / glob,模型常改用 `bash grep` / `rg` / `find`,在无人值守下被拒后
5
+ * 只能猜文件名(§1.3 E3)。这里只在「被拒的 bash 命令形如搜索」且活动集缺 grep 或 glob 时提示怎么加回来;
6
+ * 不改权限判定,也不进模型上下文。
7
+ */
8
+ export declare function isSearchCommand(command: string): boolean;
9
+ /** 被拒的 bash 命令里有搜索类命令、预设是 minimal / coordinator、活动集缺 grep 或 glob → 提示一行;否则 undefined。 */
10
+ export declare function searchToolsHint(deniedBashCommands: readonly string[], preset: string, activeTools: readonly string[]): string | undefined;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * [S-A] `-p` 下 bash 搜索命令被拒时的一行提示(docs/search-plan.md §4.2)。
3
+ *
4
+ * `minimal` / `coordinator` 预设没有 grep / glob,模型常改用 `bash grep` / `rg` / `find`,在无人值守下被拒后
5
+ * 只能猜文件名(§1.3 E3)。这里只在「被拒的 bash 命令形如搜索」且活动集缺 grep 或 glob 时提示怎么加回来;
6
+ * 不改权限判定,也不进模型上下文。
7
+ */
8
+ import { msg } from "../../i18n/index.js";
9
+ /** 命令位置(行首或 `;` `&` `|` `(` 之后,允许前置 `VAR=x` / `sudo`)上的搜索类程序。 */
10
+ const SEARCH_COMMAND = /(?:^|[;&|(`]|\$\()\s*(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(?:sudo\s+)?(?:\S*\/)?(?:grep|egrep|fgrep|rg|ag|ack|find|fd)(?=\s|$)/;
11
+ /** 只在这两个预设下提示:`default` 本来就有 grep / glob,`codemode-only` 的脚本里可调用它们。 */
12
+ const HINT_PRESETS = new Set(["minimal", "coordinator"]);
13
+ export function isSearchCommand(command) {
14
+ return SEARCH_COMMAND.test(command);
15
+ }
16
+ /** 被拒的 bash 命令里有搜索类命令、预设是 minimal / coordinator、活动集缺 grep 或 glob → 提示一行;否则 undefined。 */
17
+ export function searchToolsHint(deniedBashCommands, preset, activeTools) {
18
+ if (!HINT_PRESETS.has(preset))
19
+ return undefined;
20
+ const missing = ["grep", "glob"].filter((name) => !activeTools.includes(name));
21
+ if (missing.length === 0)
22
+ return undefined;
23
+ if (!deniedBashCommands.some(isSearchCommand))
24
+ return undefined;
25
+ return msg().print.print.searchToolsMissing(preset, missing);
26
+ }
@@ -197,7 +197,8 @@ export function createGlobTool() {
197
197
  name: "glob",
198
198
  label: "Glob",
199
199
  description: "Find files by glob (`**`, `*`, `?`, `[...]`, `{a,b}`, leading `!` excludes). " +
200
- "Patterns without `/` match names at any depth. Respects .gitignore/.ignore; newest first.",
200
+ "Patterns without `/` match names at any depth. Respects .gitignore/.ignore; newest first. " +
201
+ "Use to discover files before read.",
201
202
  parameters: {
202
203
  type: "object",
203
204
  properties: {
@@ -5,6 +5,8 @@
5
5
  * - 文件按路径排序处理,输出确定;匹配行 `path:line: text`,上下文行 `path-line- text`,
6
6
  * 不相邻的组之间 `--`;匹配行截 500 字符;
7
7
  * - 匹配数到 limit(缺省 100)即停并提示;整体输出再按 50 KB 头截断。
8
+ * - [S-A] `filesOnly`:只列命中文件(去重、按路径排序,每个文件命中一行即停),limit 按文件数计;
9
+ * 给模型在大仓库里先缩范围(docs/search-plan.md §4.2)。
8
10
  */
9
11
  import type { ToolContext, ToolDefinition, ToolResult } from "./types.js";
10
12
  export interface GrepInput {
@@ -15,6 +17,7 @@ export interface GrepInput {
15
17
  literal?: boolean;
16
18
  context?: number;
17
19
  limit?: number;
20
+ filesOnly?: boolean;
18
21
  }
19
22
  export declare const DEFAULT_GREP_LIMIT = 100;
20
23
  export declare const GREP_MAX_FILE_BYTES: number;
@@ -5,6 +5,8 @@
5
5
  * - 文件按路径排序处理,输出确定;匹配行 `path:line: text`,上下文行 `path-line- text`,
6
6
  * 不相邻的组之间 `--`;匹配行截 500 字符;
7
7
  * - 匹配数到 limit(缺省 100)即停并提示;整体输出再按 50 KB 头截断。
8
+ * - [S-A] `filesOnly`:只列命中文件(去重、按路径排序,每个文件命中一行即停),limit 按文件数计;
9
+ * 给模型在大仓库里先缩范围(docs/search-plan.md §4.2)。
8
10
  */
9
11
  import { readFile, stat } from "node:fs/promises";
10
12
  import { displayPath, resolvePath } from "./paths.js";
@@ -23,7 +25,7 @@ export function compilePattern(input) {
23
25
  const source = input.literal ? escapeRegExp(input.pattern) : input.pattern;
24
26
  return new RegExp(source, input.ignoreCase ? "i" : "");
25
27
  }
26
- async function searchFile(abs, regex) {
28
+ async function searchFile(abs, regex, firstOnly = false) {
27
29
  try {
28
30
  const info = await stat(abs);
29
31
  if (!info.isFile() || info.size > GREP_MAX_FILE_BYTES)
@@ -35,10 +37,13 @@ async function searchFile(abs, regex) {
35
37
  if (lines[lines.length - 1] === "")
36
38
  lines.pop();
37
39
  const hits = [];
38
- lines.forEach((line, i) => {
39
- if (regex.test(line))
40
- hits.push(i);
41
- });
40
+ for (let i = 0; i < lines.length; i++) {
41
+ if (!regex.test(lines[i]))
42
+ continue;
43
+ hits.push(i);
44
+ if (firstOnly)
45
+ break;
46
+ }
42
47
  return hits.length > 0 ? { lines, hits } : undefined;
43
48
  }
44
49
  catch {
@@ -103,6 +108,8 @@ export async function executeGrep(input, ctx) {
103
108
  return { content: `Path not found: ${displayPath(root, ctx.cwd)}`, isError: true };
104
109
  }
105
110
  const files = await candidateFiles(root, rootIsFile, input.glob, ctx.signal);
111
+ if (input.filesOnly === true)
112
+ return grepFilesOnly(files, regex, limit, ctx);
106
113
  const blocks = [];
107
114
  let matches = 0;
108
115
  let matchedFiles = 0;
@@ -150,22 +157,52 @@ export async function executeGrep(input, ctx) {
150
157
  details: { matches, files: matchedFiles, limited, truncated: cut.truncated },
151
158
  };
152
159
  }
160
+ /** [S-A] filesOnly:命中文件的显示路径,去重并按路径排序;limit 按文件数计。 */
161
+ async function grepFilesOnly(files, regex, limit, ctx) {
162
+ const sorted = [...new Set(files)].sort();
163
+ const shown = [];
164
+ let limited = false;
165
+ outer: for (let i = 0; i < sorted.length; i += GREP_CONCURRENCY) {
166
+ if (ctx.signal.aborted)
167
+ return { content: "aborted by user", isError: true };
168
+ const batch = sorted.slice(i, i + GREP_CONCURRENCY);
169
+ const results = await Promise.all(batch.map((f) => searchFile(f, regex, true)));
170
+ for (let j = 0; j < batch.length; j++) {
171
+ if (!results[j])
172
+ continue;
173
+ if (shown.length >= limit) {
174
+ limited = true;
175
+ break outer;
176
+ }
177
+ shown.push(displayPath(batch[j], ctx.cwd));
178
+ }
179
+ }
180
+ if (shown.length === 0) {
181
+ return { content: "No matches found", details: { matches: 0, files: 0, filesOnly: true } };
182
+ }
183
+ const unique = [...new Set(shown)].sort();
184
+ let content = unique.join("\n");
185
+ if (limited)
186
+ content += `\n\n[Stopped at ${limit} files. Narrow the search or raise limit.]`;
187
+ return { content, details: { files: unique.length, filesOnly: true, limited } };
188
+ }
153
189
  export function createGrepTool() {
154
190
  return {
155
191
  name: "grep",
156
192
  label: "Grep",
157
- description: "Search file contents by JS regex. Respects .gitignore/.ignore; skips binary and >2 MB " +
158
- "files. Output: `path:line: text`.",
193
+ description: "Use to find where a symbol or text appears (JS regex). Skips ignored, binary, >2 MB " +
194
+ "files. Output `path:line: text`; filesOnly lists matching files.",
159
195
  parameters: {
160
196
  type: "object",
161
197
  properties: {
162
198
  pattern: { type: "string" },
163
- path: { type: "string", description: "File or dir (default: cwd)" },
164
- glob: { type: "string", description: "File filter, e.g. *.ts" },
199
+ path: { type: "string", description: "File or dir" },
200
+ glob: { type: "string", description: "e.g. *.ts" },
165
201
  ignoreCase: { type: "boolean" },
166
202
  literal: { type: "boolean" },
167
203
  context: { type: "integer", description: "0-5 lines" },
168
204
  limit: { type: "integer", description: `Default ${DEFAULT_GREP_LIMIT}` },
205
+ filesOnly: { type: "boolean" },
169
206
  },
170
207
  required: ["pattern"],
171
208
  additionalProperties: false,
@@ -21,6 +21,11 @@ export interface ReadToolOptions {
21
21
  imageOptions?(ctx: ToolContext): ImageFitOptions;
22
22
  }
23
23
  export declare function isBinary(buf: Buffer): boolean;
24
+ /**
25
+ * [S-A] read 收到目录时的下一步提示:只指向模型当前真能调用的工具(docs/search-plan.md §4.2)。
26
+ * 活动集未知(宿主自建上下文)时沿用旧文案。
27
+ */
28
+ export declare function directoryHint(shown: string, ctx: Pick<ToolContext, "activeTools">): string;
24
29
  /** `cat -n` 形状。 */
25
30
  export declare function numberLines(lines: readonly string[], firstLine: number): string;
26
31
  export declare function executeRead(input: ReadInput, ctx: ToolContext, options?: ReadToolOptions): Promise<ToolResult>;
@@ -23,6 +23,25 @@ export function isBinary(buf) {
23
23
  function error(message) {
24
24
  return { content: message, isError: true };
25
25
  }
26
+ /**
27
+ * [S-A] read 收到目录时的下一步提示:只指向模型当前真能调用的工具(docs/search-plan.md §4.2)。
28
+ * 活动集未知(宿主自建上下文)时沿用旧文案。
29
+ */
30
+ export function directoryHint(shown, ctx) {
31
+ const active = ctx.activeTools;
32
+ if (active === undefined || active.has("ls"))
33
+ return "use the ls tool instead";
34
+ if (active.has("glob")) {
35
+ if (shown === ".")
36
+ return 'use glob (e.g. pattern "*")';
37
+ // cwd 外(绝对路径)或目录名含 glob 元字符时改用 path 参数,模式保持 `*`
38
+ if (shown.startsWith("/") || /^[A-Za-z]:/.test(shown) || /[*?[\]{}!\\]/.test(shown)) {
39
+ return `use glob (e.g. path ${JSON.stringify(shown)}, pattern "*")`;
40
+ }
41
+ return `use glob (e.g. pattern ${JSON.stringify(`${shown}/*`)})`;
42
+ }
43
+ return "read a file inside it";
44
+ }
26
45
  /** `cat -n` 形状。 */
27
46
  export function numberLines(lines, firstLine) {
28
47
  return lines.map((line, i) => `${String(firstLine + i).padStart(6)}\t${line}`).join("\n");
@@ -64,7 +83,7 @@ export async function executeRead(input, ctx, options = {}) {
64
83
  return error(`File not found: ${shown}`);
65
84
  }
66
85
  if (info.isDirectory())
67
- return error(`${shown} is a directory; use the ls tool instead`);
86
+ return error(`${shown} is a directory; ${directoryHint(shown, ctx)}`);
68
87
  if (imageMimeFromPath(abs) !== undefined)
69
88
  return readImage(abs, shown, ctx, options);
70
89
  const buf = await readFile(abs);
@@ -230,6 +230,11 @@ export interface ToolContext {
230
230
  /** 本会话已 read 的绝对路径(write / edit 先读后写检查)。 */
231
231
  readonly readFiles: ReadonlySet<string>;
232
232
  markRead(absolutePath: string): void;
233
+ /**
234
+ * [S-A] 本次调用时模型可直接调用的工具名(会话活动集,只读快照);工具据此给出可执行的提示
235
+ * (如 read 收到目录时说用 ls 还是 glob)。宿主 / SDK 自建上下文可不给。
236
+ */
237
+ readonly activeTools?: ReadonlySet<string>;
233
238
  /**
234
239
  * 嵌套调用其它工具(codemode 脚本、task),受同一管线;按全部未禁用工具查找(不限于活动集),
235
240
  * 发带 `parentToolCallId` 的 tool_execution_* 事件。`signal`:与 ctx.signal 合并,用于提前取消
package/docs/acp.md CHANGED
@@ -64,6 +64,9 @@ stdin 关闭后等已开始的运行结束再退出(0);SIGINT / SIGTERM
64
64
  - `session/request_permission` 交给 `onPermission`;没有处理器时回首个 `reject_once`(无人值守)。
65
65
  - `cancel(sessionId)` 发 `session/cancel`,并让该会话挂起的权限请求回 `cancelled`(规范要求)。
66
66
  - 只接受 Agent 自己给出的 `optionId`。
67
+ - 开会话(`newSession` / `resumeSession` / `loadSession`)的 `mcpServers` 缺省为空数组;宿主可传第三个参数
68
+ `{ mcpServers }`(如 stdio 的 `{ name, command, args, env: [{ name, value }] }`),原样转发给 Agent。
69
+ `AcpClient.features.mcpServers === true` 表示支持(旧版没有 `features`)。ama 自己作客户端时仍不传。
67
70
 
68
71
  `AcpDriver` 在客户端之上实现驱动契约(`AgentDriver`):续接优先 `session/resume`,其次 `session/load`(回放的历史丢弃),都不支持就新开并提示;按 ama 模式 `session/set_mode`,只读模式找不到对应模式 id 时拒绝启动。
69
72
 
package/docs/codemode.md CHANGED
@@ -24,6 +24,8 @@
24
24
 
25
25
  缺省配置里不写 `codemode.mode`(`ama init` 生成的 `config.json` 也不写),所以这张映射以后调整时老用户同样生效。`ama config show` / `ama doctor` 显示生效模式与原因(跟随哪个预设、网络由 Node 还是操作系统沙箱隔离);`ama doctor` 另列操作系统沙箱能力。
26
26
 
27
+ `minimal` / `coordinator` 预设缺省不带 codemode,也没有 grep / glob:模型找代码只能用 bash(`default` 权限模式下每次审批、`-p` 下被拒)。要检索就 `tools.default: ["+grep","+glob"]` 直接加上,比为了检索打开 codemode 便宜(见 [design.md](design.md) §5.6);`codemode-only` 的脚本里 `tools.grep()` / `tools.glob()` / `tools.ls()` 都可调用。
28
+
27
29
  `coordinator` 预设即使显式 `on`,脚本里能调用的工具也只限它的活动集(read 与宿主工具):`tools.bash`、`tools.write` 在脚本里同样不存在,协调者「不写文件、不跑 bash」的约定不能经 codemode 绕过。
28
30
 
29
31
  `codemode` 本身的权限类随沙箱能力:网络隔离(Node ≥ 25,或有操作系统沙箱,见下文沙箱)时是 `read` 类,`default` 权限模式下免审批——脚本只能经 `tools.*` 做事,每次内层调用仍逐个经过权限管线;网络未隔离(Node 22 / 24 且没有操作系统沙箱)时是 `execute` 类,`default` 模式下每次都要审批,`-p` 等无人值守场景直接拒绝,此时常用做法是在配置里放行它:
@@ -100,7 +100,7 @@ interface ToolResult {
100
100
  }
101
101
  ```
102
102
 
103
- `ToolContext` provides `toolCallId`, `cwd`, `sessionId`, `sessionFile?`, `signal`, `depth`, `model?`, `thinkingLevel?`, `outputDir?`, `onUpdate(partial)` (output while running), `readFiles` / `markRead`, `tools.executeTool(name, input)` (nested calls through the same pipeline), `session.appendCustom` / `lastCustom` (custom entries that never enter the context, see [session-format.md](../session-format.md), Chinese), `spawnSubagent?` and `log`.
103
+ `ToolContext` provides `toolCallId`, `cwd`, `sessionId`, `sessionFile?`, `signal`, `depth`, `model?`, `thinkingLevel?`, `outputDir?`, `onUpdate(partial)` (output while running), `readFiles` / `markRead`, `activeTools?` (read-only snapshot of the session's active tool set, so tools can give actionable hints), `tools.executeTool(name, input)` (nested calls through the same pipeline), `session.appendCustom` / `lastCustom` (custom entries that never enter the context, see [session-format.md](../session-format.md), Chinese), `spawnSubagent?` and `log`.
104
104
 
105
105
  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
106
 
package/docs/host-api.md CHANGED
@@ -95,7 +95,7 @@ interface ToolResult {
95
95
  }
96
96
  ```
97
97
 
98
- `ToolContext` 提供 `toolCallId`、`cwd`、`sessionId`、`sessionFile?`、`signal`、`depth`、`model?`、`thinkingLevel?`、`outputDir?`、`onUpdate(partial)`(运行中输出)、`readFiles` / `markRead`、`tools.executeTool(name, input)`(嵌套调用,受同一管线)、`session.appendCustom` / `lastCustom`(不进上下文的 custom 条目,见 [session-format.md](session-format.md))、`spawnSubagent?`、`log`。
98
+ `ToolContext` 提供 `toolCallId`、`cwd`、`sessionId`、`sessionFile?`、`signal`、`depth`、`model?`、`thinkingLevel?`、`outputDir?`、`onUpdate(partial)`(运行中输出)、`readFiles` / `markRead`、`activeTools?`(会话活动集的只读快照,工具据此给可执行的提示)、`tools.executeTool(name, input)`(嵌套调用,受同一管线)、`session.appendCustom` / `lastCustom`(不进上下文的 custom 条目,见 [session-format.md](session-format.md))、`spawnSubagent?`、`log`。
99
99
 
100
100
  宿主工具与内置工具走同一条路径:schema 校验 → 命令式 Hook PreToolUse → 权限管线(按 `permission` 分类)→ 审批 → 执行 → PostToolUse。在 codemode 脚本里也能以 `tools.<name>()` 调用。
101
101
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@armadra/agent",
3
- "version": "0.6.4",
3
+ "version": "0.6.6",
4
4
  "description": "A coding and coordination agent that runs standalone or embedded in Armadra",
5
5
  "type": "module",
6
6
  "keywords": [