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,6 +1,20 @@
1
+ import { MAX_TIMER_MS } from '../shared/timer.js';
1
2
  import { getTool } from './registry.js';
2
3
  import { truncateOutput } from './truncate.js';
3
4
  import { validateInput } from './validate.js';
5
+ /**
6
+ * 归一化工具声明的超时(ms)。返回 null = 无超时(直接执行)。
7
+ * ToolDef.timeout 是插件/内置工具代码,不是用户输入:退化声明(缺失/非正/
8
+ * 非有限)按「无超时」放行(fail-open,与 timeout: undefined 同语义);
9
+ * 有限正值超 32 位 timer 上限(2^31-1)时钳制到上限——不钳的话 Node 的
10
+ * setTimeout 会把 >2^31-1 的延迟钳到 1ms,工具每次调用都被瞬间「超时」击毙,
11
+ * 且错误信息谎报原始时长。
12
+ */
13
+ function normalizeToolTimeout(timeout) {
14
+ if (timeout === undefined || !Number.isFinite(timeout) || timeout <= 0)
15
+ return null;
16
+ return Math.min(timeout, MAX_TIMER_MS);
17
+ }
4
18
  /**
5
19
  * Execute a tool by name with full pipeline:
6
20
  * find tool → validate input → check permission → execute → truncate output.
@@ -32,7 +46,16 @@ export async function executeTool(registry, name, input, ctx, permissionChecker)
32
46
  }
33
47
  // 4. Execute
34
48
  try {
35
- const result = await tool.execute(input, ctx);
49
+ // ToolDef.timeout 声明必须兑现:此前直接 await tool.execute,声明的超时
50
+ // (websearch 30s)从未执行——后端 fetch 挂起时整个 agent run 无限期挂死,
51
+ // 只有用户手动 abort 才能脱身。超时经派生 AbortController 注入工具 ctx:
52
+ // 底层 IO(fetch 等)随超时取消,不再后台残留;外层 run abort 同样级联。
53
+ // 声明先经 normalizeToolTimeout 归一化:退化值=无超时,超 32 位上限钳到
54
+ // 上限(否则 setTimeout 把 >2^31-1 钳到 1ms,工具被瞬间击毙)。
55
+ const timeout = normalizeToolTimeout(tool.timeout);
56
+ const result = timeout !== null
57
+ ? await executeWithTimeout(tool, input, ctx, timeout)
58
+ : await tool.execute(input, ctx);
36
59
  // 5. Truncate large output
37
60
  if (result._tag === 'success') {
38
61
  const truncated = truncateOutput(result.output);
@@ -52,3 +75,32 @@ export async function executeTool(registry, name, input, ctx, permissionChecker)
52
75
  return { _tag: 'error', error: `Tool "${name}" failed: ${message}` };
53
76
  }
54
77
  }
78
+ /**
79
+ * 在声明超时内执行工具:派生 abort 信号(外层 run abort 级联),超时先 abort
80
+ * 再以明确错误拒绝。Promise.race 的晚到 resolve 被忽略;工具自身若无视 abort
81
+ * 继续执行,其结果被丢弃,但错误信息与超时语义不受影响。
82
+ * timeout 已由调用方经 normalizeToolTimeout 归一化(有限正值且 ≤ 32 位上限)。
83
+ */
84
+ async function executeWithTimeout(tool, input, ctx, timeout) {
85
+ const controller = new AbortController();
86
+ const onAbort = () => controller.abort();
87
+ ctx.abort.addEventListener('abort', onAbort, { once: true });
88
+ let timer;
89
+ try {
90
+ return await Promise.race([
91
+ tool.execute(input, { ...ctx, abort: controller.signal }),
92
+ new Promise((_, reject) => {
93
+ timer = setTimeout(() => {
94
+ controller.abort();
95
+ // 外层 catch 会加 "Tool \"x\" failed:" 前缀,这里不再重复工具名
96
+ reject(new Error(`timed out after ${timeout}ms`));
97
+ }, timeout);
98
+ }),
99
+ ]);
100
+ }
101
+ finally {
102
+ if (timer)
103
+ clearTimeout(timer);
104
+ ctx.abort.removeEventListener('abort', onAbort);
105
+ }
106
+ }
@@ -6,36 +6,72 @@ function computeHash(content) {
6
6
  }
7
7
  // ── parsePatch ───────────────────────────────────────────
