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
@@ -1,5 +1,6 @@
1
- import { readdir, readFile } from 'node:fs/promises';
2
- import { join, relative, resolve } from 'node:path';
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import { join, relative } from 'node:path';
3
+ import { safeResolve } from '../../shared/utils/path.js';
3
4
  /** Directories always skipped during search. */
4
5
  const IGNORE_DIRS = new Set(['node_modules', '.git', 'dist', '.next', 'build', '.turbo']);
5
6
  /** File extensions treated as text (skip binary files). */
@@ -63,7 +64,11 @@ async function walkForFiles(dir, base) {
63
64
  results.push(...sub);
64
65
  }
65
66
  else {
66
- const ext = entry.name.slice(entry.name.lastIndexOf('.'));
67
+ // 无扩展名(lastIndexOf 返回 -1)时 ext 须为 '':此前 slice(-1) 取到名字
68
+ // 最后一个字符,ext === '' 分支成死代码,Dockerfile/Makefile/README 等
69
+ // 无扩展名文本文件被静默跳过。
70
+ const dot = entry.name.lastIndexOf('.');
71
+ const ext = dot === -1 ? '' : entry.name.slice(dot);
67
72
  if (TEXT_EXTENSIONS.has(ext) || ext === '') {
68
73
  results.push(join(dir, entry.name));
69
74
  }
@@ -84,14 +89,29 @@ export const grepTool = {
84
89
  pattern: { type: 'string', description: 'Regular expression pattern.' },
85
90
  path: { type: 'string', description: 'Base directory to search (default: cwd).' },
86
91
  caseSensitive: { type: 'boolean', description: 'Case-sensitive search (default: true).' },
87
- maxResults: { type: 'number', description: 'Maximum number of matches to return.' },
92
+ maxResults: { type: 'integer', description: 'Maximum number of matches to return.' },
88
93
  },
89
94
  required: ['pattern'],
90
95
  },
91
96
  permission: 'auto',
