c0de-agent 1.13.0 → 1.14.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 (155) hide show
  1. package/dist/cli/commands/serve.js +5 -1
  2. package/dist/cli/deps.d.ts +2 -4
  3. package/dist/cli/deps.js +22 -36
  4. package/dist/cli/modes/print.js +27 -17
  5. package/dist/core/agent.js +59 -36
  6. package/dist/core/agents/parallel.js +4 -2
  7. package/dist/core/config-path.d.ts +3 -1
  8. package/dist/core/config-path.js +33 -3
  9. package/dist/core/config.js +81 -0
  10. package/dist/core/context.js +6 -3
  11. package/dist/core/loop/persist.js +4 -1
  12. package/dist/core/loop/subagent.d.ts +19 -0
  13. package/dist/core/loop/subagent.js +73 -7
  14. package/dist/core/loop.d.ts +4 -1
  15. package/dist/core/loop.js +22 -6
  16. package/dist/{server/registry-config.d.ts → core/provider-registry.d.ts} +10 -2
  17. package/dist/core/provider-registry.js +66 -0
  18. package/dist/core/redact.d.ts +2 -1
  19. package/dist/core/redact.js +4 -2
  20. package/dist/core/secret.d.ts +5 -0
  21. package/dist/core/secret.js +14 -0
  22. package/dist/core/skills.d.ts +2 -0
  23. package/dist/core/skills.js +61 -0
  24. package/dist/core/slash.js +31 -18
  25. package/dist/core/title.js +4 -2
  26. package/dist/core/todo-tags.d.ts +4 -3
  27. package/dist/core/todo-tags.js +20 -6
  28. package/dist/core/workflow.d.ts +4 -13
  29. package/dist/core/workflow.js +6 -6
  30. package/dist/core/workflows/context.js +31 -59
  31. package/dist/core/workflows/runtime.js +10 -8
  32. package/dist/dap/index.d.ts +1 -0
  33. package/dist/dap/index.js +1 -0
  34. package/dist/dap/protocol.d.ts +8 -3
  35. package/dist/dap/protocol.js +54 -18
  36. package/dist/dap/session.js +71 -12
  37. package/dist/dap/spawn.d.ts +11 -0
  38. package/dist/dap/spawn.js +48 -0
  39. package/dist/dap/transport.js +18 -0
  40. package/dist/kanban/index.d.ts +1 -1
  41. package/dist/kanban/index.js +1 -1
  42. package/dist/kanban/store.d.ts +8 -1
  43. package/dist/kanban/store.js +61 -13
  44. package/dist/llm/protocols/openai-compat.d.ts +7 -0
  45. package/dist/llm/protocols/openai-compat.js +19 -2
  46. package/dist/llm/protocols/utils/tool-stream.d.ts +12 -3
  47. package/dist/llm/protocols/utils/tool-stream.js +47 -23
  48. package/dist/llm/provider.js +18 -3
  49. package/dist/llm/retry.d.ts +1 -0
  50. package/dist/llm/retry.js +20 -7
  51. package/dist/llm/routing.js +14 -2
  52. package/dist/llm/schema/messages.d.ts +9 -2
  53. package/dist/llm/token.d.ts +9 -0
  54. package/dist/llm/token.js +10 -6
  55. package/dist/llm/transport.js +7 -2
  56. package/dist/mcp/client.d.ts +11 -0
  57. package/dist/mcp/client.js +70 -0
  58. package/dist/mcp/index.d.ts +46 -0
  59. package/dist/mcp/index.js +106 -0
  60. package/dist/mcp/tool-adapter.d.ts +14 -0
  61. package/dist/mcp/tool-adapter.js +65 -0
  62. package/dist/mcp/transport.d.ts +35 -0
  63. package/dist/mcp/transport.js +243 -0
  64. package/dist/mcp/types.d.ts +55 -0
  65. package/dist/mcp/types.js +3 -0
  66. package/dist/plugins/init.d.ts +2 -0
  67. package/dist/plugins/init.js +10 -1
  68. package/dist/project/project.js +50 -3
  69. package/dist/project/resolve.d.ts +7 -0
  70. package/dist/project/resolve.js +52 -5
  71. package/dist/server/permission/interactive.js +9 -4
  72. package/dist/server/permission/store.js +8 -2
  73. package/dist/server/routes/auth.js +28 -13
  74. package/dist/server/routes/chat.js +92 -26
  75. package/dist/server/routes/config.js +38 -10
  76. package/dist/server/routes/files.js +75 -16
  77. package/dist/server/routes/filesystem.js +9 -2
  78. package/dist/server/routes/kanban.js +146 -9
  79. package/dist/server/routes/permissions.js +32 -0
  80. package/dist/server/routes/project.js +15 -6
  81. package/dist/server/routes/provider.js +10 -5
  82. package/dist/server/routes/session.js +72 -26
  83. package/dist/server/routes/terminal.js +10 -2
  84. package/dist/server/routes/todo.js +18 -0
  85. package/dist/server/routes/tool.js +13 -2
  86. package/dist/server/routes/update.js +5 -1
  87. package/dist/server/routes/workflows.js +17 -6
  88. package/dist/server/server.d.ts +1 -1
  89. package/dist/server/server.js +31 -17
  90. package/dist/server/terminal/pty-manager.d.ts +3 -0
  91. package/dist/server/terminal/pty-manager.js +8 -3
  92. package/dist/server/types.d.ts +2 -0
  93. package/dist/server/util/json-body.d.ts +31 -0
  94. package/dist/server/util/json-body.js +38 -0
  95. package/dist/session/archive.js +4 -4
  96. package/dist/session/branch.d.ts +10 -1
  97. package/dist/session/branch.js +54 -9
  98. package/dist/session/compaction.d.ts +5 -0
  99. package/dist/session/compaction.js +15 -10
  100. package/dist/session/context.d.ts +7 -3
  101. package/dist/session/context.js +35 -9
  102. package/dist/session/import.js +53 -6
  103. package/dist/session/index.d.ts +2 -2
  104. package/dist/session/index.js +2 -2
  105. package/dist/session/message.d.ts +12 -3
  106. package/dist/session/message.js +44 -4
  107. package/dist/session/session.d.ts +11 -5
  108. package/dist/session/session.js +99 -88
  109. package/dist/session/shake.js +35 -25
  110. package/dist/session/squash.js +3 -4
  111. package/dist/session/token.d.ts +7 -8
  112. package/dist/session/token.js +19 -16
  113. package/dist/session/usage.js +4 -2
  114. package/dist/shared/index.d.ts +1 -0
  115. package/dist/shared/index.js +1 -0
  116. package/dist/shared/round.d.ts +12 -0
  117. package/dist/shared/round.js +18 -0
  118. package/dist/shared/timer.d.ts +23 -0
  119. package/dist/shared/timer.js +27 -0
  120. package/dist/shared/types/tool.d.ts +4 -0
  121. package/dist/shared/utils/markdown-code.d.ts +15 -0
  122. package/dist/shared/utils/markdown-code.js +44 -0
  123. package/dist/shared/utils/path.d.ts +4 -1
  124. package/dist/shared/utils/path.js +5 -2
  125. package/dist/shared/utils/string.d.ts +5 -0
  126. package/dist/shared/utils/string.js +52 -0
  127. package/dist/tools/builtin/bash.js +79 -10
  128. package/dist/tools/builtin/edit.js +67 -8
  129. package/dist/tools/builtin/glob.js +150 -14
  130. package/dist/tools/builtin/grep.js +53 -11
  131. package/dist/tools/builtin/kanban.d.ts +14 -0
  132. package/dist/tools/builtin/kanban.js +4 -1
  133. package/dist/tools/builtin/read.js +15 -2
  134. package/dist/tools/builtin/resolvers.d.ts +9 -2
  135. package/dist/tools/builtin/resolvers.js +20 -6
  136. package/dist/tools/builtin/task.js +107 -25
  137. package/dist/tools/builtin/todo.js +84 -9
  138. package/dist/tools/builtin/write.js +7 -1
  139. package/dist/tools/builtin/yield.js +9 -1
  140. package/dist/tools/executor.js +53 -1
  141. package/dist/tools/hashline/patch.js +114 -38
  142. package/dist/tools/truncate.js +45 -16
  143. package/dist/tools/validate.js +66 -20
  144. package/dist/tools/websearch/index.js +8 -4
  145. package/dist/update/index.d.ts +1 -1
  146. package/dist/update/index.js +1 -1
  147. package/dist/update/scheduler.js +9 -2
  148. package/dist/update/snapshot.d.ts +14 -4
  149. package/dist/update/snapshot.js +22 -22
  150. package/dist/update/updater.d.ts +16 -2
  151. package/dist/update/updater.js +39 -8
  152. package/dist/update/version.d.ts +3 -1
  153. package/dist/update/version.js +63 -9
  154. package/package.json +1 -1
  155. package/dist/server/registry-config.js +0 -44
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Markdown 代码剥离:围栏代码块与行内代码一律移除,供「只该命中人类文字」的
3
+ * prose 感知检测共用(workflowz 关键词、<todo:*> 标签、composer 的 @agent
4
+ * 提及提取等)。
5
+ *
6
+ * 此前各检测点自带 ad-hoc 剥离(```...``` 非贪婪配对 + `[^`]*` 单反引号)——
7
+ * 未闭合围栏、~~~ 围栏、长反引号围栏、多反引号行内代码全部漏网:代码内容上的
8
+ * 关键词/标签/提及被误触发(工作流通告注入 / todo 状态幽灵变更 / 误派发子 agent)。
9
+ *
10
+ * 语义(保守方向——宁可多剥,不可在代码上误判):
11
+ * - 围栏开启 = 行首(≤3 空格缩进)+ ≥3 个同字符反引号/波浪线;
12
+ * 闭合 = 同字符、长度 ≥ 开启、行内仅剩空白;未闭合围栏吞掉余文。
13
+ * - 行内代码 = 等长反引号对(``x`` 内可含单反引号,CommonMark 口径)。
14
+ */
15
+ export function stripMarkdownCode(text) {
16
+ const lines = text.split('\n');
17
+ const out = [];
18
+ let fence = null;
19
+ for (const raw of lines) {
20
+ // CRLF:行尾 \r 不参与围栏判定,也不进入输出
21
+ const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
22
+ const m = /^ {0,3}(`{3,}|~{3,})/.exec(line);
23
+ if (!fence) {
24
+ if (m?.[1]) {
25
+ const run = m[1];
26
+ fence = { char: run[0] ?? '`', len: run.length };
27
+ }
28
+ else {
29
+ out.push(line);
30
+ }
31
+ continue;
32
+ }
33
+ // 围栏内:仅「同字符、长度 ≥ 开启、行内无其他字符」的行闭合围栏
34
+ const run = m?.[1];
35
+ if (run &&
36
+ run[0] === fence.char &&
37
+ run.length >= fence.len &&
38
+ line.slice(m[0].length).trim() === '') {
39
+ fence = null;
40
+ }
41
+ // 围栏内其余行(含闭合行)一律不输出
42
+ }
43
+ return out.join('\n').replace(/(`+)([\s\S]*?)\1/g, '');
44
+ }
@@ -3,8 +3,11 @@
3
3
  * (如 `../etc/passwd`、绝对路径 `/etc/passwd`)。
4
4
  *
5
5
  * - `resolve(root, requestPath)` 解析后,若结果不在 root 子树内则返回 null。
6
- * - 判定逻辑:`relative(root, resolved)` 以 `..` 开头即越界;
6
+ * - 判定逻辑:`relative(root, resolved)` 为 `..` 或以 `..` 开头的**路径段**即越界;
7
7
  * 再用 `resolve(root, rel) === resolved` 兜底,防止相对段恰好名为 root 的边角情况。
8
+ * 越界判定必须按组件边界比较(`..` 整段 / `..<sep>` 前缀)——按字符串前缀
9
+ * `rel.startsWith('..')` 会把以 `..` 开头的**合法文件名**(..foo、...、..bar/x)
10
+ * 一并拒绝:工作区内的真实文件/目录对工具与 /api/files 全部 403。
8
11
  * - symlink 防逃逸(P2-5):root 与目标(或其最近已存在祖先)经 realpath 解析后,
9
12
  * 目标必须仍落在 root 实路径内;否则返回 null。
10
13
  *
@@ -5,8 +5,11 @@ import { dirname, relative, resolve, sep } from 'node:path';
5
5
  * (如 `../etc/passwd`、绝对路径 `/etc/passwd`)。
6
6
  *
7
7
  * - `resolve(root, requestPath)` 解析后,若结果不在 root 子树内则返回 null。
8
- * - 判定逻辑:`relative(root, resolved)` 以 `..` 开头即越界;
8
+ * - 判定逻辑:`relative(root, resolved)` 为 `..` 或以 `..` 开头的**路径段**即越界;
9
9
  * 再用 `resolve(root, rel) === resolved` 兜底,防止相对段恰好名为 root 的边角情况。
10
+ * 越界判定必须按组件边界比较(`..` 整段 / `..<sep>` 前缀)——按字符串前缀
11
+ * `rel.startsWith('..')` 会把以 `..` 开头的**合法文件名**(..foo、...、..bar/x)
12
+ * 一并拒绝:工作区内的真实文件/目录对工具与 /api/files 全部 403。
10
13
  * - symlink 防逃逸(P2-5):root 与目标(或其最近已存在祖先)经 realpath 解析后,
11
14
  * 目标必须仍落在 root 实路径内;否则返回 null。
12
15
  *
@@ -15,7 +18,7 @@ import { dirname, relative, resolve, sep } from 'node:path';
15
18
  function safeResolve(root, requestPath) {
16
19
  const resolved = resolve(root, requestPath);
17
20
  const rel = relative(root, resolved);
18
- if (rel.startsWith('..') || resolve(root, rel) !== resolved) {
21
+ if (rel === '..' || rel.startsWith(`..${sep}`) || resolve(root, rel) !== resolved) {
19
22
  return null;
20
23
  }
21
24
  try {
@@ -0,0 +1,5 @@
1
+ /** 取至多 n 字符的前缀;切点绝不在代理对中间。n ≥ 文本长度时原样返回。 */
2
+ export declare function headChars(text: string, n: number): string;
3
+ /** 取至多 n 字符的后缀;切点绝不在代理对中间。n ≥ 文本长度时原样返回。 */
4
+ export declare function tailChars(text: string, n: number): string;
5
+ export declare function isUuid(value: string): boolean;
@@ -0,0 +1,52 @@
1
+ // 代理对安全的字符截断工具。
2
+ //
3
+ // UTF-16 下 emoji 等 astral 字符占两个码元(高代理 + 低代理)。直接 slice 会把
4
+ // 代理对切成两半,产出孤立代理码元——经 JSON 序列化/UTF-8 编码往返后损坏为
5
+ // U+FFFD(输出内容被静默改写)。所有「按字符数截断」的调用点都应经本模块
6
+ // 把切点内收到安全边界。
7
+ const isHighSurrogate = (c) => c >= 0xd800 && c <= 0xdbff;
8
+ const isLowSurrogate = (c) => c >= 0xdc00 && c <= 0xdfff;
9
+ /** 切点把代理对拆开时向左内收(丢弃悬空高代理码元)。 */
10
+ function safeHeadEnd(text, end) {
11
+ while (end > 0 &&
12
+ end < text.length &&
13
+ isHighSurrogate(text.charCodeAt(end - 1)) &&
14
+ isLowSurrogate(text.charCodeAt(end))) {
15
+ end--;
16
+ }
17
+ return end;
18
+ }
19
+ /** 切点把代理对拆开时向右内收(跳过悬空低代理码元)。 */
20
+ function safeTailStart(text, start) {
21
+ while (start > 0 &&
22
+ start < text.length &&
23
+ isHighSurrogate(text.charCodeAt(start - 1)) &&
24
+ isLowSurrogate(text.charCodeAt(start))) {
25
+ start++;
26
+ }
27
+ return start;
28
+ }
29
+ /** 取至多 n 字符的前缀;切点绝不在代理对中间。n ≥ 文本长度时原样返回。 */
30
+ export function headChars(text, n) {
31
+ if (n <= 0)
32
+ return '';
33
+ if (n >= text.length)
34
+ return text;
35
+ return text.slice(0, safeHeadEnd(text, n));
36
+ }
37
+ /** 取至多 n 字符的后缀;切点绝不在代理对中间。n ≥ 文本长度时原样返回。 */
38
+ export function tailChars(text, n) {
39
+ if (n <= 0)
40
+ return '';
41
+ if (n >= text.length)
42
+ return text;
43
+ return text.slice(safeTailStart(text, text.length - n));
44
+ }
45
+ /** UUID(任意版本/大小写)格式判定。
46
+ * 用于「路径/参数里的 id 直接进 PG uuid 列」的入口预检:非 UUID 经 drizzle
47
+ * 参数化查询会在 PG 侧抛 22P02(invalid input syntax for type uuid),被 Hono
48
+ * 兜成 500 并把 SQL 错误细节回给客户端——入口显式判非法(404)即可避免。 */
49
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
50
+ export function isUuid(value) {
51
+ return UUID_RE.test(value);
52
+ }
@@ -1,7 +1,14 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { resolve } from 'node:path';
2
+ import { MAX_TIMER_MS } from '../../shared/timer.js';
3
+ import { safeResolve } from '../../shared/utils/path.js';
4
+ import { headChars } from '../../shared/utils/string.js';
3
5
  /** Default timeout: 120 seconds. */
4
6
  const DEFAULT_TIMEOUT = 120_000;
7
+ /** stdout/stderr 各自捕获上限(字符):命令输出在此之上的部分被丢弃并计数。
8
+ * 截断发生在命令运行期而非 close 之后——executor 的 truncateOutput 只能在
9
+ * close 后裁剪,救不了累积期内存膨胀(`yes`/`find /` 几秒即可输出数 GB)。
10
+ * 256KB 留足 executor 的 head+tail 截断预算(默认 maxChars 100k)。 */
11
+ const MAX_OUTPUT_CHARS = 256 * 1024;
5
12
  /** Kill an entire process tree (the child and all its descendants). */
6
13
  function killProcessTree(child) {
7
14
  try {
@@ -33,7 +40,7 @@ export const bashTool = {
33
40
  properties: {
34
41
  command: { type: 'string', description: 'Shell command to execute.' },
35
42
  cwd: { type: 'string', description: 'Working directory (default: ctx.cwd).' },
36
- timeout: { type: 'number', description: 'Timeout in milliseconds (default: 120000).' },
43
+ timeout: { type: 'integer', description: 'Timeout in milliseconds (default: 120000).' },
37
44
  env: {
38
45
  type: 'object',
39
46
  description: 'Additional environment variables.',
@@ -45,7 +52,28 @@ export const bashTool = {
45
52
  permission: 'ask',
46
53
  execute: async (input, ctx) => {
47
54
  const { command, cwd, timeout = DEFAULT_TIMEOUT, env } = input;
48
- const workDir = cwd ? resolve(ctx.cwd, cwd) : ctx.cwd;
55
+ // setTimeout 把 0/负数/Infinity(1e999)/NaN 全部钳到 ~1ms——命令刚 spawn
56
+ // 即被杀,模型收到「timeout after Infinityms」这类误导性错误,无法区分
57
+ // 「自己传了非法值」与「命令真超时」。显式报错供自纠。
58
+ // 同理,>2^31-1 的超大值(如 2^31)会被引擎钳到 1ms(TimeoutOverflowWarning)
59
+ // 而非「超长等待」——同样显式拒绝,上限即 Node timer 的 32 位安全上限。
60
+ if (!Number.isInteger(timeout) || timeout < 1 || timeout > MAX_TIMER_MS) {
61
+ return {
62
+ _tag: 'error',
63
+ error: `bash: timeout must be a positive integer (milliseconds, max ${MAX_TIMER_MS}), got ${timeout}`,
64
+ };
65
+ }
66
+ // 与 read/write/edit/glob/grep 同口径:cwd 必须落在工作目录内。此前直接
67
+ // resolve(ctx.cwd, cwd) 放行绝对路径/../——bash 是任意命令执行面,越界即
68
+ // 在工作目录外执行(如读取 ~/.ssh),且用户偏好可将 bash 设为 auto。
69
+ let workDir = ctx.cwd;
70
+ if (cwd) {
71
+ const resolved = safeResolve(ctx.cwd, cwd);
72
+ if (resolved === null) {
73
+ return { _tag: 'error', error: `cwd "${cwd}" escapes the working directory` };
74
+ }
75
+ workDir = resolved;
76
+ }
49
77
  return new Promise((resolvePromise) => {
50
78
  const childEnv = { ...process.env, ...env };
51
79
  const child = spawn(command, {
@@ -57,12 +85,37 @@ export const bashTool = {
57
85
  });
58
86
  let stdout = '';
59
87
  let stderr = '';
88
+ let droppedChars = 0;
60
89
  let timedOut = false;
90
+ /** 追加捕获并执行字符上限:超限部分丢弃计数,绝不无界累积。
91
+ * 切点经 headChars 内收,不产出孤立代理码元。 */
92
+ const appendCapped = (target, s) => {
93
+ const current = target === 'stdout' ? stdout : stderr;
94
+ if (current.length >= MAX_OUTPUT_CHARS) {
95
+ droppedChars += s.length;
96
+ return;
97
+ }
98
+ const next = current + s;
99
+ if (next.length > MAX_OUTPUT_CHARS) {
100
+ droppedChars += next.length - MAX_OUTPUT_CHARS;
101
+ }
102
+ if (target === 'stdout') {
103
+ stdout = headChars(next, MAX_OUTPUT_CHARS);
104
+ }
105
+ else {
106
+ stderr = headChars(next, MAX_OUTPUT_CHARS);
107
+ }
108
+ };
109
+ // setEncoding('utf8') 让流内 StringDecoder 缓冲跨 chunk 的多字节序列:
110
+ // 此前按 chunk 逐个 Buffer.toString() 解码,UTF-8 序列被拆成两片时产出
111
+ // U+FFFD(如中文/emoji 输出静默损坏)。与 MCP/DAP transport 同口径。
112
+ child.stdout?.setEncoding('utf8');
113
+ child.stderr?.setEncoding('utf8');
61
114
  child.stdout?.on('data', (data) => {
62
- stdout += data.toString();
115
+ appendCapped('stdout', data);
63
116
  });
64
117
  child.stderr?.on('data', (data) => {
65
- stderr += data.toString();
118
+ appendCapped('stderr', data);
66
119
  });
67
120
  // Timeout handler
68
121
  const timer = setTimeout(() => {
@@ -80,7 +133,7 @@ export const bashTool = {
80
133
  return;
81
134
  }
82
135
  ctx.abort.addEventListener('abort', onAbort, { once: true });
83
- child.on('close', (code) => {
136
+ child.on('close', (code, signal) => {
84
137
  clearTimeout(timer);
85
138
  ctx.abort.removeEventListener('abort', onAbort);
86
139
  if (ctx.abort.aborted) {
@@ -90,22 +143,38 @@ export const bashTool = {
90
143
  if (timedOut) {
91
144
  resolvePromise({
92
145
  _tag: 'error',
93
- error: `Command timeout after ${timeout}ms\nPartial output:\n${stdout}${stderr}`,
146
+ error: `Command timeout after ${timeout}ms\nPartial output:\n${stdout}${stderr}${droppedChars > 0 ? `\n[... ${droppedChars} chars of output dropped ...]` : ''}`,
94
147
  });
95
148
  return;
96
149
  }
97
150
  const output = stdout + (stderr ? `\n${stderr}` : '');
151
+ // 信号终止(code=null + signal 携带真实信号):OOM killer / cgroup 限额 /
152
+ // 外部 kill / 命令自杀都走这里。此前只看 code——null 落进成功分支,工具
153
+ // 回 success 且 metadata.exitCode = code ?? 0 = 0,模型据此以为命令已跑完
154
+ //(如构建被 SIGKILL 后继续用半成品产物)。
155
+ if (signal !== null) {
156
+ resolvePromise({
157
+ _tag: 'error',
158
+ error: `Command killed by signal ${signal}\n${output}${droppedChars > 0 ? `\n[... ${droppedChars} chars of output dropped ...]` : ''}`,
159
+ });
160
+ return;
161
+ }
98
162
  if (code !== null && code !== 0) {
99
163
  resolvePromise({
100
164
  _tag: 'error',
101
- error: `Command failed with exit code: ${code}\n${output}`,
165
+ error: `Command failed with exit code: ${code}\n${output}${droppedChars > 0 ? `\n[... ${droppedChars} chars of output dropped ...]` : ''}`,
102
166
  });
103
167
  return;
104
168
  }
105
169
  resolvePromise({
106
170
  _tag: 'success',
107
- output: output || '(no output)',
108
- metadata: { exitCode: code ?? 0 },
171
+ output: output +
172
+ (droppedChars > 0 ? `\n[... ${droppedChars} chars of output dropped ...]` : '') ||
173
+ '(no output)',
174
+ metadata: {
175
+ exitCode: code ?? 0,
176
+ ...(droppedChars > 0 ? { droppedOutputChars: droppedChars } : {}),
177
+ },
109
178
  });
110
179
  });
111
180
  child.on('error', (err) => {
@@ -70,9 +70,17 @@ export const editTool = {
70
70
  const newText = raw.newText;
71
71
  const content = await readFile(fullPath, 'utf-8');
72
72
  // Fuzzy whitespace matching: normalize whitespace runs
73
- const normalize = (s) => s.replace(/[ \t]+/g, ' ');
73
+ // CRLF 行尾一并折叠:\r 对模型不可见,模型生成的 oldText 恒用 \n——
74
+ // 不折叠则 \n 版 oldText 在 CRLF 文件上恒不匹配(「oldText not found」)。
75
+ // 折叠后的位置经 buildPositionMapping 映射回原文(\r 按「原文多余空白」跳过)。
76
+ const normalize = (s) => s.replace(/\r\n/g, '\n').replace(/[ \t]+/g, ' ');
74
77
  const normalizedContent = normalize(content);
75
78
  const normalizedOld = normalize(oldText);
79
+ // 空 oldText 会让 indexOf('') 恒命中同一位置、searchFrom 永不前进——
80
+ // matches 推到 2^32 溢出(RangeError)前烧数秒 CPU 且报错无法指导模型。
81
+ if (normalizedOld.length === 0) {
82
+ return { _tag: 'error', error: `edit: oldText must not be empty for "${path}"` };
83
+ }
76
84
  // Find all match positions
77
85
  const matches = [];
78
86
  let searchFrom = 0;
@@ -99,7 +107,30 @@ export const editTool = {
99
107
  const mapping = buildPositionMapping(content, normalizedContent);
100
108
  const origStart = mapping.get(charCount) ?? charCount;
101
109
  const origEnd = mapping.get(charCount + normalizedOld.length) ?? charCount + normalizedOld.length;
102
- const newContent = content.slice(0, origStart) + newText + content.slice(origEnd);
110
+ const newContent = (() => {
111
+ // 映射起点可能落在 CRLF 对中间:区域以 \n 开头时(normalize 把 \r\n 折成
112
+ // \n,映射把原文的 \r 当作「多余空白」跳过),前一行的 \r 留在区域外,
113
+ // 而 replacement 自带 \r\n → 拼成「\r\r\n」(孤立回车 + 原行尾被吃);
114
+ // replacement 不含换行时 \r 更会留在行中("line1\rX")。与末尾守卫对称:
115
+ // 起点落在 CRLF 对中间时把 \r 纳入区域。
116
+ let start = origStart;
117
+ if (start > 0 && content[start - 1] === '\r' && content[start] === '\n')
118
+ start--;
119
+ // 映射终点可能落在 CRLF 中间(region 吞入 \r,行尾 \n 留在区域外):
120
+ // 把 \r 让回外部行尾,否则单行替换会产出「替换文本\n」丢失 \r 的混合行尾。
121
+ let end = origEnd;
122
+ if (end < content.length && content[end - 1] === '\r' && content[end] === '\n')
123
+ end--;
124
+ // 行尾口径按**文件主导行尾**判定(与 hashline 模式/grep/shake/workflows 同口径)。
125
+ // 只看被替换区域是否含 CRLF 会在「单行区域 + 多行 newText」漏判——区域本身
126
+ // 不含换行,newText 的 \n 原样写入 CRLF 文件,产出混合行尾(git diff 全行噪音)。
127
+ // LF 文件同理:newText 里的 \r\n(模型粘贴 Windows 文本)此前原样写入,
128
+ // 与其余行混成混合行尾——两个方向都要归一(与 hashline 的同型修复一致)。
129
+ const replacement = content.includes('\r\n')
130
+ ? newText.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n')
131
+ : newText.replace(/\r\n/g, '\n');
132
+ return content.slice(0, start) + replacement + content.slice(end);
133
+ })();
103
134
  await writeFile(fullPath, newContent, 'utf-8');
104
135
  return {
105
136
  _tag: 'success',
@@ -124,6 +155,17 @@ function formatHashlineError(path, result) {
124
155
  /**
125
156
  * Build a mapping from normalized string positions to original string positions.
126
157
  * Used to map fuzzy match results back to the original content.
158
+ *
159
+ * normalize 的两条折叠规则各自需要**专门**的推进分支(此前只有一个「原文是空白
160
+ * 就只推进原文指针」的分支,掩盖了两条规则的区别):
161
+ * - `\r\n` → `\n`:原文的 \r 不产出任何 normalized 字符,只推进原文指针;
162
+ * - `[ \t]+` → `' '`:原文整个空白 run 只产出**一个**空格,必须连同 normalized
163
+ * 侧的那个空格一起消费。
164
+ * 缺第二个分支时,run 首字符是空格(与 normalized 的空格相等,走对齐分支)、
165
+ * 其余空格走「只推进原文」分支,恰好正确;而 run 首字符是 tab 时首个不对齐处
166
+ * 落进「理论不可达」的兜底分支(两侧同时推进),此后整串错位一格——匹配区终点
167
+ * 落到目标之后,替换连紧随的换行与下一行首字符一起吃掉(静默源码损坏,工具
168
+ * 仍报 success)。
127
169
  */
128
170
  function buildPositionMapping(original, normalized) {
129
171
  const map = new Map();
@@ -131,19 +173,36 @@ function buildPositionMapping(original, normalized) {
131
173
  let normIdx = 0;
132
174
  while (origIdx < original.length && normIdx < normalized.length) {
133
175
  map.set(normIdx, origIdx);
134
- if (original[origIdx] === normalized[normIdx]) {
176
+ const origChar = original[origIdx];
177
+ const normChar = normalized[normIdx];
178
+ if (origChar === normChar) {
135
179
  origIdx++;
136
180
  normIdx++;
181
+ continue;
137
182
  }
138
- else if (original[origIdx] === ' ' || original[origIdx] === '\t') {
139
- // Original has whitespace that was collapsed
183
+ // \r:CRLF 行尾在 normalize 中被折叠为 \n——映射时原文的 \r 是
184
+ // 「原文多余的空白」,仅前进原文指针(normIdx 停住等 \n 对齐)。
185
+ if (origChar === '\r') {
140
186
  origIdx++;
187
+ continue;
141
188
  }
142
- else {
143
- // Shouldn't happen with proper normalization
144
- origIdx++;
189
+ // 空白 run 折叠为单个空格:吃掉整个 run,同时消费 normalized 侧的那个空格。
190
+ if (normChar === ' ' && (origChar === ' ' || origChar === '\t')) {
191
+ while (origIdx < original.length &&
192
+ (original[origIdx] === ' ' || original[origIdx] === '\t')) {
193
+ origIdx++;
194
+ }
145
195
  normIdx++;
196
+ continue;
197
+ }
198
+ // run 的后续空白(normalized 侧无对应字符):只推进原文指针。
199
+ if (origChar === ' ' || origChar === '\t') {
200
+ origIdx++;
201
+ continue;
146
202
  }
203
+ // 理论上不可达(normalize 后两侧应逐字符对齐):保守同步推进,绝不抛错。
204
+ origIdx++;
205
+ normIdx++;
147
206
  }
148
207
  // Map the end position
149
208
  map.set(normIdx, origIdx);
@@ -1,5 +1,6 @@
1
1
  import { readdir } from 'node:fs/promises';
2
- import { join, relative, resolve } from 'node:path';
2
+ import { join, relative } from 'node:path';
3
+ import { safeResolve } from '../../shared/utils/path.js';
3
4
  /** Directories always skipped during glob traversal. */
4
5
  const IGNORE_DIRS = new Set(['node_modules', '.git', 'dist', '.next', 'build', '.turbo']);
5
6
  /**
@@ -7,16 +8,101 @@ const IGNORE_DIRS = new Set(['node_modules', '.git', 'dist', '.next', 'build', '
7
8
  * Supports: * (single segment), ** (across segments), ? (single char), {a,b} (alternation), [abc] (char class).
8
9
  */
9
10
  export function globToRegex(pattern) {
11
+ return new RegExp(`^${globFragment(pattern)}$`);
12
+ }
13
+ /** 把字面量文本转义为正则原文(用于降级路径:非法字符类/含空分支的花括号)。 */
14
+ function escapeRegex(s) {
15
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
16
+ }
17
+ /** 找到与 pattern[start]('{')配对的 '}',计嵌套深度;未配对返回 -1。
18
+ * 此前用 indexOf('}') 只取第一个——{a,{b,c}} 被截到内层的 },内层分支降级为
19
+ * 字面量,b/c 静默零命中。 */
20
+ function findBraceEnd(pattern, start) {
21
+ let depth = 0;
22
+ for (let i = start; i < pattern.length; i++) {
23
+ const c = pattern[i];
24
+ if (c === '{')
25
+ depth++;
26
+ else if (c === '}') {
27
+ depth--;
28
+ if (depth === 0)
29
+ return i;
30
+ }
31
+ }
32
+ return -1;
33
+ }
34
+ /** 按顶层逗号切分花括号内容:嵌套花括号与字符类内的逗号属于内层,不参与切分。
35
+ * 字符类按 []a] 口径跳过类首字面量 ](与 globFragment 的类解析同源)。 */
36
+ function splitBraceAlternatives(inner) {
37
+ const alts = [];
38
+ let depth = 0;
39
+ let inClass = false;
40
+ let current = '';
41
+ for (let i = 0; i < inner.length; i++) {
42
+ const c = inner[i] ?? '';
43
+ if (inClass) {
44
+ if (c === ']')
45
+ inClass = false;
46
+ current += c;
47
+ continue;
48
+ }
49
+ if (c === '[') {
50
+ inClass = true;
51
+ current += c;
52
+ if (inner[i + 1] === ']') {
53
+ current += ']';
54
+ i++;
55
+ }
56
+ continue;
57
+ }
58
+ if (c === '{')
59
+ depth++;
60
+ else if (c === '}')
61
+ depth--;
62
+ if (c === ',' && depth === 0) {
63
+ alts.push(current);
64
+ current = '';
65
+ continue;
66
+ }
67
+ current += c;
68
+ }
69
+ alts.push(current);
70
+ return alts;
71
+ }
72
+ /** Convert a glob fragment (may appear inside brace alternation) to regex source.
73
+ * Recursive so wildcards inside {a,b} stay wildcards instead of being escaped
74
+ * as literals(此前 {*.spec.ts,*.test.ts} 恒不匹配任何文件)。
75
+ * segStartAt0:fragment 起始位置是否算「段起点」——顶层模式为真;花括号分支
76
+ * 按分支前一个字符是否为 / 传入({/**,x} 中的 ** 才是完整段)。 */
77
+ function globFragment(pattern, segStartAt0 = true) {
10
78
  let re = '';
11
79
  let i = 0;
12
80
  while (i < pattern.length) {
13
81
  const c = pattern[i] ?? '';
14
82
  if (c === '*') {
15
83
  if (pattern[i + 1] === '*') {
16
- re += '.*';
17
- i += 2;
18
- if (pattern[i] === '/')
19
- i++; // skip separator after **
84
+ const prev = pattern[i - 1];
85
+ const next = pattern[i + 2];
86
+ // 双星只在「完整段」(前后均无普通字符)时才有跨目录语义(bash globstar)。
87
+ const segStart = i === 0 ? segStartAt0 : prev === '/';
88
+ const segEnd = next === undefined || next === '/';
89
+ if (segStart && segEnd) {
90
+ if (next === '/') {
91
+ // **/ 整体表达「零或多个段」:恒译 .* 会把 a/**/b 译成 a/.*b,
92
+ // 命中 a/xb、**/b 命中 foob(段边界失守)。
93
+ re += '(?:.*/)?';
94
+ i += 3;
95
+ }
96
+ else {
97
+ re += '.*';
98
+ i += 2;
99
+ }
100
+ }
101
+ else {
102
+ // 非段对齐的双星(a**b、**.ts)塌缩为单星,不跨目录。
103
+ re += '[^/]*';
104
+ i += 2;
105
+ }
20
106
  }
21
107
  else {
22
108
  re += '[^/]*';
@@ -28,25 +114,73 @@ export function globToRegex(pattern) {
28
114
  i++;
29
115
  }
30
116
  else if (c === '{') {
31
- const end = pattern.indexOf('}', i);
117
+ const end = findBraceEnd(pattern, i);
32
118
  if (end === -1) {
33
119
  re += '\\{';
34
120
  i++;
35
121
  }
36
122
  else {
37
123
  const inner = pattern.slice(i + 1, end);
38
- re += `(?:${inner.split(',').map(escapeRegex).join('|')})`;
124
+ const alts = splitBraceAlternatives(inner);
125
+ // 空分支({a,})会生成空交替 (?:a|)——空串匹配一切,静默过匹配。
126
+ // 含空分支整体降级为字面量(宁可零命中,不可命中一切)。
127
+ if (alts.some((alt) => alt.length === 0)) {
128
+ re += `\\{${escapeRegex(inner)}\\}`;
129
+ }
130
+ else {
131
+ const fragStartSeg = i === 0 ? segStartAt0 : pattern[i - 1] === '/';
132
+ re += `(?:${alts.map((alt) => globFragment(alt, fragStartSeg)).join('|')})`;
133
+ }
39
134
  i = end + 1;
40
135
  }
41
136
  }
42
137
  else if (c === '[') {
43
- const end = pattern.indexOf(']', i);
138
+ // 类首 ] 是字面量成员(bash 口径:[]a] 匹配 ] 或 a)——跳过后找真正的闭合。
139
+ let end = pattern.indexOf(']', i);
140
+ if (end === i + 1) {
141
+ const realEnd = pattern.indexOf(']', i + 2);
142
+ if (realEnd !== -1)
143
+ end = realEnd;
144
+ }
44
145
  if (end === -1) {
45
146
  re += '\\[';
46
147
  i++;
47
148
  }
48
149
  else {
49
- re += pattern.slice(i, end + 1);
150
+ const inner = pattern.slice(i + 1, end);
151
+ // [] 在 JS 是「空类恒不匹配」(合法但不产生任何命中)——语义为字面量。
152
+ if (inner === '') {
153
+ re += '\\[\\]';
154
+ i = end + 1;
155
+ continue;
156
+ }
157
+ // glob 取反语义:类首 ! 转为正则 ^;类首 ^ 在 bash 口径是字面量,
158
+ // 必须转义,否则被 JS 解释为取反([!0-9]/[^a] 均曾静默语义反转)。
159
+ let cls = inner;
160
+ if (cls.startsWith('!')) {
161
+ // 单独 [!]:取反空类在正则中是 [^](匹配一切)——保持普通单成员类。
162
+ if (cls.length === 1) {
163
+ cls = '!';
164
+ }
165
+ else {
166
+ cls = `^${cls.slice(1)}`;
167
+ }
168
+ }
169
+ else if (cls.startsWith('^')) {
170
+ cls = `\\^${cls.slice(1)}`;
171
+ }
172
+ else if (cls.startsWith(']')) {
173
+ cls = `\\]${cls.slice(1)}`;
174
+ }
175
+ // 退化类(反向区间 [z-a] 等)此前直接拼进 new RegExp 抛 SyntaxError
176
+ // 击穿工具调用——探测编译失败时按字面量降级,绝不抛出。
177
+ try {
178
+ new RegExp(`^[${cls}]$`);
179
+ re += `[${cls}]`;
180
+ }
181
+ catch {
182
+ re += `\\[${escapeRegex(inner)}\\]`;
183
+ }
50
184
  i = end + 1;
51
185
  }
52
186
  }
@@ -59,10 +193,7 @@ export function globToRegex(pattern) {
59
193
  i++;
60
194
  }
61
195
  }
62
- return new RegExp(`^${re}$`);
63
- }
64
- function escapeRegex(s) {
65
- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
196
+ return re;
66
197
  }
67
198
  /** Recursively walk a directory, skipping IGNORE_DIRS. Returns relative file paths. */
68
199
  async function walkDir(dir, base) {
@@ -101,7 +232,12 @@ export const globTool = {
101
232
  permission: 'auto',
102
233
  execute: async (input, ctx) => {
103
234
  const { pattern, path } = input;
104
- const basePath = path ? resolve(ctx.cwd, path) : ctx.cwd;
235
+ // 与 read/write/edit 同口径:path 必须落在 cwd 内——permission 是 auto,
236
+ // 若放行绝对路径/../,模型可在无确认下枚举工作目录外(如 ~/.ssh)。
237
+ const basePath = path ? safeResolve(ctx.cwd, path) : ctx.cwd;
238
+ if (basePath === null) {
239
+ return { _tag: 'error', error: `Path "${path}" escapes the working directory` };
240
+ }
105
241
  try {
106
242
  const regex = globToRegex(pattern);
107
243
  const files = await walkDir(basePath, basePath);