8
8
  const HEADER_RE = /^\[(.+?)#([0-9a-fA-F]+)\]\s*$/;
9
- /** 解析一个 `[path#hash]` 块内的操作序列。lines 为该块头之后的行。 */
9
+ /** 解析一个 `[path#hash]` 块内的操作序列。lines 为该块头之后的全部剩余行。
10
+ * 返回 { ops, consumed }:consumed 是本次消费的行数(含块间分隔空行,
11
+ * 不含下一块头行)——parsePatch 据此推进。
12
+ *
13
+ * 结构感知:块头只在「操作位置」生效——以 `---` 显式终止的操作,其内容里的
14
+ * 任何行(含 [path#hash] 形行、操作语法形行)都是内容;`---` 缺失时以
15
+ * 下一个操作行/块头为隐式边界。 */
10
16
  function parseOps(lines) {
11
17
  const ops = [];
12
18
  let i = 0;
13
19
  while (i < lines.length) {
14
- const line = lines[i];
15
- if (!line || line.trim() === '') {
20
+ const line = lines[i] ?? '';
21
+ if (line.trim() === '' || isSeparator(line)) {
16
22
  i++;
17
23
  continue;
18
24
  }
25
+ if (HEADER_RE.test(line.trim()))
26
+ break; // 下一块头:操作位置生效
19
27
  const tokens = line.split(/\s+/);
20
28
  const head = tokens[0];
21
29
  if (!head)
22
30
  throw new Error('hashline: empty operation line');
23
- // 收集到下一个分隔符 `---`(或块尾)的内容行
31
+ // 候选边界行之后是否先出现 `---`:是则该候选行是内容(显式终止符
32
+ // 优先于一切),否则该候选行就是边界。
33
+ const isContentLine = (at) => {
34
+ for (let k = at + 1; k < lines.length; k++) {
35
+ const lk = lines[k] ?? '';
36
+ if (isSeparator(lk))
37
+ return true;
38
+ if (isOpLine(lk) || HEADER_RE.test(lk.trim()))
39
+ return false;
40
+ }
41
+ return false;
42
+ };
43
+ // 收集到显式 `---`、或(无 `---` 时)到下一个操作行/块头/文件尾的内容行。
24
44
  const collectContent = () => {
25
45
  const body = [];
26
46
  let j = i + 1;
27
- while (j < lines.length && lines[j] !== '---') {
28
- body.push(lines[j] ?? '');
47
+ while (j < lines.length) {
48
+ const l = lines[j] ?? '';
49
+ if (isSeparator(l))
50
+ return { content: body.join('\n'), next: j + 1 };
51
+ if ((isOpLine(l) || HEADER_RE.test(l.trim())) && !isContentLine(j))
52
+ break;
53
+ body.push(l);
29
54
  j++;
30
55
  }
31
- return { content: body.join('\n'), next: j + 1 };
56
+ // 隐式终止(无 --- 的块):块间分隔空行不是内容,剥离恰好一个
57
+ //(内容里合法结尾空行由倒数第二个空串表达,与既有口径一致)。
58
+ if (body.length > 0 && body[body.length - 1] === '')
59
+ body.pop();
60
+ return { content: body.join('\n'), next: j };
32
61
  };
33
- // 无内容体(DEL)也吃掉到 `---`
62
+ // DEL 无内容体:跳到显式 `---` 或下一个操作行/块头(无 --- 的多操作补丁
63
+ // 里,后续操作不得被当「要跳过的内容」吞掉)。
34
64
  const skipToSeparator = () => {
35
65
  let j = i + 1;
36
- while (j < lines.length && lines[j] !== '---')
66
+ while (j < lines.length) {
67
+ const l = lines[j] ?? '';
68
+ if (isSeparator(l))
69
+ return j + 1;
70
+ if (isOpLine(l) || HEADER_RE.test(l.trim()))
71
+ return j;
37
72
  j++;
38
- return j + 1;
73
+ }
74
+ return j;
39
75
  };
40
76
  if (head === 'SWAP') {
41
77
  const [s, e] = parseRange(tokens[1]);
@@ -50,12 +86,12 @@ function parseOps(lines) {
50
86
  }
51
87
  else if (head === 'INS.PRE') {
52
88
  const { content, next } = collectContent();
53
- ops.push({ _tag: 'INS_PRE', line: Number(tokens[1]), content });
89
+ ops.push({ _tag: 'INS_PRE', line: parseLineNumber(tokens[1]), content });
54
90
  i = next;
55
91
  }
56
92
  else if (head === 'INS.POST') {
57
93
  const { content, next } = collectContent();
58
- ops.push({ _tag: 'INS_POST', line: Number(tokens[1]), content });
94
+ ops.push({ _tag: 'INS_POST', line: parseLineNumber(tokens[1]), content });
59
95
  i = next;
60
96
  }
61
97
  else if (head === 'INS.HEAD') {
@@ -72,17 +108,43 @@ function parseOps(lines) {
72
108
  throw new Error(`hashline: unknown operation "${head}"`);
73
109
  }
74
110
  }
75
- return ops;
111
+ return { ops, consumed: i };
112
+ }
113
+ /** 操作行的完整语法(用于内容边界判定;宽容行尾空白)。 */
114
+ const OP_LINE_RE = /^(?:SWAP|DEL)\s+\d+(?:-\d+)?\s*$|^INS\.(?:PRE|POST)\s+\d+\s*$|^INS\.(?:HEAD|TAIL)\s*$/;
115
+ function isOpLine(line) {
116
+ return OP_LINE_RE.test(line);
117
+ }
118
+ /**
119
+ * 显式操作终止符 `---`。编辑器/模型产物常带尾随空白(`--- `、`---\t`)或缩进
120
+ * (` --- `)——此前只做全等比较,空白变体被当内容行收进替换内容(文件凭空
121
+ * 多出 "--- " 一行)、后续操作静默变成文本/被跳过,工具仍报 success。
122
+ * 空白变体与 `---` 视觉等价,统一视为终止符(与「`---` 恒为终止符、不能做
123
+ * 内容」的既有口径一致)。CRLF 补丁文本的行尾 \r 也由 \s 覆盖。
124
+ */
125
+ function isSeparator(line) {
126
+ return /^\s*---\s*$/.test(line);
76
127
  }
77
- /** 解析 `start` 或 `start-end`,返回 [start, end?](1-indexed)。 */
128
+ /**
129
+ * 解析 `start` 或 `start-end`,返回 [start, end?](1-indexed 十进制整数)。
130
+ * 非十进制整数(小数/十六进制/科学计数/尾随垃圾)显式抛错:Number('1.5') 会
131
+ * 通过 inBounds 后被 splice 静默截断、Number('0x10')=16——垃圾行号静默命中错误行。
132
+ */
78
133
  function parseRange(spec) {
79
134
  if (!spec)
80
135
  throw new Error('hashline: missing line range');
81
- if (spec.includes('-')) {
82
- const [a, b] = spec.split('-');
83
- return [Number(a), Number(b)];
136
+ const parts = spec.split('-');
137
+ if (parts.length > 2 || parts.some((p) => !/^\d+$/.test(p))) {
138
+ throw new Error(`hashline: invalid line range "${spec}" (decimal integers expected)`);
84
139
  }
85
- return [Number(spec), undefined];
140
+ return [Number(parts[0]), parts[1] !== undefined ? Number(parts[1]) : undefined];
141
+ }
142
+ /** 解析单行号(INS.PRE/POST 用),与 parseRange 同口径严格校验。 */
143
+ function parseLineNumber(spec) {
144
+ if (spec === undefined || !/^\d+$/.test(spec)) {
145
+ throw new Error(`hashline: invalid line number "${spec ?? ''}" (decimal integer expected)`);
146
+ }
147
+ return Number(spec);
86
148
  }
87
149
  /** 解析补丁文本为一个或多个 ParsedPatch(按 `[path#hash]` 头分块)。 */
88
150
  function parsePatch(input) {
@@ -100,20 +162,12 @@ function parsePatch(input) {
100
162
  throw new Error(`hashline: malformed header "${line}"`);
101
163
  const path = m[1] ?? '';
102
164
  const hash = m[2] ?? '';
103
- const block = lines.slice(i + 1);
104
- const endIdx = block.indexOf(''); // 块以空行或文件尾分隔(其实下一头才分隔)
105
- // 操作持续到下一个头或文件尾:找到下一个 HEADER_RE 匹配行
106
- let nextHead = block.length;
107
- for (let k = 0; k < block.length; k++) {
108
- if (HEADER_RE.test((block[k] ?? '').trim())) {
109
- nextHead = k;
110
- break;
111
- }
112
- }
113
- void endIdx;
114
- const opsLines = block.slice(0, nextHead);
115
- patches.push({ path, hash, operations: parseOps(opsLines) });
116
- i = i + 1 + nextHead;
165
+ // 结构感知解析:块头只会在「操作位置」终止上一个块——`---` 终止的操作
166
+ // 其内容里的 [path#hash] 形行是内容而非新块(此前恒按块头扫描,合法
167
+ // 内容被拦腰截断、残余行被当作操作解析报 unknown operation)。
168
+ const { ops, consumed } = parseOps(lines.slice(i + 1));
169
+ patches.push({ path, hash, operations: ops });
170
+ i = i + 1 + consumed;
117
171
  }
118
172
  return patches;
119
173
  }
@@ -134,6 +188,16 @@ function anchor(op, lineCount) {
134
188
  return lineCount + 1;
135
189
  }
136
190
  }
191
+ /** 同锚点操作的次级排序键:**消耗锚点行的操作(SWAP/DEL)先于插入操作**。
192
+ * 锚点只保证「高行号先改、低行号不受影响」;锚点相同时(INS.HEAD 与 SWAP 1-1、
193
+ * INS.PRE n 与 SWAP n / DEL n、INS.POST n 与 SWAP n+1 均锚在同一行)按补丁顺序
194
+ * 稳定排序会让插入先执行——插入内容占据该行下标,随后 SWAP/DEL 恰好吃掉它:
195
+ * 插入行消失、原行未被替换/删除(两个操作都没生效,工具仍报 success)。
196
+ * 先消耗后插入是唯一同时满足两侧语义的顺序:行被替换/删除后,插入内容落在
197
+ * 该行位置的「之前」,仍紧邻原本要插入的锚点。 */
198
+ function applyRank(op) {
199
+ return op._tag === 'SWAP' || op._tag === 'DEL' ? 0 : 1;
200
+ }
137
201
  /** 校验操作的行号范围是否落在 [1, lineCount]。 */
138
202
  function inBounds(op, lineCount) {
139
203
  switch (op._tag) {
@@ -157,7 +221,13 @@ function applyPatch(file, patch) {
157
221
  // 末尾换行保留策略:按 \n 拆分,末尾空串代表文件以换行结尾。
158
222
  const hadTrailingNewline = file.endsWith('\n');
159
223
  const src = hadTrailingNewline ? file.slice(0, -1) : file;
160
- const lines = src.split('\n');
224
+ // CRLF 行尾:行 token 剥离 \r 参与行算术,输出按检测到的行尾重建。
225
+ // 此前 split('\n') 后行内残留 \r——SWAP 替换整行(含其 \r)、INS 插入行
226
+ // 用 \n 连接,编辑/插入行静默丢失 \r,与未触碰行混成 CRLF/LF 混合行尾。
227
+ // 无 \n 的孤立 \r(LF 判定下)是文件内容而非行尾,保持原样。
228
+ const eol = src.includes('\r\n') ? '\r\n' : '\n';
229
+ const stripCR = (l) => (l.endsWith('\r') ? l.slice(0, -1) : l);
230
+ const lines = src.split('\n').map((l) => (eol === '\r\n' ? stripCR(l) : l));
161
231
  const lineCount = lines.length;
162
232
  // 先全部校验范围,任一越界即 line_not_found(保持原文件不变)
163
233
  for (const op of patch.operations) {
@@ -165,13 +235,19 @@ function applyPatch(file, patch) {
165
235
  return { _tag: 'line_not_found', operation: op };
166
236
  }
167
237
  }
168
- // 按锚点降序应用:高行号先改,低行号锚点不受影响
238
+ // 按锚点降序应用:高行号先改,低行号锚点不受影响。
239
+ // 锚点相同(INS.HEAD / INS.PRE n / INS.POST n-1 与 SWAP·DEL 落在同一行)时
240
+ // 先应用消耗该行的 SWAP/DEL,再应用插入(见 applyRank:否则插入内容被吞掉)。
169
241
  const ordered = [...patch.operations].sort((a, b) => {
170
242
  const lineCountFinal = lineCount;
171
- return anchor(b, lineCountFinal) - anchor(a, lineCountFinal);
243
+ return anchor(b, lineCountFinal) - anchor(a, lineCountFinal) || applyRank(a) - applyRank(b);
172
244
  });
173
245
  for (const op of ordered) {
174
- const contentLines = 'content' in op && op.content !== '' ? op.content.split('\n') : [];
246
+ // 补丁内容行按文件行尾归一:模型粘贴 CRLF 内容时先剥行尾 \r 再按 eol 重建,
247
+ // 与文件其余部分保持单一换行风格。剥离对 LF 内容是无操作——此前只在
248
+ // eol===CRLF 时剥离,LF 文件 + CRLF 补丁内容的行残留 \r,与其余行混成
249
+ // CRLF/LF 混合行尾(与 edit diff 模式的单向归一化同型)。
250
+ const contentLines = 'content' in op && op.content !== '' ? op.content.split('\n').map(stripCR) : [];
175
251
  switch (op._tag) {
176
252
  case 'SWAP':
177
253
  lines.splice(op.start - 1, op.end - op.start + 1, ...contentLines);
@@ -193,9 +269,9 @@ function applyPatch(file, patch) {
193
269
  break;
194
270
  }
195
271
  }
196
- let result = lines.join('\n');
272
+ let result = lines.join(eol);
197
273
  if (hadTrailingNewline)
198
- result += '\n';
274
+ result += eol;
199
275
  return { _tag: 'success', content: result };
200
276
  }
201
277
  export { applyPatch, computeHash, parsePatch };
@@ -1,3 +1,4 @@
1
+ import { headChars, tailChars } from '../shared/utils/string.js';
1
2
  /** Default truncation thresholds — tuned for LLM context windows. */
2
3
  export const DEFAULT_TRUNCATE_OPTIONS = {
3
4
  maxLines: 2000,
@@ -22,29 +23,57 @@ export function truncateOutput(output, opts = DEFAULT_TRUNCATE_OPTIONS) {
22
23
  if (!needsLineTrunc && !needsCharTrunc) {
23
24
  return { output, truncated: false, totalLines, totalChars };
24
25
  }
26
+ // 字符截断(保留 head/tail 各半)。
27
+ // 标记长度计入预算:此前 head+tail 吃满 maxChars 后追加标记,输出反而
28
+ // 比原文更长且突破上限(如 100_001 字符 → 100_029 字符)。
29
+ const truncateByChars = (text) => {
30
+ const total = text.length;
31
+ const markerFor = (omitted) => `\n[... ${omitted} chars truncated ...]\n`;
32
+ let marker = '';
33
+ let keepChars = opts.maxChars;
34
+ for (;;) {
35
+ const omitted = total - keepChars;
36
+ const next = markerFor(omitted);
37
+ if (next === marker)
38
+ break; // 数字位数不再变化,收敛
39
+ marker = next;
40
+ keepChars = Math.max(0, opts.maxChars - marker.length);
41
+ }
42
+ // 上限连标记自身都放不下(maxChars 小于标记长度):无标记硬截断,
43
+ // 保住「输出 ≤ maxChars」不变量(此前 marker 恒长于预算仍被输出)。
44
+ if (marker.length >= opts.maxChars) {
45
+ return headChars(text, opts.maxChars);
46
+ }
47
+ const headLen = Math.floor(keepChars / 2);
48
+ const tailLen = keepChars - headLen;
49
+ // 切点代理对安全:head/tail 各取至多 headLen/tailLen 字符,
50
+ // 内收只会缩短输出,marker 计入预算后的 ≤ maxChars 不变量保持。
51
+ return headChars(text, headLen) + marker + tailChars(text, tailLen);
52
+ };
25
53
  // Line-based truncation takes priority
26
54
  if (needsLineTrunc) {
27
55
  const head = lines.slice(0, opts.headLines);
28
56
  const tailStart = totalLines - opts.tailLines;
29
- // If tail would overlap head, show only head + marker
30
- if (tailStart <= opts.headLines) {
31
- const omitted = totalLines - head.length;
32
- const marker = `[... ${omitted} lines truncated ...]`;
33
- return { output: [...head, marker].join('\n'), truncated: true, totalLines, totalChars };
34
- }
35
- const tail = lines.slice(tailStart);
57
+ // tail 与 head 重叠(或 tailLines ≥ totalLines)→ 只保留 head
58
+ const tail = tailStart > opts.headLines ? lines.slice(tailStart) : [];
36
59
  const omitted = totalLines - head.length - tail.length;
60
+ // head 已覆盖全部行(headLines ≥ totalLines)→ 无任何省略:
61
+ // 此前恒插 "[... 0 lines truncated ...]",输出比原文多一行且行数突破
62
+ // maxLines。直接原样返回;字符超限时仍走字符兜底截断。
63
+ if (omitted === 0) {
64
+ if (needsCharTrunc) {
65
+ return { output: truncateByChars(output), truncated: true, totalLines, totalChars };
66
+ }
67
+ return { output, truncated: false, totalLines, totalChars };
68
+ }
37
69
  const marker = `[... ${omitted} lines truncated ...]`;
38
70
  const result = [...head, marker, ...tail].join('\n');
71
+ // 行截断后仍可能突破字符上限:head/tail 行自身巨大(minified 单行)或
72
+ // 省略行全为空而标记把长度顶过上限——此时再按字符截断兜底,保证 ≤ maxChars。
73
+ if (result.length > opts.maxChars) {
74
+ return { output: truncateByChars(result), truncated: true, totalLines, totalChars };
75
+ }
39
76
  return { output: result, truncated: true, totalLines, totalChars };
40
77
  }
41
- // Char-based truncation (keep proportional head/tail of chars)
42
- const keepChars = opts.maxChars;
43
- const headChars = Math.floor(keepChars * 0.5);
44
- const tailChars = keepChars - headChars;
45
- const head = output.slice(0, headChars);
46
- const tail = output.slice(totalChars - tailChars);
47
- const omitted = totalChars - keepChars;
48
- const marker = `\n[... ${omitted} chars truncated ...]\n`;
49
- return { output: head + marker + tail, truncated: true, totalLines, totalChars };
78
+ return { output: truncateByChars(output), truncated: true, totalLines, totalChars };
50
79
  }
@@ -14,18 +14,20 @@ function validateNode(schema, value, path) {
14
14
  if (Object.keys(schema).length === 0)
15
15
  return null;
16
16
  // anyOf: at least one must pass
17
+ // 命中不短路:JSON Schema 中 anyOf/oneOf 与 type/required/properties 等
18
+ // 兄弟关键字是**合取**关系——此前命中即 return null,兄弟校验整体失效
19
+ // (task 工具 schema 的 properties 类型检查形同虚设:prompt: 123、
20
+ // tasks 条目缺 assignment 均通过,静默派出 prompt=undefined 的子 agent)。
17
21
  if (schema.anyOf) {
18
22
  const passed = schema.anyOf.some((s) => validateNode(s, value, path) === null);
19
23
  if (!passed)
20
24
  return `${path || 'value'}: does not match anyOf schemas`;
21
- return null;
22
25
  }
23
26
  // oneOf: exactly one must pass
24
27
  if (schema.oneOf) {
25
28
  const count = schema.oneOf.filter((s) => validateNode(s, value, path) === null).length;
26
29
  if (count !== 1)
27
30
  return `${path || 'value'}: must match exactly one oneOf schema (matched ${count})`;
28
- return null;
29
31
  }
30
32
  // enum
31
33
  if (schema.enum !== undefined) {
@@ -40,15 +42,22 @@ function validateNode(schema, value, path) {
40
42
  return typeError;
41
43
  }
42
44
  // object validation
43
- if (schema.type === 'object' &&
44
- typeof value === 'object' &&
45
- value !== null &&
46
- !Array.isArray(value)) {
45
+ // 结构关键字与 `type` 是**独立**约束:JSON Schema 语义下 required/properties/
46
+ // additionalProperties 只对「对象实例」生效、items 只对「数组实例」生效,均
47
+ // 不要求同一节点声明 type。task 工具的 anyOf 分支正是裸 `{required:['prompt']}`
48
+ // ——此前整个对象校验块以 `schema.type === 'object'` 为前提,这类分支被整体跳过
49
+ // (二选一守卫形同虚设:`{}`、`{subagent_type:'coder'}` 一律通过校验)。
50
+ const isObjectValue = typeof value === 'object' && value !== null && !Array.isArray(value);
51
+ const declaresObjectKeywords = schema.required !== undefined ||
52
+ schema.properties !== undefined ||
53
+ schema.additionalProperties !== undefined;
54
+ if (isObjectValue && declaresObjectKeywords) {
47
55
  const obj = value;
48
- // required fields
56
+ // required fields(只认自有属性:`'toString' in {}` 恒真,原型链上的同名键
57
+ // 不代表实例真的携带该字段)
49
58
  if (schema.required) {
50
59
  for (const field of schema.required) {
51
- if (!(field in obj)) {
60
+ if (!Object.hasOwn(obj, field)) {
52
61
  return `${path ? `${path}.` : ''}${field}: missing required field`;
53
62
  }
54
63
  }
@@ -56,26 +65,61 @@ function validateNode(schema, value, path) {
56
65
  // properties
57
66
  if (schema.properties) {
58
67
  for (const [key, propSchema] of Object.entries(schema.properties)) {
59
- if (key in obj) {
68
+ if (Object.hasOwn(obj, key)) {
60
69
  const err = validateNode(propSchema, obj[key], path ? `${path}.${key}` : key);
61
70
  if (err)
62
71
  return err;
63
72
  }
64
73
  }
65
74
  }
66
- // additionalProperties
67
- if (schema.additionalProperties === false && schema.properties) {
68
- const knownKeys = new Set(Object.keys(schema.properties));
69
- for (const key of Object.keys(obj)) {
70
- if (!knownKeys.has(key)) {
71
- return `${path ? `${path}.` : ''}${key}: additional property not allowed`;
75
+ // additionalProperties 两种形式(JSON Schema draft-07):
76
+ // - false:只允许 properties 声明的键(未声明 properties 时任何键都是额外的);
77
+ // - 模式({type:'string'} 等):每个**未被 properties 声明**的键按该模式校验
78
+ // (声明键只受自身 schema 约束,不受 additionalProperties 影响)。
79
+ // 此前只实现 false 分支——声明 additionalProperties:{type:'string'} 的 schema
80
+ // (bash 工具的 env 等)对未声明键完全放行:env:{FOO:123} 通过校验后进
81
+ // spawn,值被 Node 静默字符串化(对象变 "[object Object]")。
82
+ if (schema.additionalProperties !== undefined) {
83
+ const knownKeys = new Set(Object.keys(schema.properties ?? {}));
84
+ if (schema.additionalProperties === false) {
85
+ for (const key of Object.keys(obj)) {
86
+ if (!knownKeys.has(key)) {
87
+ return `${path ? `${path}.` : ''}${key}: additional property not allowed`;
88
+ }
89
+ }
90
+ }
91
+ else if (typeof schema.additionalProperties === 'object' &&
92
+ schema.additionalProperties !== null) {
93
+ for (const [key, value] of Object.entries(obj)) {
94
+ if (knownKeys.has(key))
95
+ continue;
96
+ const err = validateNode(schema.additionalProperties, value, path ? `${path}.${key}` : key);
97
+ if (err)
98
+ return err;
72
99
  }
73
100
  }
74
101
  }
75
102
  }
76
- // array validation
77
- if (schema.type === 'array' && Array.isArray(value)) {
78
- if (schema.items) {
103
+ // array validation(items 只对数组实例生效,同样不要求同层 type: 'array')
104
+ // minItems/maxItems:数组基数约束(同样只对数组实例生效,与同层 type 无关)。
105
+ // 此前从未实现——todo 工具 schema 声明的 minItems:1(空阶段不得创建)形同
106
+ // 虚设:{op:'init', list:[{phase:'P', items:[]}]} 静默通过并持久化空阶段;
107
+ // MCP 服务器自带的 inputSchema 声明的同类约束同样被静默放行。
108
+ if (Array.isArray(value) &&
109
+ (schema.items !== undefined || schema.minItems !== undefined || schema.maxItems !== undefined)) {
110
+ if (schema.minItems !== undefined) {
111
+ const minItems = schema.minItems;
112
+ if (typeof minItems === 'number' && value.length < minItems) {
113
+ return `${path || 'array'}: expected at least ${minItems} items, got ${value.length}`;
114
+ }
115
+ }
116
+ if (schema.maxItems !== undefined) {
117
+ const maxItems = schema.maxItems;
118
+ if (typeof maxItems === 'number' && value.length > maxItems) {
119
+ return `${path || 'array'}: expected at most ${maxItems} items, got ${value.length}`;
120
+ }
121
+ }
122
+ if (schema.items !== undefined) {
79
123
  const itemSchema = Array.isArray(schema.items) ? schema.items : [schema.items];
80
124
  for (let i = 0; i < value.length; i++) {
81
125
  const s = itemSchema[i] ?? itemSchema[0];
@@ -97,8 +141,10 @@ function checkType(type, value, path) {
97
141
  return `${label}: expected string, got ${typeof value}`;
98
142
  break;
99
143
  case 'number':
100
- if (typeof value !== 'number' || Number.isNaN(value)) {
101
- return `${label}: expected number, got ${typeof value}`;
144
+ // JSON.parse('1e999') → Infinity 可真实到达(typeof number 且非 NaN),
145
+ // 放行后进入 slice/setTimeout/max() 等算术即静默错误行为——一律拒绝。
146
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
147
+ return `${label}: expected finite number, got ${typeof value}`;
102
148
  }
103
149
  break;
104
150
  case 'integer':
@@ -1,4 +1,5 @@
1
- import { decryptSecret } from '../../core/secret.js';
1
+ import { decryptSecretSafe } from '../../core/secret.js';
2
+ import { headChars } from '../../shared/utils/string.js';
2
3
  import { createFetch } from './fetch.js';
3
4
  import { braveProvider } from './providers/brave.js';
4
5
  import { duckduckgoProvider } from './providers/duckduckgo.js';
@@ -10,9 +11,11 @@ const PROVIDERS = {
10
11
  brave: braveProvider,
11
12
  };
12
13
  /** 环境变量名 → config key 字段映射。环境变量优先于 config。
13
- * config 值落盘时已加密(enc: 前缀),此处解密后使用(明文兼容透传)。 */
14
+ * config 值落盘时已加密(enc: 前缀),此处解密后使用(明文兼容透传)。
15
+ * 跨机同步/损坏的密文解密失败 → 视为无 key(undefined):auto 模式降级
16
+ * duckduckgo,显式模式给出「需要 API key」的可操作错误——解密异常不击穿。 */
14
17
  function resolveKeys(config) {
15
- const decrypt = (v) => (v ? decryptSecret(v) : v);
18
+ const decrypt = (v) => (v ? decryptSecretSafe(v) : v);
16
19
  return {
17
20
  tavily: process.env.TAVILY_API_KEY ?? decrypt(config.tavilyApiKey),
18
21
  brave: process.env.BRAVE_API_KEY ?? decrypt(config.braveApiKey),
@@ -37,7 +40,8 @@ export function resolveProvider(preference, keys) {
37
40
  }
38
41
  const SNIPPET_MAX = 240;
39
42
  function truncate(text, max) {
40
- return text.length <= max ? text : `${text.slice(0, Math.max(0, max - 1))}…`;
43
+ // headChars 内收切点:截断不产出孤立代理码元
44
+ return text.length <= max ? text : `${headChars(text, Math.max(0, max - 1))}…`;
41
45
  }
42
46
  /** 将统一响应格式化为 LLM 友好的纯文本。 */
43
47
  export function formatForLLM(response) {
@@ -3,7 +3,7 @@ export { confirmHandoff, createHandoffServer, requestHandoff } from './ipc.js';
3
3
  export type { UpdateScheduler, UpdateSchedulerOptions } from './scheduler.js';
4
4
  export { createUpdateScheduler } from './scheduler.js';
5
5
  export type { SerializedEntry, SerializedSession, SessionSnapshot } from './snapshot.js';
6
- export { orderSessionsByParent, restoreSessions, serializeSessions } from './snapshot.js';
6
+ export { restoreSessions, serializeSessions } from './snapshot.js';
7
7
  export type { HotUpdateOptions, HotUpdateResult, InstallMethod, InstallOnlyResult, SpawnFn, } from './updater.js';
8
8
  export { cleanupSnapshot, manualInstallCommand, performHandoff, performHotUpdate, performInstall, } from './updater.js';
9
9
  export type { UpdateCheckResult } from './version.js';
@@ -1,6 +1,6 @@
1
1
  // 热更新 + 会话迁移包(spec §18)。
2
2
  export { confirmHandoff, createHandoffServer, requestHandoff } from './ipc.js';
3
3
  export { createUpdateScheduler } from './scheduler.js';
4
- export { orderSessionsByParent, restoreSessions, serializeSessions } from './snapshot.js';
4
+ export { restoreSessions, serializeSessions } from './snapshot.js';
5
5
  export { cleanupSnapshot, manualInstallCommand, performHandoff, performHotUpdate, performInstall, } from './updater.js';
6
6
  export { checkForUpdate, compareSemver, getCurrentVersion } from './version.js';
@@ -1,9 +1,16 @@
1
+ import { clampTimerDelay } from '../shared/timer.js';
1
2
  import { getCurrentVersion } from './version.js';
2
3
  const DEFAULT_INTERVAL_MS = 60 * 60 * 1000;
3
4
  const DEFAULT_INITIAL_DELAY_MS = 10_000;
4
5
  function createUpdateScheduler(opts) {
5
- const intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
6
- const initialDelayMs = opts.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS;
6
+ // intervalMs/initialDelayMs 来自用户 config(server.ts 直传),未经校验:
7
+ // 非有限或超 32 位上限的延迟会被 Node 钳到 1ms——config 里写「超长间隔」
8
+ // 本意是少检查,实际却以每毫秒一次的频率狂刷 npm registry;写 0/负数
9
+ // (「不检查」的常见写法)同样落到 1ms 轮询,比超限更糟。一律回落默认值
10
+ // (超上限钳到上限 ≈ 实践上不再检查,保持既有语义);0 延迟对
11
+ // initialDelayMs 合法(立即首检),对周期间隔无意义(回落默认 1 小时)。
12
+ const intervalMs = clampTimerDelay(opts.intervalMs, DEFAULT_INTERVAL_MS, 1);
13
+ const initialDelayMs = clampTimerDelay(opts.initialDelayMs, DEFAULT_INITIAL_DELAY_MS, 0);
7
14
  // 直接引用全局 timer;vitest useFakeTimers() 会替换全局,start() 时读取即拿到 mock。
8
15
  let intervalId = null;
9
16
  let initialId = null;
@@ -48,11 +48,21 @@ type SessionSnapshot = {
48
48
  * 前端持久化布局可无感重连)。旧快照缺省视为无终端。 */
49
49
  terminals?: SerializedTerminal[];
50
50
  };
51
- /** 按 parentId 拓扑序排列 sessions,使父会话先于子会话插入(满足自引用 FK)。 */
52
- declare function orderSessionsByParent(list: SerializedSession[]): SerializedSession[];
53
51
  /** 从 DB 导出所有会话与条目为可序列化快照。terminals 为活跃终端元信息(可选)。 */
54
52
  declare function serializeSessions(handle: DB, config?: unknown, terminals?: SerializedTerminal[]): Promise<SessionSnapshot>;
55
- /** 把快照导入 DB(保留原始 id 与时间戳;父会话先插入以满足 FK)。 */
53
+ /**
54
+ * 把快照导入 DB(保留原始 id 与时间戳),两阶段插入:
55
+ * 1. 全部会话行以 parentId=NULL 插入——任何插入顺序都满足自引用 FK,
56
+ * 无需拓扑排序;
57
+ * 2. 回填各行的 parentId——此时全部行已存在,FK 恒可满足。
58
+ *
59
+ * 此前按拓扑序逐个插入(orderSessionsByParent 先父后子):环防护只保证
60
+ * 「终止」——parentId 成环(a↔b 互指/自引用)时环上第一个被访问的节点先于
61
+ * 其父入序,插入它即撞自引用 FK 23503(其父行尚不存在),整个恢复抛错、
62
+ * serve 启动失败。两阶段插入对任意形状(环/自引用/正常森林)都成立,
63
+ * 环上节点父指针完整保留;悬空 parentId(快照外引用)在第二阶段撞 FK,
64
+ * 仍是「数据损坏显式失败」而非静默改写。
65
+ */
56
66
  declare function restoreSessions(handle: DB, snapshot: SessionSnapshot): Promise<void>;
57
67
  export type { SerializedEntry, SerializedSession, SerializedTerminal, SessionSnapshot };
58
- export { orderSessionsByParent, restoreSessions, serializeSessions };
68
+ export { restoreSessions, serializeSessions };