ysagc-agent 0.9.5 → 0.9.14

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 (51) hide show
  1. package/dist/agent.d.ts +1 -1
  2. package/dist/agent.d.ts.map +1 -1
  3. package/dist/agent.js +137 -10
  4. package/dist/agent.js.map +1 -1
  5. package/dist/browser.d.ts +31 -0
  6. package/dist/browser.d.ts.map +1 -0
  7. package/dist/browser.js +609 -0
  8. package/dist/browser.js.map +1 -0
  9. package/dist/cli.d.ts.map +1 -1
  10. package/dist/cli.js +69 -0
  11. package/dist/cli.js.map +1 -1
  12. package/dist/executor.d.ts +6 -0
  13. package/dist/executor.d.ts.map +1 -1
  14. package/dist/executor.js +46 -0
  15. package/dist/executor.js.map +1 -1
  16. package/dist/hooks.d.ts +29 -0
  17. package/dist/hooks.d.ts.map +1 -0
  18. package/dist/hooks.js +211 -0
  19. package/dist/hooks.js.map +1 -0
  20. package/dist/index.d.ts +3 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +13 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/mcp.d.ts +92 -0
  25. package/dist/mcp.d.ts.map +1 -0
  26. package/dist/mcp.js +746 -0
  27. package/dist/mcp.js.map +1 -0
  28. package/dist/memory.d.ts +14 -0
  29. package/dist/memory.d.ts.map +1 -0
  30. package/dist/memory.js +148 -0
  31. package/dist/memory.js.map +1 -0
  32. package/dist/protocol-handler.d.ts +8 -0
  33. package/dist/protocol-handler.d.ts.map +1 -0
  34. package/dist/protocol-handler.js +127 -0
  35. package/dist/protocol-handler.js.map +1 -0
  36. package/dist/protocol.d.ts +60 -3
  37. package/dist/protocol.d.ts.map +1 -1
  38. package/dist/protocol.js +1 -1
  39. package/dist/protocol.js.map +1 -1
  40. package/dist/screenshot.d.ts +15 -0
  41. package/dist/screenshot.d.ts.map +1 -0
  42. package/dist/screenshot.js +385 -0
  43. package/dist/screenshot.js.map +1 -0
  44. package/dist/security.d.ts.map +1 -1
  45. package/dist/security.js +18 -4
  46. package/dist/security.js.map +1 -1
  47. package/dist/tools.d.ts +22 -1
  48. package/dist/tools.d.ts.map +1 -1
  49. package/dist/tools.js +470 -78
  50. package/dist/tools.js.map +1 -1
  51. package/package.json +1 -1
package/dist/tools.js CHANGED
@@ -38,14 +38,23 @@ exports.walkTree = walkTree;
38
38
  exports.globToRegex = globToRegex;
39
39
  exports.preExecute = preExecute;
40
40
  exports.postExecute = postExecute;
41
+ exports.listMcpTools = listMcpTools;
42
+ exports.mcpFailuresFor = mcpFailuresFor;
43
+ exports.shutdownMcp = shutdownMcp;
41
44
  exports.createToolRegistry = createToolRegistry;
42
45
  const fs = __importStar(require("node:fs"));
46
+ const os = __importStar(require("node:os"));
43
47
  const path = __importStar(require("node:path"));
44
48
  const childProcess = __importStar(require("node:child_process"));
45
49
  const path_utils_1 = require("./path-utils");
46
50
  const security_1 = require("./security");
47
51
  const skills_1 = require("./skills");
48
52
  const executor_1 = require("./executor");
53
+ const screenshot_1 = require("./screenshot");
54
+ const browser_1 = require("./browser");
55
+ const memory_1 = require("./memory");
56
+ const hooks_1 = require("./hooks");
57
+ const mcp_1 = require("./mcp");
49
58
  /** Thrown when a tool invocation is invalid or unsafe. */