92
97
  execute: async (input, ctx) => {
93
98
  const { pattern, path, caseSensitive = true, maxResults = 200 } = input;
94
- const basePath = path ? resolve(ctx.cwd, path) : ctx.cwd;
99
+ // maxResults <= 0 会让「matches.length >= max」在首个匹配后立即成立,
100
+ // 静默返回 1 条且 truncated:true(语义应为最多 N 条)。
101
+ // Infinity(1e999)使比较恒 false、上限静默失效;小数语义无定义。
102
+ // 显式报错供模型自纠。
103
+ if (!Number.isInteger(maxResults) || maxResults < 1) {
104
+ return {
105
+ _tag: 'error',
106
+ error: `grep: maxResults must be an integer >= 1, got ${maxResults}`,
107
+ };
108
+ }
109
+ // 与 read/write/edit 同口径:path 必须落在 cwd 内——permission 是 auto,
110
+ // 若放行绝对路径/../,模型可在无确认下读取工作目录外文件内容(如 ~/.ssh)。
111
+ const basePath = path ? safeResolve(ctx.cwd, path) : ctx.cwd;
112
+ if (basePath === null) {
113
+ return { _tag: 'error', error: `Path "${path}" escapes the working directory` };
114
+ }
95
115
  let regex;
96
116
  try {
97
117
  regex = new RegExp(pattern, caseSensitive ? '' : 'i');
@@ -104,15 +124,32 @@ export const grepTool = {
104
124
  const files = await walkForFiles(basePath, basePath);
105
125
  const matches = [];
106
126
  const max = maxResults;
127
+ // 读取失败(权限不可读/悬空符号链接/删除竞态)的文件数:单文件失败只
128
+ // 跳过该文件,绝不中断整个搜索(与 walkForFiles 对 readdir 失败的
129
+ // 容错同口径),但计数并显式回报——静默跳过会让模型误判「仓库里没有」。
130
+ let unreadableFiles = 0;
107
131
  outer: for (const filePath of files) {
108
- const stat = await readFile(filePath);
109
- if (stat.length > MAX_FILE_SIZE)
132
+ // 先 stat 再读:大小上限的语义是「跳过 >1MB 文件」,此前先 readFile
133
+ // 再查长度——整个大文件已被读进内存才丢弃(仓库里一个 GB 级文件即可
134
+ // 让 grep 读取数 GB),且超限且不可读的文件会因先读而 EACCES 失败。
135
+ const info = await stat(filePath).catch(() => null);
136
+ if (info === null || info.size > MAX_FILE_SIZE)
137
+ continue;
138
+ const content = await readFile(filePath, 'utf-8').catch(() => {
139
+ unreadableFiles += 1;
140
+ return null;
141
+ });
142
+ if (content === null)
110
143
  continue;
111
- const content = stat.toString('utf-8');
112
144
  const lines = content.split('\n');
113
145
  const relPath = relative(basePath, filePath);
146
+ // CRLF 文件每行经 split('\n') 仍携带尾 \r:^...$ / \w+$ 等锚定到行尾的
147
+ // 正则全部在 \r 上失败,命中行静默归零。按文件主导行尾归一化行尾再匹配
148
+ //(与 shake/hashline 同型;孤立 \r 即旧 Mac 行尾文件不触碰)。
149
+ const crlf = content.includes('\r\n');
114
150
  for (let i = 0; i < lines.length; i++) {
115
- const line = lines[i] ?? '';
151
+ const raw = lines[i] ?? '';
152
+ const line = crlf && raw.endsWith('\r') ? raw.slice(0, -1) : raw;
116
153
  const match = line.match(regex);
117
154
  if (match) {
118
155
  matches.push({
@@ -126,11 +163,16 @@ export const grepTool = {
126
163
  }
127
164
  }
128
165
  }
129
- const output = matches.map((m) => `${m.file}:${m.line}: ${m.text}`).join('\n');
166
+ const skippedNote = unreadableFiles > 0 ? `\n[${unreadableFiles} unreadable file(s) skipped]` : '';
167
+ const output = matches.map((m) => `${m.file}:${m.line}: ${m.text}`).join('\n') + skippedNote;
130
168
  return {
131
169
  _tag: 'success',
132
170
  output,
133
- metadata: { count: matches.length, truncated: matches.length >= max },
171
+ metadata: {
172
+ count: matches.length,
173
+ truncated: matches.length >= max,
174
+ ...(unreadableFiles > 0 ? { unreadableFiles } : {}),
175
+ },
134
176
  };
135
177
  }
136
178
  catch (error) {
@@ -25,8 +25,22 @@ type KanbanInput = {
25
25
  op: 'delete';
26
26
  id: string;
27
27
  };
28
+ declare function formatBoardSummary(board: {
29
+ columns: {
30
+ id: string;
31
+ name: string;
32
+ }[];
33
+ cards: {
34
+ id: string;
35
+ title: string;
36
+ columnId: string;
37
+ priority: KanbanPriority;
38
+ description: string | null;
39
+ }[];
40
+ }, errors?: string[]): string;
28
41
  /** kanban tool: project-scoped task board for human↔agent collaboration.
29
42
  * Permission: auto (operates on the project's kanban board, no system side effects).
30
43
  * State persists in DB via ctx.kanbanStore (dependency-reversal, like todoState). */
31
44
  export declare const kanbanTool: ToolDef;
32
45
  export type { KanbanInput };
46
+ export { formatBoardSummary };
@@ -3,6 +3,7 @@
3
3
  // instead of in-memory session state. Agents can add, move, update, delete
4
4
  // tasks and view the full board — enabling human↔agent and agent↔agent
5
5
  // coordination via a shared kanban board.
6
+ import { headChars } from '../../shared/utils/string.js';
6
7
  // =============================================================================
7
8
  // Summary formatter
8
9
  // =============================================================================
@@ -24,7 +25,8 @@ function formatBoardSummary(board, errors = []) {
24
25
  const sym = PRIORITY_SYMBOL[card.priority] ?? '';
25
26
  lines.push(` ${sym} [${shortId}] ${card.title}`);
26
27
  if (card.description) {
27
- const preview = card.description.length > 80 ? `${card.description.slice(0, 77)}…` : card.description;
28
+ // headChars 内收切点:截断不落在 emoji 代理对中间产出孤立码元。
29
+ const preview = card.description.length > 80 ? `${headChars(card.description, 77)}…` : card.description;
28
30
  lines.push(` ${preview}`);
29
31
  }
30
32
  }
@@ -212,3 +214,4 @@ The board is shared across all sessions and agents in the same project — use i
212
214
  };
213
215
  },
214
216
  };
217
+ export { formatBoardSummary };
@@ -21,14 +21,27 @@ export const readTool = {
21
21
  type: 'object',
22
22
  properties: {
23
23
  path: { type: 'string', description: 'File path (relative to cwd or absolute).' },
24
- offset: { type: 'number', description: 'Starting line number (1-indexed). Default: 1.' },
25
- limit: { type: 'number', description: 'Maximum number of lines to read.' },
24
+ offset: { type: 'integer', description: 'Starting line number (1-indexed). Default: 1.' },
25
+ limit: { type: 'integer', description: 'Maximum number of lines to read.' },
26
26
  },
27
27
  required: ['path'],
28
28
  },
29
29
  permission: 'auto',
30
30
  execute: async (input, ctx) => {
31
31
  const { path, offset, limit } = input;
32
+ // offset 为 1-indexed 行号。offset < 1 或 limit < 0 会经 (offset ?? 1) - 1
33
+ // 产生负 slice 起点/终点,静默返回错位内容(如 offset=0 → 最后一行)。
34
+ // JSON 还可表示 Infinity(1e999)与小数:slice 对非整数静默截断/错位
35
+ // (offset=Infinity → 空串、offset=2.5 → 错行)。一律显式报错让模型自纠。
36
+ if (offset !== undefined && (!Number.isInteger(offset) || offset < 1)) {
37
+ return {
38
+ _tag: 'error',
39
+ error: `read: offset must be an integer >= 1 (1-indexed), got ${offset}`,
40
+ };
41
+ }
42
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 0)) {
43
+ return { _tag: 'error', error: `read: limit must be an integer >= 0, got ${limit}` };
44
+ }
32
45
  // 内部 URL scheme(spec §3.10):skill://, agent://, pr:// 等。
33
46
  // 命中时走 resolver 拿到文本内容,再统一应用 offset/limit;目录列举对 URL 无意义。
34
47
  if (isURLPath(path)) {
@@ -1,7 +1,14 @@
1
1
  import type { URLRegistry, URLResolver } from '../../shared/types/tool.js';
2
- /** file:// 解析器:`file://rel/path` 相对 cwd;`file:///abs/path` 用绝对路径。 */
2
+ /**
3
+ * file:// 解析器:`file://rel/path` 相对 cwd;`file:///abs/path` 用绝对路径。
4
+ * 与 read/grep/glob/bash 同口径:解析结果必须落在 cwd 内(safeResolve 沙箱)——
5
+ * read 工具 permission=auto,若放行任意绝对路径/../,模型可无确认读取
6
+ * 工作目录外文件(如 ~/.ssh)。
7
+ */
3
8
  declare function createFileResolver(): URLResolver;
4
- /** skill:// 解析器:技能文件候选路径(项目优先于全局)。 */
9
+ /** skill:// 解析器:技能文件候选路径(项目优先于全局)。
10
+ * name 必须是单个路径段:此前未校验,'../config' 可读出 .c0de/config.md,
11
+ * 绝对路径经 resolvePath 前缀重置可读出磁盘任意 .md 文件。 */
5
12
  declare function createSkillResolver(opts?: {
6
13
  homeDir?: string;
7
14
  }): URLResolver;
@@ -2,14 +2,15 @@
2
2
  //
3
3
  // registry 框架(createURLRegistry / registerURLResolver / resolveURL 分发)见
4
4
  // ../resolver.ts。这里实现具体 scheme 的内容来源:
5
- // - file:// → 本地文件(相对 ctx.cwd 或绝对路径)
6
- // - skill:// → 技能文件,项目 .c0de/skills/<name>(.md|/SKILL.md) 优先于
7
- // 全局 ~/.c0de/skills/<name>(.md|/SKILL.md)
5
+ // - file:// → 本地文件(相对 ctx.cwd 或 cwd 内绝对路径,safeResolve 沙箱约束)
6
+ // - skill:// → 技能文件(name 单路径段),项目 .c0de/skills/<name>(.md|/SKILL.md)
7
+ // 优先于全局 ~/.c0de/skills/<name>(.md|/SKILL.md)
8
8
  //
9
9
  // agent:// pr:// issue:// 依赖未实现的子 agent 输出与 GitHub 访问,暂不内置。
10
10
  import { readFile } from 'node:fs/promises';
11
11
  import { homedir } from 'node:os';
12
12
  import { resolve as resolvePath } from 'node:path';
13
+ import { safeResolve } from '../../shared/utils/path.js';
13
14
  import { createURLRegistry, registerURLResolver } from '../resolver.js';
14
15
  /** 从 `scheme://rest` 中取出 rest(scheme 已校验过,直接切首个 `://`)。 */
15
16
  function stripScheme(url) {
@@ -20,14 +21,22 @@ function stripScheme(url) {
20
21
  async function readOrFail(path) {
21
22
  return readFile(path, 'utf-8');
22
23
  }
23
- /** file:// 解析器:`file://rel/path` 相对 cwd;`file:///abs/path` 用绝对路径。 */
24
+ /**
25
+ * file:// 解析器:`file://rel/path` 相对 cwd;`file:///abs/path` 用绝对路径。
26
+ * 与 read/grep/glob/bash 同口径:解析结果必须落在 cwd 内(safeResolve 沙箱)——
27
+ * read 工具 permission=auto,若放行任意绝对路径/../,模型可无确认读取
28
+ * 工作目录外文件(如 ~/.ssh)。
29
+ */
24
30
  function createFileResolver() {
25
31
  return {
26
32
  scheme: 'file',
27
33
  resolve: async (url, ctx) => {
28
34
  const rest = stripScheme(url);
29
35
  // file:///abs → rest 形如 /abs(已含前导斜杠);file://rel → rest 形如 rel。
30
- const path = resolvePath(ctx.cwd, rest);
36
+ const path = safeResolve(ctx.cwd, rest);
37
+ if (path === null) {
38
+ throw new Error(`file:// path "${rest}" escapes the working directory`);
39
+ }
31
40
  return readOrFail(path);
32
41
  },
33
42
  };
@@ -46,7 +55,9 @@ async function firstExistingFile(candidates) {
46
55
  }
47
56
  return null;
48
57
  }
49
- /** skill:// 解析器:技能文件候选路径(项目优先于全局)。 */
58
+ /** skill:// 解析器:技能文件候选路径(项目优先于全局)。
59
+ * name 必须是单个路径段:此前未校验,'../config' 可读出 .c0de/config.md,
60
+ * 绝对路径经 resolvePath 前缀重置可读出磁盘任意 .md 文件。 */
50
61
  function createSkillResolver(opts) {
51
62
  const home = opts?.homeDir ?? homedir();
52
63
  return {
@@ -55,6 +66,9 @@ function createSkillResolver(opts) {
55
66
  const name = stripScheme(url);
56
67
  if (!name)
57
68
  throw new Error('skill URL requires a name, e.g. skill://brainstorming');
69
+ if (name === '.' || name === '..' || name.includes('/') || name.includes('\\')) {
70
+ throw new Error(`Invalid skill name "${name}": must be a single path segment (no /, ..)`);
71
+ }
58
72
  const candidates = [
59
73
  resolvePath(ctx.cwd, '.c0de', 'skills', `${name}.md`),
60
74
  resolvePath(ctx.cwd, '.c0de', 'skills', name, 'SKILL.md'),
@@ -29,58 +29,140 @@ export const taskTool = {
29
29
  role: { type: 'string', description: 'Specialist role for this sub-task.' },
30
30
  assignment: { type: 'string' },
31
31
  },
32
+ // 缺 assignment 的条目此前静默派出 prompt=undefined 的子 agent
33
+ // (headChars 抛 TypeError / 烧一轮内容为 "undefined" 的 LLM 调用)——
34
+ // 逐条目显式必填,错误信息可指导模型修正。
35
+ required: ['assignment'],
32
36
  },
33
37
  description: 'Parallel sub-tasks (batch mode).',
34
38
  },
35
39
  },
36
- required: ['prompt'],
40
+ // 单任务(prompt)与批量(context+tasks)二选一。不能与顶层
41
+ // required:['prompt'] 并存:anyOf 与兄弟关键字是合取关系,顶层 required
42
+ // 会让批量形态恒校验失败。
37
43
  anyOf: [{ required: ['prompt'] }, { required: ['context', 'tasks'] }],
38
44
  },
39
45
  permission: 'auto',
40
46
  execute: async (input, ctx) => {
41
- if (!ctx.runSubAgent) {
47
+ // 单任务入口(ctx.runSubAgent)与批量入口(ctx.runSubAgents)二选一即可工作:
48
+ // 批量入口存在时批量模式走宿主的并发池,否则回退逐个派发。
49
+ const runOne = ctx.runSubAgent;
50
+ const runBatch = ctx.runSubAgents;
51
+ if (!runOne && !runBatch) {
42
52
  return {
43
53
  _tag: 'error',
44
54
  error: 'task tool unavailable: no sub-agent runner is wired into this context',
45
55
  };
46
56
  }
47
57
  const inp = input;
58
+ // 运行时形状校验(schema 兜底之外的执行层防线——execute 可绕过 executor
59
+ // 校验被直接调用)。此前 tasks 条目缺 assignment / 空 tasks 数组会静默
60
+ // 落回单任务模式,把 prompt=undefined 派给子 agent(headChars 处 TypeError
61
+ // 或烧一轮内容为 "undefined" 的 LLM 调用),模型只拿到晦涩报错。
62
+ const tasksInput = 'tasks' in inp ? inp.tasks : undefined;
63
+ if (tasksInput !== undefined && !Array.isArray(tasksInput)) {
64
+ return {
65
+ _tag: 'error',
66
+ error: 'task: tasks must be an array of { assignment, description?, role? }',
67
+ };
68
+ }
69
+ const batchTasks = Array.isArray(tasksInput) ? tasksInput : [];
70
+ for (let i = 0; i < batchTasks.length; i++) {
71
+ const item = batchTasks[i];
72
+ if (item === null ||
73
+ typeof item !== 'object' ||
74
+ Array.isArray(item) ||
75
+ typeof item.assignment !== 'string' ||
76
+ item.assignment.trim().length === 0) {
77
+ return {
78
+ _tag: 'error',
79
+ error: `task: tasks[${i}] is missing a non-empty string "assignment" (the sub-agent prompt). Expected batch item shape: { assignment, description?, role? }`,
80
+ };
81
+ }
82
+ }
48
83
  // 批量模式
49
- if ('tasks' in inp && Array.isArray(inp.tasks) && inp.tasks.length > 0) {
50
- const agentType = inp.subagent_type ?? 'general';
84
+ if ('tasks' in inp && batchTasks.length > 0) {
85
+ const agentType = typeof inp.subagent_type === 'string' && inp.subagent_type.length > 0
86
+ ? inp.subagent_type
87
+ : 'general';
88
+ const requests = batchTasks.map((item) => ({
89
+ agentType,
90
+ prompt: item.assignment,
91
+ ...(typeof item.description === 'string' && item.description.length > 0
92
+ ? { description: item.description }
93
+ : {}),
94
+ ...(typeof item.role === 'string' && item.role.length > 0 ? { role: item.role } : {}),
95
+ ...(typeof inp.context === 'string' && inp.context.length > 0
96
+ ? { context: inp.context }
97
+ : {}),
98
+ }));
99
+ // 并发派发:工具描述承诺 "Launch multiple agents concurrently",宿主批量入口
100
+ // 按 config.agents.subagentConcurrency 建并发池。此前无论宿主是否提供批量能力
101
+ // 都逐个 await——N 个子 agent 的墙钟时间线性叠加,并发配置形同虚设。
102
+ // 宿主未注入批量入口时回退逐个派发(结果顺序不变,仅串行)。
51
103
  const results = [];
52
- for (const item of inp.tasks) {
53
- const req = {
54
- agentType,
55
- prompt: item.assignment,
56
- ...(item.description ? { description: item.description } : {}),
57
- ...(item.role ? { role: item.role } : {}),
58
- ...(inp.context ? { context: inp.context } : {}),
59
- };
60
- const res = await ctx.runSubAgent(req);
104
+ if (runBatch) {
105
+ results.push(...(await runBatch(requests)));
106
+ }
107
+ else if (runOne) {
108
+ for (const req of requests)
109
+ results.push(await runOne(req));
110
+ }
111
+ const label = (i) => inp.tasks[i]?.description ?? inp.tasks[i]?.role ?? 'task';
112
+ const completed = [];
113
+ const failures = [];
114
+ results.forEach((res, i) => {
61
115
  if (res._tag === 'error') {
62
- return { _tag: 'error', error: `Sub-agent failed: ${res.error}` };
116
+ failures.push(`[${label(i)}] ${res.error}`);
117
+ return;
63
118
  }
64
- const label = item.description ?? item.role ?? 'task';
65
119
  if (res._tag === 'running') {
66
- results.push(`[${label}] background started (jobId: ${res.jobId})`);
67
- }
68
- else {
69
- results.push(`[${label}] ${res.output}`);
120
+ completed.push(`[${label(i)}] background started (jobId: ${res.jobId})`);
121
+ return;
70
122
  }
123
+ completed.push(`[${label(i)}] ${res.output}`);
124
+ });
125
+ if (failures.length > 0) {
126
+ // 失败不吞掉已完成任务的结果:模型据此只重派失败的子任务
127
+ return {
128
+ _tag: 'error',
129
+ error: `Sub-agent failed: ${failures.join('; ')}` +
130
+ (completed.length > 0 ? `\n\nCompleted:\n${completed.join('\n\n')}` : ''),
131
+ };
71
132
  }
72
- return { _tag: 'success', output: results.join('\n\n') };
133
+ return { _tag: 'success', output: completed.join('\n\n') };
73
134
  }
74
135
  // 单任务模式
75
136
  const single = inp;
137
+ // 空 tasks 数组等形态会落进单任务分支:prompt 非非空字符串显式报错,
138
+ // 绝不把 undefined 派给子 agent。
139
+ if (typeof single.prompt !== 'string' || single.prompt.trim().length === 0) {
140
+ return {
141
+ _tag: 'error',
142
+ error: 'task: prompt must be a non-empty string (single mode) — for batch mode provide context + tasks[] where every item has a non-empty "assignment"',
143
+ };
144
+ }
145
+ const agentType = typeof single.subagent_type === 'string' && single.subagent_type.length > 0
146
+ ? single.subagent_type
147
+ : 'general';
76
148
  const req = {
77
- agentType: single.subagent_type ?? 'general',
149
+ agentType,
78
150
  prompt: single.prompt,
79
- ...(single.description ? { description: single.description } : {}),
80
- ...(single.model ? { model: single.model } : {}),
81
- ...(single.background ? { background: true } : {}),
151
+ ...(typeof single.description === 'string' && single.description.length > 0
152
+ ? { description: single.description }
153
+ : {}),
154
+ ...(typeof single.model === 'string' && single.model.length > 0
155
+ ? { model: single.model }
156
+ : {}),
157
+ ...(single.background === true ? { background: true } : {}),
82
158
  };
83
- const result = await ctx.runSubAgent(req);
159
+ if (!runOne) {
160
+ return {
161
+ _tag: 'error',
162
+ error: 'task tool unavailable: single-task mode requires a sub-agent runner (ctx.runSubAgent)',
163
+ };
164
+ }
165
+ const result = await runOne(req);
84
166
  if (result._tag === 'error') {
85
167
  return { _tag: 'error', error: `Sub-agent failed: ${result.error}` };
86
168
  }
@@ -288,17 +288,33 @@ const STATUS_TO_MARKER = {
288
288
  completed: 'x',
289
289
  abandoned: '-',
290
290
  };
291
+ /** 渲染转义:反斜杠先行、换行编码为字面 `\n`、回车编码为字面 `\r`——
292
+ * 任务内容/阶段名恒渲染为单行,内容里的换行无法在解析侧伪装成续行结构
293
+ * (- [x] / # 行首注入),裸 \r 也不会被 split(/\r?\n/) 拆成行边界
294
+ * (此前内容含 \r 时渲染后拆行,内容被截断为行首片段)。
295
+ * 与 unescapeMarkdownContent 严格互逆。 */
296
+ function escapeMarkdownContent(value) {
297
+ return value.replace(/\\/g, '\\\\').replace(/\r/g, '\\r').replace(/\n/g, '\\n');
298
+ }
299
+ /** 渲染转义的逆操作(单趟:`\n` → 换行,`\r` → 回车,`\\` → 反斜杠,长匹配
300
+ * 优先保证字面 "\\n"/"\\r" 还原为反斜杠+n/r 而非真换行/回车)。 */
301
+ function unescapeMarkdownContent(value) {
302
+ return value.replace(/\\n|\\r|\\\\/g, (m) => (m === '\\n' ? '\n' : m === '\\r' ? '\r' : '\\'));
303
+ }
291
304
  /** Render todo phases as a Markdown checklist suitable for editing/copying. */
292
305
  export function phasesToMarkdown(phases) {
306
+ // 空列表渲染为空串(而非 "# Todos" 占位)——markdownToPhases("# Todos\n")
307
+ // 会解析出含一个空 "Todos" 阶段的列表,round-trip 契约
308
+ // (markdownToPhases(phasesToMarkdown(x)) === x)对 x=[] 被静默破坏。
293
309
  if (phases.length === 0)
294
- return '# Todos\n';
310
+ return '';
295
311
  const out = [];
296
312
  for (const [i, phase] of phases.entries()) {
297
313
  if (i > 0)
298
314
  out.push('');
299
- out.push(`# ${phase.name}`);
315
+ out.push(`# ${escapeMarkdownContent(phase.name)}`);
300
316
  for (const task of phase.tasks) {
301
- out.push(`- [${STATUS_TO_MARKER[task.status]}] ${task.content}`);
317
+ out.push(`- [${STATUS_TO_MARKER[task.status]}] ${escapeMarkdownContent(task.content)}`);
302
318
  }
303
319
  }
304
320
  return `${out.join('\n')}\n`;
@@ -323,13 +339,24 @@ export function markdownToPhases(md) {
323
339
  const trimmed = raw.trim();
324
340
  if (!trimmed)
325
341
  continue;
326
- const headingMatch = /^#{1,6}\s+(.+?)\s*$/.exec(trimmed);
342
+ // 空阶段名合法(模型可传 phase:'' 或用户手动清空标题):heading 名称允许
343
+ // 为空——此前 (.+?) 要求非空,"# " 行报 unrecognized syntax、阶段被改名
344
+ // "Todos" 且其任务被挪进伪阶段(round-trip 静默改名 + 任务错位)。
345
+ const headingMatch = /^#{1,6}\s*(.*?)\s*$/.exec(trimmed);
327
346
  if (headingMatch) {
328
- currentPhase = { name: (headingMatch[1] ?? '').trim(), tasks: [] };
347
+ // 名称从原始行取(保留前导/尾随空白,渲染方不 trim);换行/反斜杠
348
+ // 经渲染转义可逆还原。此前从 trimmed 分组取名称,尾随空白静默丢失。
349
+ // 渲染格式恒为 `# <name>`:只吃掉 `#` 后的**一个**分隔空白——`\s*` 会把
350
+ // 名字自身的前导空白一起吞掉(`# Lead` 解析回 `Lead`),与尾随空白
351
+ // 同型的 round-trip 静默改写。
352
+ const name = unescapeMarkdownContent(raw.replace(/^\s*#{1,6}[ \t]?/, ''));
353
+ currentPhase = { name, tasks: [] };
329
354
  phases.push(currentPhase);
330
355
  continue;
331
356
  }
332
- const taskMatch = /^[-*+]\s*\[(.?)\]\s+(.+?)\s*$/.exec(trimmed);
357
+ // 空内容任务("- [ ] ")合法:(.+?) 会把它报成 unrecognized syntax 并
358
+ // 丢弃任务——round-trip 丢数据。
359
+ const taskMatch = /^[-*+]\s*\[(.?)\]\s*(.*?)\s*$/.exec(trimmed);
333
360
  if (taskMatch) {
334
361
  if (!currentPhase) {
335
362
  currentPhase = { name: 'Todos', tasks: [] };
@@ -341,12 +368,18 @@ export function markdownToPhases(md) {
341
368
  errors.push(`Line ${lineNum + 1}: unknown status marker "[${marker}]" (use [ ], [x], [/], [-])`);
342
369
  continue;
343
370
  }
344
- currentPhase.tasks.push({ content: (taskMatch[2] ?? '').trim(), status });
371
+ // 内容从原始行取:`\s?` 只吃标记与内容之间的单个分隔空格——前导
372
+ // 空白与尾随空白是内容的一部分,必须保真(trim 会静默改写)。
373
+ const contentRaw = /^[-*+]\s*\[(.?)\]\s?(.*)$/.exec(raw)?.[2] ?? '';
374
+ currentPhase.tasks.push({ content: unescapeMarkdownContent(contentRaw), status });
345
375
  continue;
346
376
  }
347
377
  errors.push(`Line ${lineNum + 1}: unrecognized syntax "${trimmed}"`);
348
378
  }
349
- normalizeInProgressTask(phases);
379
+ // 解析必须保真(markdownToPhases(phasesToMarkdown(x)) === x 的 round-trip
380
+ // 契约):normalizeInProgressTask 是 op 状态不变量(at most one
381
+ // in_progress),此前在解析收尾调用会把无 in_progress 的列表「自动提升
382
+ // 首条 pending」、多条 in_progress「降级除首条外」——导入的数据被静默改写。
350
383
  return { phases, errors };
351
384
  }
352
385
  // =============================================================================
@@ -431,12 +464,54 @@ export function getLatestTodoPhasesFromMessages(messages) {
431
464
  const metadata = output.metadata;
432
465
  const phases = metadata?.phases;
433
466
  if (Array.isArray(phases)) {
434
- return clonePhases(phases);
467
+ return sanitizeTodoPhases(phases);
435
468
  }
436
469
  }
437
470
  }
438
471
  return [];
439
472
  }
473
+ /**
474
+ * 收敛消息里 phases 数组的形状(未受信数据:会话导入的 sanitize 只校验
475
+ * tool_result 分片形状,不递归校验 metadata.phases——{name:1, tasks:'x'}、
476
+ * 缺字段、null 条目等畸形数据原样入库)。此前直接 clonePhases:
477
+ * phase.tasks.map 在非数组上抛 TypeError,GET/POST /api/sessions/:id/todo
478
+ * 击穿 500,todo 面板对该会话永久不可用。畸形条目整体丢弃(宁少勿坏,
479
+ * 与导入 sanitize 同口径)。
480
+ */
481
+ function sanitizeTodoPhases(phases) {
482
+ const out = [];
483
+ for (const phase of phases) {
484
+ if (phase === null || typeof phase !== 'object')
485
+ continue;
486
+ const name = phase.name;
487
+ const tasks = phase.tasks;
488
+ if (typeof name !== 'string' || !Array.isArray(tasks))
489
+ continue;
490
+ const cleanTasks = [];
491
+ let wellFormed = true;
492
+ for (const task of tasks) {
493
+ if (task === null || typeof task !== 'object') {
494
+ wellFormed = false;
495
+ break;
496
+ }
497
+ const content = task.content;
498
+ const status = task.status;
499
+ if (typeof content !== 'string' ||
500
+ (status !== 'pending' &&
501
+ status !== 'in_progress' &&
502
+ status !== 'completed' &&
503
+ status !== 'abandoned')) {
504
+ wellFormed = false;
505
+ break;
506
+ }
507
+ cleanTasks.push({ content, status });
508
+ }
509
+ if (!wellFormed)
510
+ continue;
511
+ out.push({ name, tasks: cleanTasks });
512
+ }
513
+ return out;
514
+ }
440
515
  // =============================================================================
441
516
  // Schema
442
517
  // =============================================================================
@@ -26,7 +26,13 @@ export const writeTool = {
26
26
  try {
27
27
  await mkdir(dirname(fullPath), { recursive: true });
28
28
  await writeFile(fullPath, content, 'utf-8');
29
- return { _tag: 'success', output: `Wrote ${content.length} bytes to ${path}` };
29
+ // 字节数按 UTF-8 编码计:content.length 是 UTF-16 码元数,非 ASCII 内容
30
+ // (中文 3×、emoji 4×)会被低估数倍——工具反馈是模型/用户判断落盘
31
+ // 大小的唯一依据,谎报字节数误导后续操作。
32
+ return {
33
+ _tag: 'success',
34
+ output: `Wrote ${Buffer.byteLength(content, 'utf8')} bytes to ${path}`,
35
+ };
30
36
  }
31
37
  catch (error) {
32
38
  const message = error instanceof Error ? error.message : String(error);
@@ -34,7 +34,15 @@ export const yieldTool = {
34
34
  permission: 'auto',
35
35
  execute: async (input, ctx) => {
36
36
  const { data } = input;
37
- ctx.collectYield?.(data);
37
+ // collectYield 由宿主实现(runSubAgent)——宿主在收集时对 outputSchema 做
38
+ // 校验并抛错。把校验失败折成 error ToolResult 回给模型:模型看到失败原因后
39
+ // 可修正 data 重试,而非整个子 run 静默带上非法结果。
40
+ try {
41
+ ctx.collectYield?.(data);
42
+ }
43
+ catch (error) {
44
+ return { _tag: 'error', error: error instanceof Error ? error.message : String(error) };
45
+ }
38
46
  return { _tag: 'success', output: 'Result submitted.' };
39
47
  },
40
48
  };