50
59
  class ToolError extends Error {
51
60
  constructor(code, message) {
@@ -76,7 +85,11 @@ function optionalString(args, key, fallback) {
76
85
  return v;
77
86
  }
78
87
  function optionalInt(args, key, fallback, opts = {}) {
79
- const v = args[key];
88
+ // 兼容平台下发的同义字段:平台工具 schema 用 timeoutMs,本机历史配置用 timeout
89
+ let v = args[key];
90
+ if ((v === undefined || v === null) && key === 'timeout' && args.timeoutMs !== undefined) {
91
+ v = args.timeoutMs;
92
+ }
80
93
  if (v === undefined || v === null)
81
94
  return fallback;
82
95
  const n = typeof v === 'number' ? v : Number(v);
@@ -142,6 +155,64 @@ function readUpTo(target, length, offset = 0) {
142
155
  fs.closeSync(fd);
143
156
  }
144
157
  }
158
+ /**
159
+ * 备份目录:备份放在授权根的 `.ysagc/backups/` 下,**保持原相对路径**,而不是写在文件旁边。
160
+ *
161
+ * 为什么改:原来写 `<name>.bak`(和源文件同目录),等于往用户的项目里塞文件 ——
162
+ * 实测用户的 Vue 项目根目录出现 `README.md.bak`、`report.html.bak`,
163
+ * 一不小心就被提交进 git。平台虽然有「本次改动 + 一键还原」能回滚,
164
+ * 但备份本身还是该放在一个明显属于工具、不会被误提交的地方。
165
+ */
166
+ function backupPathFor(root, target) {
167
+ const rel = path.relative(root, target);
168
+ const safe = rel.startsWith('..') || path.isAbsolute(rel) ? path.basename(target) : rel;
169
+ return path.join(root, '.ysagc', 'backups', `${safe}.bak`);
170
+ }
171
+ /**
172
+ * 原子写入:先写同目录临时文件并 fsync,再 rename 覆盖目标。
173
+ * rename 在同一文件系统上是原子操作,因此「目标文件要么是旧内容、要么是新内容」,
174
+ * 不会出现写一半的破损文件(进程被杀 / 断电 / 磁盘写满都安全)。
175
+ * 目标原本存在时先留一份备份到 .ysagc/backups/(仅保留最近一次),便于误改后恢复。
176
+ */
177
+ function atomicWrite(target, data, opts = {}) {
178
+ const dir = path.dirname(target);
179
+ fs.mkdirSync(dir, { recursive: true });
180
+ const tmp = path.join(dir, `.${path.basename(target)}.${process.pid}.${Date.now().toString(36)}.tmp`);
181
+ let fd = null;
182
+ try {
183
+ fd = fs.openSync(tmp, 'w', opts.mode ?? 0o644);
184
+ fs.writeFileSync(fd, data);
185
+ try {
186
+ fs.fsyncSync(fd);
187
+ }
188
+ catch { /* 某些文件系统不支持,忽略 */ }
189
+ fs.closeSync(fd);
190
+ fd = null;
191
+ // 覆盖已有文件前留一份备份(只在目标确实存在且不是空文件时)
192
+ try {
193
+ if (fs.existsSync(target) && fs.statSync(target).size > 0 && opts.root) {
194
+ const bak = backupPathFor(opts.root, target);
195
+ fs.mkdirSync(path.dirname(bak), { recursive: true });
196
+ fs.copyFileSync(target, bak);
197
+ }
198
+ }
199
+ catch { /* 备份失败不影响主流程 */ }
200
+ fs.renameSync(tmp, target);
201
+ }
202
+ catch (err) {
203
+ try {
204
+ if (fd !== null)
205
+ fs.closeSync(fd);
206
+ }
207
+ catch { /* ignore */ }
208
+ try {
209
+ if (fs.existsSync(tmp))
210
+ fs.unlinkSync(tmp);
211
+ }
212
+ catch { /* ignore */ }
213
+ throw err;
214
+ }
215
+ }
145
216
  function rel(root, target) {
146
217
  return path.relative(root, target).replace(/\\/g, '/');
147
218
  }
@@ -294,6 +365,12 @@ const WRITE_OR_COMMAND_TOOLS = new Set([
294
365
  'mv',
295
366
  'cp',
296
367
  'delete_file',
368
+ // 截图会往工作区里写文件,所以同样算写操作:只读模式下必须被拒
369
+ 'screenshot',
370
+ // 浏览器操作会驱动真实页面(点按 / 输入 / 提交表单),同样按写操作对待
371
+ 'browser',
372
+ // 写项目记忆文件(.ysagc/memory.md)
373
+ 'remember',
297
374
  ]);
298
375
  /** Reconstruct the shell command a command-tool intends to run, if any. */
299
376
  function commandForTool(tool, args) {
@@ -325,8 +402,35 @@ function pathArgsForTool(tool, args) {
325
402
  }
326
403
  return out;
327
404
  }
328
- /** Default preset used when the caller did not specify one on the context. */
329
- const DEFAULT_PRESET = 'workspace-write';
405
+ /**
406
+ * 读取类工具的路径解析:允许落在**主授权根**或任一**只读参考根**内。
407
+ *
408
+ * 与 safeResolve 的区别只有一点:多了 readRoots。写入类工具一律继续用
409
+ * safeResolve(只看主根),所以参考目录天然是只读的——这是这个功能的安全边界。
410
+ * 相对路径仍按主根解析,参考目录里的文件用绝对路径访问。
411
+ */
412
+ function resolveReadable(ctx, p) {
413
+ const primary = (0, path_utils_1.canonicalize)(ctx.root);
414
+ const raw = String(p ?? '').trim();
415
+ if (!raw)
416
+ throw new path_utils_1.PathError('ERR_EMPTY_PATH', 'Path is empty');
417
+ const abs = path.isAbsolute(raw) ? raw : path.resolve(primary, raw);
418
+ const cand = (0, path_utils_1.canonicalize)(abs);
419
+ if ((0, path_utils_1.isWithinRoot)(primary, cand, { allowEqual: true }))
420
+ return cand;
421
+ for (const r of ctx.readRoots || []) {
422
+ try {
423
+ const rr = (0, path_utils_1.canonicalize)(r);
424
+ if ((0, path_utils_1.isWithinRoot)(rr, cand, { allowEqual: true }))
425
+ return cand;
426
+ }
427
+ catch {
428
+ /* 无效的参考根(不存在/无权限)直接忽略 */
429
+ }
430
+ }
431
+ throw new path_utils_1.PathError('ERR_OUTSIDE_ROOT', `Path is outside the authorised root: ${p} (root: ${primary})`);
432
+ }
433
+ /** Default preset used when the caller did not specify one on the context. */ const DEFAULT_PRESET = 'workspace-write';
330
434
  /** Whether the active preset permits a danger-flagged command without an
331
435
  * approval prompt. Only the most-permissive mode allows non-critical danger
332
436
  * commands; every other mode blocks them at the gate. */
@@ -347,7 +451,9 @@ function preExecute(ctx, tool, args) {
347
451
  const preset = ctx.permissionPreset ?? DEFAULT_PRESET;
348
452
  // 1) Permission preset: read-only denies anything that writes or executes.
349
453
  if (preset === 'read-only') {
350
- if (WRITE_OR_COMMAND_TOOLS.has(tool)) {
454
+ // MCP 工具对我们是黑盒:无法判断它到底写不写文件,所以保守地按写操作拦。
455
+ // 否则「只读模式」会变成一个能通过任意 MCP server 写盘的洞。
456
+ if (WRITE_OR_COMMAND_TOOLS.has(tool) || tool.startsWith('mcp__')) {
351
457
  return {
352
458
  ok: false,
353
459
  error: `Tool "${tool}" is blocked in read-only mode (preset: read-only).`,
@@ -382,11 +488,20 @@ function preExecute(ctx, tool, args) {
382
488
  }
383
489
  }
384
490
  // 3) Canonicalise + sensitive-path check for every path argument.
491
+ // 读取类工具允许落在「只读参考根」内;写入类工具一律只认主授权根——
492
+ // 参考目录因此天然只读,这是「让 AI 参考别的项目」时的安全边界。
493
+ const isWriteTool = WRITE_OR_COMMAND_TOOLS.has(tool);
385
494
  let realRoot;
386
495
  for (const p of pathArgsForTool(tool, args)) {
387
496
  try {
388
- realRoot = realRoot ?? (0, path_utils_1.canonicalize)(ctx.root);
389
- const target = (0, path_utils_1.safeResolve)(ctx.root, p, realRoot, { allowEqual: true });
497
+ let target;
498
+ if (isWriteTool) {
499
+ realRoot = realRoot ?? (0, path_utils_1.canonicalize)(ctx.root);
500
+ target = (0, path_utils_1.safeResolve)(ctx.root, p, realRoot, { allowEqual: true });
501
+ }
502
+ else {
503
+ target = resolveReadable(ctx, p);
504
+ }
390
505
  if ((0, security_1.isSensitivePath)(target)) {
391
506
  return {
392
507
  ok: false,
@@ -442,7 +557,7 @@ const listDir = async (ctx, args) => {
442
557
  const p = optionalString(args, 'path', '.') ?? '.';
443
558
  const depth = optionalInt(args, 'depth', 1, { min: 0, max: 10 });
444
559
  const realRoot = (0, path_utils_1.canonicalize)(ctx.root);
445
- const target = (0, path_utils_1.safeResolve)(ctx.root, p, realRoot, { allowEqual: true });
560
+ const target = resolveReadable(ctx, p);
446
561
  assertNotSensitive(target);
447
562
  let stat;
448
563
  try {
@@ -500,7 +615,7 @@ const readFile = async (ctx, args, signal) => {
500
615
  const hasLength = args.length !== undefined && args.length !== null;
501
616
  const length = optionalInt(args, hasLength ? 'length' : 'maxBytes', ctx.maxBytes, { min: 1, max: MAX_WRITE_BYTES });
502
617
  const realRoot = (0, path_utils_1.canonicalize)(ctx.root);
503
- const target = (0, path_utils_1.safeResolve)(ctx.root, targetStr, realRoot);
618
+ const target = resolveReadable(ctx, targetStr);
504
619
  assertNotSensitive(target);
505
620
  let stat;
506
621
  try {
@@ -552,13 +667,59 @@ const writeFile = async (ctx, args, signal) => {
552
667
  if (totalBytes > MAX_WRITE_BYTES) {
553
668
  throw new ToolError('TOO_LARGE', `Content exceeds ${MAX_WRITE_BYTES} bytes.`);
554
669
  }
555
- fs.mkdirSync(path.dirname(target), { recursive: true });
556
- fs.writeFileSync(target, content, { mode });
670
+ atomicWrite(target, content, { mode, root: ctx.root });
557
671
  return {
558
672
  ok: true,
559
673
  result: { path: rel(realRoot, target), bytesWritten: totalBytes, mode },
560
674
  };
561
675
  };
676
+ /**
677
+ * 换行符归一化:Windows 上仓库里的文件普遍是 CRLF,而模型发过来的 oldText 是 LF。
678
+ *
679
+ * 实测踩到(用户报障的整条任务就是卡在这里):`admin/server/index.ts` 是 CRLF 文件,
680
+ * 模型给的多行 oldText 用的是 `\n`,逐字面比较必然 "oldText was not found in the file.",
681
+ * 于是模型反复重试、最后放弃并给了用户一个错误的结论(说工作区路径不对)。
682
+ * 单行编辑不受影响(没有换行符),所以这个 bug 只在多行编辑上暴露 —— 更难被发现。
683
+ *
684
+ * 规则:**匹配时统一按 LF 比**,**写回时还原文件原本的换行风格**(不让 git 出现整文件 diff)。
685
+ */
686
+ function detectEol(text) {
687
+ const crlf = (text.match(/\r\n/g) || []).length;
688
+ const lf = (text.match(/(?<!\r)\n/g) || []).length;
689
+ return crlf > lf ? '\r\n' : '\n';
690
+ }
691
+ function toLf(text) {
692
+ return text.replace(/\r\n/g, '\n');
693
+ }
694
+ function applyEol(text, eol) {
695
+ return eol === '\r\n' ? text.replace(/\n/g, '\r\n') : text;
696
+ }
697
+ /** 读文件:返回「LF 归一化后的正文」与「该文件原本的换行符」 */
698
+ function readTextFile(target) {
699
+ const buf = fs.readFileSync(target);
700
+ if (!isUtf8(buf))
701
+ throw new ToolError('BINARY_FILE', 'only UTF-8 text files are supported.');
702
+ const raw = buf.toString('utf8');
703
+ return { text: toLf(raw), eol: detectEol(raw) };
704
+ }
705
+ /**
706
+ * 匹配失败时给模型**可操作**的信息,而不是干巴巴一句 not found。
707
+ * 做法:拿 oldText 的第一行去文件里找,找到就把那附近几行回给它 —— 模型据此就能
708
+ * 把 oldText 改对,而不是靠猜(实测它猜两轮就放弃了)。
709
+ */
710
+ function noMatchHint(text, needle) {
711
+ const firstLine = needle.split('\n').map((l) => l.trim()).find((l) => l.length >= 8) || '';
712
+ const lines = text.split('\n');
713
+ const idx = firstLine ? lines.findIndex((l) => l.includes(firstLine)) : -1;
714
+ const head = 'oldText was not found in the file.';
715
+ if (idx < 0) {
716
+ return head + '(提示:oldText 必须与文件内容**逐字一致**,含缩进与标点;建议先用 read_file 或 str_replace_editor view 读一次再改)';
717
+ }
718
+ const from = Math.max(0, idx - 2);
719
+ const to = Math.min(lines.length, idx + 3);
720
+ const snippet = lines.slice(from, to).map((l, i) => `${from + i + 1}| ${l}`).join('\n');
721
+ return `${head}\n文件里最接近的位置在第 ${idx + 1} 行附近,实际内容如下(行号| 内容):\n${snippet}\n请按上面的真实内容修正 oldText 后重试。`;
722
+ }
562
723
  const editFile = async (ctx, args, signal) => {
563
724
  abortIfRequested(signal);
564
725
  const targetStr = requireString(args, 'path');
@@ -578,16 +739,15 @@ const editFile = async (ctx, args, signal) => {
578
739
  if (stat.isDirectory()) {
579
740
  throw new ToolError('IS_DIR', `Path is a directory, not a file: ${targetStr}`);
580
741
  }
581
- const buf = fs.readFileSync(target);
582
- if (!isUtf8(buf)) {
583
- throw new ToolError('BINARY_FILE', 'edit_file only supports UTF-8 text files.');
584
- }
585
- const text = buf.toString('utf8');
586
- const count = text.split(oldText).length - 1;
742
+ // 统一按 LF 匹配(CRLF 文件 + 模型给的 LF oldText 也能命中),写回时还原原文件的换行风格
743
+ const { text, eol } = readTextFile(target);
744
+ const needle = toLf(oldText);
745
+ const count = text.split(needle).length - 1;
587
746
  if (count === 0) {
588
- throw new ToolError('NO_MATCH', 'oldText was not found in the file.');
747
+ throw new ToolError('NO_MATCH', noMatchHint(text, needle));
589
748
  }
590
- fs.writeFileSync(target, text.split(oldText).join(newText), 'utf8');
749
+ const updated = text.split(needle).join(toLf(newText));
750
+ atomicWrite(target, applyEol(updated, eol), { root: ctx.root });
591
751
  return { ok: true, result: { path: rel(realRoot, target), replacements: count } };
592
752
  };
593
753
  const runCommandTool = async (ctx, args, signal, opts) => {
@@ -596,6 +756,21 @@ const runCommandTool = async (ctx, args, signal, opts) => {
596
756
  const timeout = optionalInt(args, 'timeout', ctx.timeoutMs, { min: 1 });
597
757
  const realRoot = (0, path_utils_1.canonicalize)(ctx.root);
598
758
  const cwd = (0, path_utils_1.safeResolve)(ctx.root, cwdArg, realRoot, { allowEqual: true });
759
+ // 工作目录不存在时先说清楚。
760
+ // 实测踩过:用户把工作区目录删掉/改名之后,所有命令只回一个 `exit=127` 加空输出 ——
761
+ // 模型看不懂,用户更看不懂(127 是 shell 的「命令不存在」,这里的真实原因是
762
+ // 「进不去这个目录」)。宁可在这里明确拦住,也不要让它变成一串看不懂的退出码。
763
+ if (!fs.existsSync(cwd)) {
764
+ return {
765
+ ok: false,
766
+ error: `工作目录不存在:${cwd}。`
767
+ + `(工作区根目录 ${realRoot} 可能已被删除或改名)`
768
+ + '请在网页上重新选择/新建工作区后再试。',
769
+ };
770
+ }
771
+ if (!fs.statSync(cwd).isDirectory()) {
772
+ return { ok: false, error: `工作目录不是一个目录:${cwd}` };
773
+ }
599
774
  const assessment = (0, security_1.isDangerCommand)(cmd);
600
775
  if (assessment.danger && !permitsDangerCommand(ctx.permissionPreset, assessment.severity) && !opts?.preApproved) {
601
776
  return {
@@ -613,6 +788,7 @@ const runCommandTool = async (ctx, args, signal, opts) => {
613
788
  timeoutMs: timeout,
614
789
  maxBytes: ctx.maxBytes,
615
790
  signal,
791
+ ...(opts?.onOutput ? { onOutput: opts.onOutput } : {}),
616
792
  });
617
793
  return {
618
794
  ok: true,
@@ -754,7 +930,7 @@ const generate = async (ctx, args, signal) => {
754
930
  fs.mkdirSync(path.dirname(dest), { recursive: true });
755
931
  const content = tpl.content.replace(/\{NAME\}/g, name);
756
932
  const bytes = Buffer.byteLength(content);
757
- fs.writeFileSync(dest, content, 'utf8');
933
+ atomicWrite(dest, content, { root: ctx.root });
758
934
  written.push({ path: rel(realRoot, dest), bytes });
759
935
  }
760
936
  return {
@@ -779,7 +955,7 @@ const search = async (ctx, args, signal) => {
779
955
  throw new ToolError('INVALID_REGEX', `Invalid search pattern: ${patternStr}`);
780
956
  }
781
957
  const realRoot = (0, path_utils_1.canonicalize)(ctx.root);
782
- const target = (0, path_utils_1.safeResolve)(ctx.root, targetStr, realRoot, { allowEqual: true });
958
+ const target = resolveReadable(ctx, targetStr);
783
959
  assertNotSensitive(target);
784
960
  let st;
785
961
  try {
@@ -883,7 +1059,7 @@ const glob = async (ctx, args, signal) => {
883
1059
  const baseStr = optionalString(args, 'path', '.') ?? '.';
884
1060
  const maxResults = optionalInt(args, 'maxResults', 1000, { min: 1, max: 5000 });
885
1061
  const realRoot = (0, path_utils_1.canonicalize)(ctx.root);
886
- const base = (0, path_utils_1.safeResolve)(ctx.root, baseStr, realRoot, { allowEqual: true });
1062
+ const base = resolveReadable(ctx, baseStr);
887
1063
  assertNotSensitive(base);
888
1064
  let st;
889
1065
  try {
@@ -923,7 +1099,7 @@ const tree = async (ctx, args, signal) => {
923
1099
  const maxEntries = optionalInt(args, 'maxEntries', 500, { min: 1, max: 5000 });
924
1100
  const includeHidden = optionalBool(args, 'showHidden', false);
925
1101
  const realRoot = (0, path_utils_1.canonicalize)(ctx.root);
926
- const target = (0, path_utils_1.safeResolve)(ctx.root, targetStr, realRoot, { allowEqual: true });
1102
+ const target = resolveReadable(ctx, targetStr);
927
1103
  assertNotSensitive(target);
928
1104
  let st;
929
1105
  try {
@@ -1035,23 +1211,9 @@ const strReplaceEditor = async (ctx, args, signal) => {
1035
1211
  throw new ToolError('ROOT_PATH', 'The authorisation root itself cannot be edited.');
1036
1212
  }
1037
1213
  };
1038
- const ensureFile = () => {
1039
- let st;
1040
- try {
1041
- st = fs.statSync(target);
1042
- }
1043
- catch {
1044
- throw new ToolError('NOT_FOUND', `Path does not exist: ${pathStr}`);
1045
- }
1046
- if (st.isDirectory()) {
1047
- throw new ToolError('IS_DIR', `Path is a directory, not a file: ${pathStr}`);
1048
- }
1049
- const buf = fs.readFileSync(target);
1050
- if (!isUtf8(buf)) {
1051
- throw new ToolError('BINARY_FILE', 'str_replace_editor only supports UTF-8 text files.');
1052
- }
1053
- return buf.toString('utf8');
1054
- };
1214
+ // 注:读文件的统一入口改成了 readTextFile()(它顺带做换行归一化并返回原文件 EOL)。
1215
+ // 这里原先的 ensureFile() 已经没人用了 —— 它返回的是带 \r 的原始正文,
1216
+ // 正是「CRLF 文件上多行匹配必然失败」的根源,留着容易再被误用,所以删掉。
1055
1217
  switch (command) {
1056
1218
  case 'create': {
1057
1219
  assertNotRootTarget();
@@ -1061,11 +1223,13 @@ const strReplaceEditor = async (ctx, args, signal) => {
1061
1223
  throw new ToolError('EXISTS', `File already exists: ${pathStr} (pass allowOverwrite:true to replace it).`);
1062
1224
  }
1063
1225
  fs.mkdirSync(path.dirname(target), { recursive: true });
1064
- fs.writeFileSync(target, fileText, 'utf8');
1226
+ atomicWrite(target, fileText, { root: ctx.root });
1065
1227
  return { ok: true, result: { path: rel(realRoot, target), bytesWritten: Buffer.byteLength(fileText) } };
1066
1228
  }
1067
1229
  case 'view': {
1068
- const text = ensureFile();
1230
+ // 用 readTextFile:CRLF 文件的行尾 \r 会被归一化掉,否则 view 出来每行都带一个不可见字符,
1231
+ // 模型照着它拼 old_str 就更不可能匹配上了。
1232
+ const { text } = readTextFile(target);
1069
1233
  const allLines = text === '' ? [] : text.split('\n');
1070
1234
  const range = readLineRange(args);
1071
1235
  const selected = range ? allLines.slice(range[0] - 1, range[1]) : allLines;
@@ -1085,36 +1249,36 @@ const strReplaceEditor = async (ctx, args, signal) => {
1085
1249
  }
1086
1250
  case 'str_replace': {
1087
1251
  assertNotRootTarget();
1088
- const text = ensureFile();
1089
- const oldStr = requireString(args, 'old_str');
1090
- const newStr = typeof args.new_str === 'string' ? args.new_str : '';
1252
+ const { text, eol } = readTextFile(target);
1253
+ const oldStr = toLf(requireString(args, 'old_str'));
1254
+ const newStr = toLf(typeof args.new_str === 'string' ? args.new_str : '');
1091
1255
  const replaceAll = optionalBool(args, 'replace_all', false);
1092
1256
  const count = text.split(oldStr).length - 1;
1093
1257
  if (count === 0) {
1094
- throw new ToolError('NO_MATCH', 'old_str was not found in the file.');
1258
+ throw new ToolError('NO_MATCH', noMatchHint(text, oldStr));
1095
1259
  }
1096
1260
  if (!replaceAll && count !== 1) {
1097
1261
  throw new ToolError('MULTIPLE_MATCHES', `old_str matched ${count} times; pass replace_all:true to replace all of them.`);
1098
1262
  }
1099
1263
  const updated = replaceAll ? text.split(oldStr).join(newStr) : text.replace(oldStr, newStr);
1100
- fs.writeFileSync(target, updated, 'utf8');
1264
+ atomicWrite(target, applyEol(updated, eol), { root: ctx.root });
1101
1265
  return { ok: true, result: { path: rel(realRoot, target), replacements: count } };
1102
1266
  }
1103
1267
  case 'insert': {
1104
1268
  assertNotRootTarget();
1105
- const text = ensureFile();
1106
- const newStr = requireString(args, 'new_str');
1269
+ const { text, eol } = readTextFile(target);
1270
+ const newStr = toLf(requireString(args, 'new_str'));
1107
1271
  const insertLine = optionalInt(args, 'insert_line', 0, { min: 0 });
1108
1272
  const lines = text === '' ? [] : text.split('\n');
1109
1273
  // insert_line is 1-based; 0 or out-of-range appends.
1110
1274
  const pos = insertLine >= 1 && insertLine <= lines.length + 1 ? insertLine - 1 : lines.length;
1111
1275
  lines.splice(pos, 0, newStr);
1112
- fs.writeFileSync(target, lines.join('\n'), 'utf8');
1276
+ atomicWrite(target, applyEol(lines.join('\n'), eol), { root: ctx.root });
1113
1277
  return { ok: true, result: { path: rel(realRoot, target), line: pos + 1, inserted: newStr } };
1114
1278
  }
1115
1279
  case 'delete': {
1116
1280
  assertNotRootTarget();
1117
- const text = ensureFile();
1281
+ const { text, eol } = readTextFile(target);
1118
1282
  const range = readLineRange(args);
1119
1283
  if (!range) {
1120
1284
  throw new ToolError('INVALID_ARG', '"delete" requires a "view_range" [start, end].');
@@ -1126,7 +1290,7 @@ const strReplaceEditor = async (ctx, args, signal) => {
1126
1290
  }
1127
1291
  const removed = lines.slice(start - 1, Math.min(end, lines.length));
1128
1292
  lines.splice(start - 1, Math.min(end, lines.length) - (start - 1));
1129
- fs.writeFileSync(target, lines.join('\n'), 'utf8');
1293
+ atomicWrite(target, applyEol(lines.join('\n'), eol), { root: ctx.root });
1130
1294
  return { ok: true, result: { path: rel(realRoot, target), lineStart: start, lineEnd: Math.min(end, start + removed.length - 1), removed: removed.length } };
1131
1295
  }
1132
1296
  default:
@@ -1404,13 +1568,18 @@ const fsDrives = async (_ctx) => {
1404
1568
  * - 仍拦截敏感/凭证路径(.env、id_rsa、C:\Windows\System32 等);
1405
1569
  * - 只返回元数据(名称/类型/大小/时间),不读文件内容。
1406
1570
  */
1407
- const fsBrowse = async (_ctx, args) => {
1571
+ const fsBrowse = async (ctx, args) => {
1408
1572
  const p = String(args.path || '').trim();
1409
1573
  if (!p)
1410
1574
  return { ok: true, result: { drives: listDriveRoots(), prompt: 'drives' } };
1411
1575
  let abs;
1412
1576
  try {
1413
- abs = (0, path_utils_1.canonicalize)(p);
1577
+ // 相对路径必须按【授权根】解析,与 read_file/glob 等所有其它工具保持一致。
1578
+ // 历史实现直接 canonicalize(p)(= 进程 cwd),有两个真问题:
1579
+ // 1) 平台传相对路径(如 'gen/music')时会去找 <agent cwd>/gen/music,用户看到「路径不存在」;
1580
+ // 2) 前置校验按授权根放行,工具本体却按 cwd 读——两边解析的不是同一个路径,
1581
+ // 相对路径可以越出授权根去列别的目录。
1582
+ abs = resolveReadable(ctx, p);
1414
1583
  }
1415
1584
  catch (e) {
1416
1585
  return { ok: false, error: `无效路径: ${e?.message || e}` };
@@ -1450,10 +1619,10 @@ const fsBrowse = async (_ctx, args) => {
1450
1619
  return { ok: true, result: { path: abs, entries } };
1451
1620
  };
1452
1621
  /** 用系统原生"选择文件夹"对话框返回绝对路径(Windows/Linux/macOS)。取消或不可用返回 null。 */
1453
- function execFileP(cmd, args, timeoutMs) {
1622
+ function execFileP(cmd, args, timeoutMs, windowsHide = true) {
1454
1623
  return new Promise((resolve) => {
1455
1624
  try {
1456
- childProcess.execFile(cmd, args, { timeout: timeoutMs, windowsHide: true, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
1625
+ childProcess.execFile(cmd, args, { timeout: timeoutMs, windowsHide, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
1457
1626
  resolve(String(stdout || '').trim());
1458
1627
  });
1459
1628
  }
@@ -1462,22 +1631,57 @@ function execFileP(cmd, args, timeoutMs) {
1462
1631
  }
1463
1632
  });
1464
1633
  }
1634
+ async function pickFolderViaWindowsDialog() {
1635
+ /**
1636
+ * 用 WSH(wscript.exe 是 GUI 子系统,不会闪现控制台窗口)+ Shell.Application
1637
+ * 弹出资源管理器风格的「选择文件夹」对话框,选中的路径写入临时文件后返回。
1638
+ *
1639
+ * 注意:子进程不能带 windowsHide:true(或其它隐藏控制台的方式)启动——实测在该
1640
+ * 启动方式下模态对话框(WinForms FolderBrowserDialog / Shell BrowseForFolder)
1641
+ * 都不会显示窗口,ShowDialog 一直挂到超时,用户毫无感知。wscript 无控制台,
1642
+ * 用默认启动方式即可正常显示对话框。
1643
+ */
1644
+ const base = path.join(os.tmpdir(), `ysagc-pick-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
1645
+ const vbsPath = `${base}.vbs`;
1646
+ const outPath = `${base}.txt`;
1647
+ const vbs = [
1648
+ 'Set sh = CreateObject("Shell.Application")',
1649
+ 'Set f = sh.BrowseForFolder(0, "选择工作区目录", 65, "")',
1650
+ 'If Not f Is Nothing Then',
1651
+ ' Set fs = CreateObject("Scripting.FileSystemObject")',
1652
+ // 65 = BIF_NEWDIALOGSTYLE(64) + BIF_RETURNONLYFSDIRS(1):现代风格 + 只选文件系统目录
1653
+ ` Set out = fs.OpenTextFile("${outPath.replace(/"/g, '""')}", 2, True)`,
1654
+ ' out.WriteLine f.Items().Item().Path',
1655
+ ' out.Close',
1656
+ 'End If',
1657
+ ].join('\r\n');
1658
+ try {
1659
+ // WSH 按系统 ANSI 代码页解析 .vbs,中文标题必须以 UTF-16LE + BOM 写入
1660
+ fs.writeFileSync(vbsPath, Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(vbs, 'utf16le')]));
1661
+ // 3 分钟内选完(含折叠目录的时间);后端等待 200s,略大于此值
1662
+ await execFileP('wscript.exe', ['//Nologo', vbsPath], 180000, false);
1663
+ if (!fs.existsSync(outPath))
1664
+ return null; // 用户取消
1665
+ return fs.readFileSync(outPath, 'utf8').trim() || null;
1666
+ }
1667
+ catch {
1668
+ return null;
1669
+ }
1670
+ finally {
1671
+ try {
1672
+ fs.unlinkSync(vbsPath);
1673
+ }
1674
+ catch { /* best-effort */ }
1675
+ try {
1676
+ fs.unlinkSync(outPath);
1677
+ }
1678
+ catch { /* best-effort */ }
1679
+ }
1680
+ }
1465
1681
  async function pickFolderViaOsDialog() {
1466
1682
  try {
1467
1683
  if (process.platform === 'win32') {
1468
- // 系统原生"选择文件夹"对话框(WinForms FolderBrowserDialog,Windows 经典的 Browse for Folder)。
1469
- // 用 -STA 单线程 + EnableVisualStyles,确保对话框在交互式桌面上真实弹出。
1470
- const script = [
1471
- 'Add-Type -AssemblyName System.Windows.Forms',
1472
- '[System.Windows.Forms.Application]::EnableVisualStyles()',
1473
- '$d = New-Object System.Windows.Forms.FolderBrowserDialog',
1474
- '$d.Description = "选择工作区目录"',
1475
- '$d.ShowNewFolderButton = $true',
1476
- '[System.Windows.Forms.DialogResult]$r = $d.ShowDialog()',
1477
- 'if ($r -eq [System.Windows.Forms.DialogResult]::OK) { $d.SelectedPath }',
1478
- ].join('; ');
1479
- const out = await execFileP('powershell.exe', ['-NoProfile', '-STA', '-Command', script], 55000);
1480
- return out || null;
1684
+ return await pickFolderViaWindowsDialog();
1481
1685
  }
1482
1686
  if (process.platform === 'linux') {
1483
1687
  const out = await execFileP('zenity', ['--file-selection', '--directory', '--title=选择工作区目录'], 55000);
@@ -1531,7 +1735,7 @@ const writeBinary = async (ctx, args) => {
1531
1735
  throw new ToolError('DENIED', `File type "."${ext || '(none)'}" is blocked by safety policy. Use install_app / run_command with approval for executables.`);
1532
1736
  }
1533
1737
  fs.mkdirSync(path.dirname(target), { recursive: true });
1534
- fs.writeFileSync(target, buf);
1738
+ atomicWrite(target, buf, { root: ctx.root });
1535
1739
  return { ok: true, result: { path: rel(realRoot, target), bytes: buf.length, written: true } };
1536
1740
  };
1537
1741
  // --- open_app: 在本机打开网页/应用/文件夹(无副作用,默认浏览器/系统打开)---
@@ -1542,6 +1746,19 @@ const DENIED_APPS = new Set([
1542
1746
  'shutdown', 'sdelete', 'wevtutil', 'schtasks', 'taskkill', 'format', 'fdisk',
1543
1747
  ]);
1544
1748
  const ALLOWED_BIN_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'wav', 'mp3', 'json', 'html', 'htm', 'txt', 'md']);
1749
+ /**
1750
+ * open_app 拒绝直接拉起的可执行/脚本扩展名。
1751
+ *
1752
+ * 为什么需要这一层:open_app 走的是「系统默认动作」,在 Windows 上 `cmd /c start` 一个
1753
+ * .bat/.ps1/.exe 就等于执行它。而它既没有 run_command 的危险命令检测,也不在平台的确认门里,
1754
+ * 于是形成一条绕过所有确认的代码执行路径:先 write_file 落一个脚本,再 open_app 打开它。
1755
+ * 打开文档/图片/网页是「看」,打开脚本/程序是「执行」—— 后者必须走需确认的命令通道。
1756
+ */
1757
+ const DENIED_OPEN_EXT = new Set([
1758
+ 'exe', 'com', 'scr', 'bat', 'cmd', 'ps1', 'psm1', 'vbs', 'vbe', 'js', 'jse', 'wsf', 'wsh',
1759
+ 'msi', 'msp', 'lnk', 'url', 'hta', 'reg', 'jar', 'py', 'pyw', 'sh', 'bash', 'zsh', 'pl', 'rb',
1760
+ 'app', 'dmg', 'pkg', 'deb', 'rpm', 'apk', 'gadget', 'cpl', 'scf', 'inf', 'chm',
1761
+ ]);
1545
1762
  const openApp = async (ctx, args, signal) => {
1546
1763
  abortIfRequested(signal);
1547
1764
  const target = requireString(args, 'target').trim();
@@ -1550,11 +1767,23 @@ const openApp = async (ctx, args, signal) => {
1550
1767
  if (/^(https?:\/\/)/i.test(target)) {
1551
1768
  // 允许:默认浏览器打开
1552
1769
  }
1553
- else if (/^[a-zA-Z]:[\\/]/.test(target) || target.startsWith('/') || target.startsWith('\\\\')) {
1770
+ else if (target.startsWith('\\\\') || target.startsWith('//')) {
1771
+ // UNC 路径:既是「拉起远端可执行文件」,光做存在性探测就会把 NTLM 哈希发给对方主机。
1772
+ throw new ToolError('DENIED', 'UNC/网络路径不允许通过 open_app 打开。');
1773
+ }
1774
+ else if (/^[a-zA-Z]:[\\/]/.test(target) || target.startsWith('/')) {
1554
1775
  if (!fs.existsSync(target))
1555
1776
  throw new ToolError('NOT_FOUND', `Path does not exist: ${target}`);
1556
1777
  if ((0, security_1.isSensitivePath)(target))
1557
1778
  throw new ToolError('SENSITIVE', 'Opening this path is blocked by safety policy.');
1779
+ const ext = path.extname(target).replace(/^\./, '').toLowerCase();
1780
+ if (DENIED_OPEN_EXT.has(ext)) {
1781
+ throw new ToolError('DENIED', `Refusing to launch "${path.basename(target)}": executable/script targets must go through a confirm-required command.`);
1782
+ }
1783
+ const base = path.basename(target).toLowerCase().replace(/\.exe$|\.bat$|\.cmd$|\.ps1$/, '');
1784
+ if (DENIED_APPS.has(base)) {
1785
+ throw new ToolError('DENIED', `"${target}" is blocked by safety policy.`);
1786
+ }
1558
1787
  }
1559
1788
  else {
1560
1789
  const base = path.basename(target).toLowerCase().replace(/\.exe$|\.bat$|\.cmd$|\.ps1$/, '');
@@ -1665,6 +1894,86 @@ const installApp = async (ctx, args, signal, opts) => {
1665
1894
  return { ok: false, error: `install error: ${msg}` };
1666
1895
  }
1667
1896
  };
1897
+ // ---------------------------------------------------------------------------
1898
+ // MCP:每个授权根一个管理器
1899
+ // ---------------------------------------------------------------------------
1900
+ /** MCP server 是按「工作区」配置拉起的,所以管理器也按授权根分桶 —— 切工作区互不干扰。 */
1901
+ const mcpManagers = new Map();
1902
+ function mcpManagerFor(ctx) {
1903
+ const key = (0, path_utils_1.canonicalize)(ctx.root);
1904
+ let m = mcpManagers.get(key);
1905
+ if (!m) {
1906
+ m = new mcp_1.McpManager(key, (msg) => ctx.logger.info(msg));
1907
+ mcpManagers.set(key, m);
1908
+ }
1909
+ return m;
1910
+ }
1911
+ /**
1912
+ * 供 WS 层调用:把当前工作区可用的 MCP 工具清单报给平台,让模型直接看到这些工具。
1913
+ * 失败/未配置都返回空数组(MCP 是可选能力,不该拖垮 Agent 上线)。
1914
+ */
1915
+ async function listMcpTools(root, logger) {
1916
+ if ((0, mcp_1.mcpDisabled)())
1917
+ return [];
1918
+ try {
1919
+ const key = (0, path_utils_1.canonicalize)(root);
1920
+ let m = mcpManagers.get(key);
1921
+ if (!m) {
1922
+ m = new mcp_1.McpManager(key, (msg) => logger?.info(msg));
1923
+ mcpManagers.set(key, m);
1924
+ }
1925
+ return await m.listTools();
1926
+ }
1927
+ catch {
1928
+ return [];
1929
+ }
1930
+ }
1931
+ /** 配置了但启动失败的 MCP server(用于向平台如实说明,而不是让工具静默消失) */
1932
+ function mcpFailuresFor(root) {
1933
+ try {
1934
+ return mcpManagers.get((0, path_utils_1.canonicalize)(root))?.failures() || [];
1935
+ }
1936
+ catch {
1937
+ return [];
1938
+ }
1939
+ }
1940
+ /** 进程退出时收尾:把所有 MCP 子进程杀掉,避免留下孤儿进程 */
1941
+ function shutdownMcp() {
1942
+ for (const [, m] of mcpManagers) {
1943
+ try {
1944
+ m.shutdown();
1945
+ }
1946
+ catch { /* 忽略 */ }
1947
+ }
1948
+ mcpManagers.clear();
1949
+ }
1950
+ /**
1951
+ * mcp_list:列出当前工作区配置的 MCP server 与它们暴露的工具。
1952
+ * 平台会把工具清单合并进模型工具表(正常情况下模型不需要调这个);
1953
+ * 这里保留是为了「平台没同步到」或模型想确认参数时有个兜底。
1954
+ */
1955
+ const mcpList = async (ctx) => {
1956
+ if ((0, mcp_1.mcpDisabled)()) {
1957
+ return { ok: true, result: { enabled: false, count: 0, tools: [], note: 'MCP 已通过环境变量 YSAGC_MCP=0 关闭' } };
1958
+ }
1959
+ const root = (0, path_utils_1.canonicalize)(ctx.root);
1960
+ const m = mcpManagerFor(ctx);
1961
+ const tools = await m.listTools();
1962
+ const failures = m.failures();
1963
+ return {
1964
+ ok: true,
1965
+ result: {
1966
+ enabled: true,
1967
+ config_path: (0, mcp_1.mcpConfigPath)(root),
1968
+ count: tools.length,
1969
+ tools: tools.map((t) => ({ name: t.name, description: t.description })),
1970
+ failures,
1971
+ note: tools.length
1972
+ ? '调用方式:直接用上面列出的工具名(mcp__server__tool)调用即可,参数按各工具的 inputSchema 传。'
1973
+ : `当前工作区没有可用的 MCP 工具。如需接入,编辑 ${(0, mcp_1.mcpConfigPath)(root)} 后重新调用本工具。`,
1974
+ },
1975
+ };
1976
+ };
1668
1977
  const TOOLS = {
1669
1978
  list_dir: listDir,
1670
1979
  fs_drives: fsDrives,
@@ -1689,6 +1998,10 @@ const TOOLS = {
1689
1998
  write_binary: writeBinary,
1690
1999
  open_app: openApp,
1691
2000
  install_app: installApp,
2001
+ screenshot: screenshot_1.screenshotTool,
2002
+ browser: browser_1.browserTool,
2003
+ remember: memory_1.rememberTool,
2004
+ mcp_list: mcpList,
1692
2005
  };
1693
2006
  /** Build a tool registry bound to a single execution context. */
1694
2007
  function createToolRegistry(ctx) {
@@ -1697,12 +2010,16 @@ function createToolRegistry(ctx) {
1697
2010
  ctx,
1698
2011
  tools: names,
1699
2012
  has(name) {
1700
- return Object.prototype.hasOwnProperty.call(TOOLS, name);
2013
+ // MCP 工具名是动态的,静态表里查不到;按前缀放行,真正的可用性由 Manager 判断。
2014
+ return Object.prototype.hasOwnProperty.call(TOOLS, name) || (0, mcp_1.parseMcpToolName)(name) !== null;
1701
2015
  },
1702
- async dispatch(name, args, signal, rootOverride, preApproved) {
2016
+ async dispatch(name, args, signal, rootOverride, preApproved, onProgress, readRootsOverride) {
1703
2017
  const started = Date.now();
1704
2018
  const executor = TOOLS[name];
1705
- if (!executor) {
2019
+ // mcp__<server>__<tool>:MCP 工具是动态的(由工作区配置决定),没法写进静态 TOOLS 表,
2020
+ // 所以在这里按名字前缀分流。走的是同一套权限闸门 / hook / 密钥脱敏。
2021
+ const mcpTarget = (0, mcp_1.parseMcpToolName)(name);
2022
+ if (!executor && !mcpTarget) {
1706
2023
  return { ok: false, error: `Unknown tool: ${name}`, durationMs: 0 };
1707
2024
  }
1708
2025
  // 平台可把“用户所选工作区”作为本次调用的授权根:工作区是用户的明确选择,允许任意绝对路径(不限于启动时的 --root);仍拦截系统/凭证敏感路径,执行边界就是该工作区(safeResolve/assertNotSensitive 保证)。
@@ -1721,6 +2038,21 @@ function createToolRegistry(ctx) {
1721
2038
  /* 无效的 override:沿用原授权根 */
1722
2039
  }
1723
2040
  }
2041
+ // 只读参考根:逐个规范化,无效/敏感的直接丢弃(宁可少一个参考目录,也不要放开边界)
2042
+ if (readRootsOverride && readRootsOverride.length) {
2043
+ const cleaned = [];
2044
+ for (const r of readRootsOverride.slice(0, 10)) {
2045
+ try {
2046
+ const rc = (0, path_utils_1.canonicalize)(r);
2047
+ if (!(0, security_1.isSensitivePath)(rc))
2048
+ cleaned.push(rc);
2049
+ }
2050
+ catch {
2051
+ /* 忽略无效参考根 */
2052
+ }
2053
+ }
2054
+ effCtx = { ...effCtx, readRoots: cleaned };
2055
+ }
1724
2056
  try {
1725
2057
  // Guarded gate: pre-execution check (permission preset, danger
1726
2058
  // commands, canonicalise + sensitive-path). A platform-preApproved
@@ -1741,13 +2073,73 @@ function createToolRegistry(ctx) {
1741
2073
  }
1742
2074
  // A cancel message may arrive while pre-checking; honour it.
1743
2075
  abortIfRequested(signal);
1744
- const outcome = await executor(effCtx, args ?? {}, signal, { preApproved: preApproved === true });
2076
+ // ===== 前置 hook(工作区 .ysagc/hooks.json 里配的)=====
2077
+ // 放在权限检查之后:被安全策略拦下的调用不该触发用户的 hook(否则 hook 会对着
2078
+ // 一次根本没发生的操作跑)。hook 失败**不阻断**本次工具调用 —— 它是辅助动作,
2079
+ // 不能因为 prettier 没装就让「写文件」这件事失败;失败信息会带回给模型。
2080
+ let preHookNote = null;
2081
+ try {
2082
+ const ph = await (0, hooks_1.runPreHooks)(effCtx, name, (args ?? {}));
2083
+ if (ph)
2084
+ preHookNote = ph.ok
2085
+ ? `前置 hook \`${ph.command}\` 执行成功${ph.output ? `:${ph.output}` : ''}`
2086
+ : `前置 hook \`${ph.command}\` 执行失败${ph.output ? `:${ph.output}` : ''}`;
2087
+ }
2088
+ catch { /* hook 机制自身出错不影响工具 */ }
2089
+ const outcome = mcpTarget
2090
+ ? await (async () => {
2091
+ try {
2092
+ // 用 effCtx(平台下发的当前工作区;没有 override 时就是 Agent 授权根):
2093
+ // MCP 配置跟着**工作区**走 —— `.ysagc/mcp.json` 就放在工作区根目录,
2094
+ // mcp_list 与平台侧工具表也都按工作区算。
2095
+ // 这里以前写的是 ctx(Agent 授权根),于是「探测到的工具」和「实际能调到的 server」
2096
+ // 读的不是同一份配置,实测表现是:工具表里有 mcp__testbox__add,
2097
+ // 一调用却回「MCP server "testbox" 未配置或未启动」。
2098
+ const raw = await mcpManagerFor(effCtx).callByName(name, (args ?? {}));
2099
+ const r = (0, mcp_1.renderMcpResult)(raw);
2100
+ return r.ok
2101
+ ? { ok: true, result: { mcp: name, content: r.text } }
2102
+ : { ok: false, error: `MCP 工具 ${name} 返回错误:${r.text}` };
2103
+ }
2104
+ catch (err) {
2105
+ return { ok: false, error: err?.message || String(err) };
2106
+ }
2107
+ })()
2108
+ : await executor(effCtx, args ?? {}, signal, {
2109
+ preApproved: preApproved === true,
2110
+ ...(onProgress ? { onOutput: onProgress } : {}),
2111
+ });
2112
+ // ===== 后置 hook:只在工具**成功**后跑 =====
2113
+ // 工具失败说明工作区可能停在半成品状态,这时再跑格式化只会对着残缺内容动手,
2114
+ // 还可能把失败原因搅乱。所以失败的工具不触发后置 hook。
2115
+ let hookNote = preHookNote;
2116
+ if (outcome.ok) {
2117
+ try {
2118
+ const po = await (0, hooks_1.runPostHooks)(effCtx, name, (args ?? {}));
2119
+ if (po) {
2120
+ const note = po.ok
2121
+ ? `后置 hook \`${po.command}\` 执行成功${po.output ? `:${po.output}` : ''}`
2122
+ : `后置 hook \`${po.command}\` 执行失败${po.output ? `:${po.output}` : ''}`;
2123
+ hookNote = hookNote ? `${hookNote}\n${note}` : note;
2124
+ }
2125
+ }
2126
+ catch { /* 同上 */ }
2127
+ }
2128
+ // 把 hook 结果并进 result:模型和用户都能看到「自动跑了什么、成没成」
2129
+ const withHook = hookNote
2130
+ ? {
2131
+ ...outcome,
2132
+ result: (outcome.result && typeof outcome.result === 'object')
2133
+ ? { ...outcome.result, hook: hookNote }
2134
+ : { hook: hookNote },
2135
+ }
2136
+ : outcome;
1745
2137
  const result = {
1746
- ok: outcome.ok,
1747
- result: outcome.result,
1748
- error: outcome.error,
2138
+ ok: withHook.ok,
2139
+ result: withHook.result,
2140
+ error: withHook.error,
1749
2141
  durationMs: Date.now() - started,
1750
- ...(outcome.needsApproval ? { needsApproval: true } : {}),
2142
+ ...(withHook.needsApproval ? { needsApproval: true } : {}),
1751
2143
  };
1752
2144
  // Guarded gate: post-execution check (mask secrets + record reason).
1753
2145
  const post = postExecute(ctx, name, result);