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,7 +1,10 @@
1
1
  import { appendMessage } from '../../session/message.js';
2
2
  import { createSession, updateSessionLastRun } from '../../session/session.js';
3
3
  import { generateId } from '../../shared/index.js';
4
- import { createAgent, runAgent } from '../agent.js';
4
+ import { headChars } from '../../shared/utils/string.js';
5
+ import { validateInput } from '../../tools/validate.js';
6
+ import { abortAgent, createAgent, runAgent } from '../agent.js';
7
+ import { mapWithConcurrencyLimit } from '../agents/parallel.js';
5
8
  import { applyPatchToParent, captureBaseline, captureDeltaPatch, createWorktree, removeWorktree, } from '../worktree.js';
6
9
  /** 运行一个按类型派发的子 agent(spec: multi-agent-design §4.5)。
7
10
  *
@@ -24,7 +27,7 @@ export async function runSubAgent(deps, parent, request) {
24
27
  };
25
28
  }
26
29
  const title = request.description?.trim() ||
27
- `Sub-agent (${request.agentType}): ${request.prompt.slice(0, 60)}`;
30
+ `Sub-agent (${request.agentType}): ${headChars(request.prompt, 60)}`;
28
31
  const childId = generateId();
29
32
  const yielded = [];
30
33
  // 发射 subagent_start 事件(spec §4.5 step 7)
@@ -39,7 +42,7 @@ export async function runSubAgent(deps, parent, request) {
39
42
  // 删除父会话时级联,不再是游离根节点)
40
43
  let childSession;
41
44
  try {
42
- childSession = await createSession(deps.db, title, parent.session.projectId ?? undefined, request.agentType, parent.session.source ?? undefined, parent.session.id);
45
+ childSession = await createSession(deps.db, title, parent.session.projectId ?? undefined, request.agentType, parent.session.source ?? undefined, parent.session.id, undefined, undefined, deps.hookRunner);
43
46
  }
44
47
  catch (e) {
45
48
  return { _tag: 'error', error: e instanceof Error ? e.message : String(e) };
@@ -81,11 +84,21 @@ export async function runSubAgent(deps, parent, request) {
81
84
  ...(def.model ? { model: def.model } : {}),
82
85
  ...(request.model ? { model: request.model } : {}),
83
86
  };
84
- // 子 agent 的 deps:覆盖 cwd(worktree)+ 注入 yield 收集器 + 递归深度
87
+ // 子 agent 的 deps:覆盖 cwd(worktree)+ 注入 yield 收集器 + 递归深度。
88
+ // yield 收集器对 def.outputSchema 做 JSON Schema 校验(spec: multi-agent-design
89
+ // §4.5 声明但从未消费):不合法抛错 → yield 工具折成 error 反馈给模型,模型可
90
+ // 修正后重试;合法才入收集数组(最终作为 task 结果 data 回传父 agent)。
85
91
  const childDeps = {
86
92
  ...deps,
87
93
  cwd: childCwd,
88
94
  _subagentYieldCollector: (data) => {
95
+ if (def.outputSchema) {
96
+ const result = validateInput(def.outputSchema, data);
97
+ if (!result.valid) {
98
+ throw new Error(`yield data does not match the agent's outputSchema: ${result.error}. ` +
99
+ 'Call yield again with data that satisfies the schema.');
100
+ }
101
+ }
89
102
  yielded.push(data);
90
103
  },
91
104
  _subagentDepth: childDepth,
@@ -95,12 +108,21 @@ export async function runSubAgent(deps, parent, request) {
95
108
  // 继续后,子 agent 不应再因同一预算超支提前中止(用户已知情继续);父未超支
96
109
  // 时标记为 undefined,子 agent 自跑其 loop 仍会按轮次独立检查预算。
97
110
  childState.budgetPauseTriggered = parent.budgetPauseTriggered;
98
- // abort 链接:父 abort 则子 abort
111
+ // abort 链接:父 abort 则子 abort。
112
+ // 必须走 abortAgent(= abort signal + 把 running/paused 清成 stopped)而非裸
113
+ // abortController.abort():子 run 可能正停在暂停点(权限超时/预算暂停会级联暂停
114
+ // 子 run),signal-only 中止不清 status,子 loop 的暂停等待永远不返回——子 run
115
+ // 槽位泄漏、父 run 的工具批次(task 工具 await)永不返回。
116
+ // 监听器在子 run 结束时摘除(见 runBody 的 finally):{ once: true } 只在真的
117
+ // 触发 abort 时自动摘除,正常完成的子 run(绝大多数)从不触发——父 run 级
118
+ // signal 上每派发一个子 agent 就留一个监听器,累积到 11 个即触发 Node 的
119
+ // MaxListenersExceededWarning,闭包也随 signal 活到父 run 结束。
120
+ const onParentAbort = () => abortAgent(childState);
99
121
  if (parent.abortController.signal.aborted) {
100
- childState.abortController.abort();
122
+ abortAgent(childState);
101
123
  }
102
124
  else {
103
- parent.abortController.signal.addEventListener('abort', () => childState.abortController.abort(), { once: true });
125
+ parent.abortController.signal.addEventListener('abort', onParentAbort, { once: true });
104
126
  }
105
127
  // P1:把子 run(同步 + 后台)注册进宿主 run 跟踪器(Web=agentManager)。
106
128
  // 此前子 run 完全游离:暂停(权限超时/用户/预算)、热更新 pauseAll、
@@ -120,6 +142,9 @@ export async function runSubAgent(deps, parent, request) {
120
142
  return await runChildBody(childState, childDeps, childSession, title, baseline, worktreePath);
121
143
  }
122
144
  finally {
145
+ // 子 run 结束即摘除父 signal 上的 abort 联动监听器(background 路径在此前
146
+ // 一直保留——子 run 仍在跑时父 abort 必须仍能级联,故只能在此摘除)。
147
+ parent.abortController.signal.removeEventListener('abort', onParentAbort);
123
148
  unregisterChild?.();
124
149
  }
125
150
  };
@@ -220,3 +245,44 @@ export async function runSubAgent(deps, parent, request) {
220
245
  }
221
246
  return runBody();
222
247
  }
248
+ /** 子 agent 并发上限的默认值与上限。
249
+ * 配置项 config.agents.subagentConcurrency 经 normalizeSubagentConcurrency 归一化;
250
+ * 上限 10 防配置面(手改/克隆仓库自带)把并发推到宿主无法承受(每个子 agent 都是
251
+ * 独立 session + LLM 调用 + DB 写)。 */
252
+ const DEFAULT_SUBAGENT_CONCURRENCY = 3;
253
+ const MAX_SUBAGENT_CONCURRENCY = 10;
254
+ /** 归一化子 agent 并发上限:缺失/非有限/<1 回落默认 3,上限钳到 10。
255
+ * 与 ToolDef.timeout / clampTimerDelay 同口径——配置面的退化值不得让派发失控
256
+ * (0 或负数会让并发池退化成「一个都不跑」)。 */
257
+ export function normalizeSubagentConcurrency(value) {
258
+ if (value === undefined || !Number.isFinite(value) || value < 1) {
259
+ return DEFAULT_SUBAGENT_CONCURRENCY;
260
+ }
261
+ return Math.min(Math.trunc(value), MAX_SUBAGENT_CONCURRENCY);
262
+ }
263
+ /**
264
+ * 批量派发子 agent(task 工具批量模式 / 工作流 ctx.runSubagents 共用)。
265
+ *
266
+ * - 并发上限取 config.agents.subagentConcurrency(默认 3)——此前 task 工具批量模式
267
+ * 逐个 await(墙钟时间线性叠加,与工具描述的 "Launch multiple agents concurrently"
268
+ * 相悖),工作流侧则硬编码 3(配置项形同虚设)。
269
+ * - 结果与入参顺序一一对应,绝不出现空洞:未启动的任务(abort)填 error 结果,
270
+ * 调用方无需再处理 undefined。
271
+ * - 单个任务失败隔离(抛错/error 结果都收敛为 error),不 fail-fast 终止兄弟任务。
272
+ * - abort(父 run 中止)取消尚未启动的任务。
273
+ */
274
+ export async function runSubAgents(deps, parent, requests, runOne = (request) => runSubAgent(deps, parent, request)) {
275
+ if (requests.length === 0)
276
+ return [];
277
+ const limit = normalizeSubagentConcurrency(deps.config.agents?.subagentConcurrency);
278
+ const { results } = await mapWithConcurrencyLimit(requests, limit, async (request) => {
279
+ try {
280
+ return await runOne(request);
281
+ }
282
+ catch (e) {
283
+ // 隔离单个任务的异常:不向上抛,避免 fail-fast 终止尚未启动的兄弟任务
284
+ return { _tag: 'error', error: e instanceof Error ? e.message : String(e) };
285
+ }
286
+ }, parent.abortController.signal);
287
+ return results.map((result, i) => result ?? { _tag: 'error', error: `sub-agent task ${i + 1} was not started (run aborted)` });
288
+ }
@@ -2,7 +2,7 @@ import { chatStream as llmChatStream } from '../llm/provider.js';
2
2
  import type { AgentEvent, AgentState } from '../shared/types/agent.js';
3
3
  import type { AgentDependencies } from './types.js';
4
4
  export { compactContext } from './loop/compaction.js';
5
- export { runSubAgent } from './loop/subagent.js';
5
+ export { runSubAgent, runSubAgents } from './loop/subagent.js';
6
6
  type LoopDeps = AgentDependencies & {
7
7
  chatStream?: typeof llmChatStream;
8
8
  /** 子 agent 运行时注入:yield 工具的结果收集器(透传到 ToolContext.collectYield)。 */
@@ -19,6 +19,9 @@ type LoopDeps = AgentDependencies & {
19
19
  /** 预算护栏的 CLI 变体:无恢复 UI,超支时**中止 run**并产出 error(而非暂停挂起)。
20
20
  * 由 CLI deps 组装(buildAgentDeps)按 usage 动作注入,替代 budgetPause(恒 abort)。 */
21
21
  readonly budgetAbort?: boolean;
22
+ /** 已发现技能名列表(src/core/skills.ts discoverSkills)→ PromptContext.skills →
23
+ * system prompt ## Loaded Skills 段。宿主(CLI/Web 各通道)注入;未注入时为空。 */
24
+ readonly skills?: string[];
22
25
  };
23
26
  export type { LoopDeps };
24
27
  export declare function agentLoop(state: AgentState, deps: LoopDeps): AsyncGenerator<AgentEvent>;
package/dist/core/loop.js CHANGED
@@ -16,7 +16,7 @@ import { DEFAULT_EDIT_MODE, getToolMetrics, selectBestMode } from './metrics.js'
16
16
  import { buildDynamicPrompt, createPromptRegistry, registerPromptSection, } from './prompt-registry.js';
17
17
  import { drainSteering } from './steering.js';
18
18
  export { compactContext } from './loop/compaction.js';
19
- export { runSubAgent } from './loop/subagent.js';
19
+ export { runSubAgent, runSubAgents } from './loop/subagent.js';
20
20
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
21
21
  /** provider 未声明 contextWindow 时回填的保守下限(避免估计过大不压缩、过小过度压缩)。 */
22
22
  const FALLBACK_CONTEXT_WINDOW = 32_000;
@@ -53,8 +53,16 @@ function resolveEffectiveContextWindow(state, raw, inputTokens, provider, model)
53
53
  effectiveContextWindowCache.set(state, estimated);
54
54
  return estimated;
55
55
  }
56
+ /**
57
+ * 等待 resume(或 abort)。暂停态只由 status 表达,但 abort 路径不止一条:
58
+ * `abortAgent` 会同时置 stopped,而父→子 abort 联动等 signal-only 路径不会——
59
+ * 只查 status 会让暂停中的 run 在 100ms 空转里永久等待(run 槽位不释放、父 run
60
+ * 的工具批次永不返回、会话 409 RUN_ACTIVE 锁死)。故 signal 中止同样结束等待,
61
+ * 由调用方按「仍是 paused」判定为中止退出。
62
+ */
56
63
  async function waitForResume(state) {
57
- while (true) {
64
+ const signal = state.abortController.signal;
65
+ while (!signal.aborted) {
58
66
  await sleep(100);
59
67
  if (state.status._tag !== 'paused')
60
68
  return;
@@ -85,8 +93,13 @@ export async function* agentLoop(state, deps) {
85
93
  startedAt: state.lastRunStartedAt ?? Date.now(),
86
94
  }).catch(() => { });
87
95
  await waitForResume(state);
88
- if (state.status._tag === 'paused')
89
- return; // 等待期间被 abort 等终结
96
+ // 等待期间被中止(abortAgent 置 stopped / signal-only 中止仍是 paused)
97
+ // 一律结束本 run:继续往下会把状态复位成 running 并再发一次 LLM 请求,
98
+ // 已中止的 run 复活成「运行中」。状态经显式断言重新读取——上方 if 的
99
+ // paused 收窄在 await 后仍生效,直接比较会被 TS 判为无交集。
100
+ const afterWait = state.status;
101
+ if (afterWait._tag !== 'running')
102
+ return;
90
103
  }
91
104
  // 预算护栏:金额 + token 双口径(token 兜底价格未知的自建网关/未登记模型),
92
105
  // 每轮 LLM 请求前检查。每 run 至多触发一次(budgetPauseTriggered),恢复后
@@ -125,7 +138,10 @@ export async function* agentLoop(state, deps) {
125
138
  // budgetPauseTriggered,避免同一超支原因二次暂停打断用户。
126
139
  await markBudgetPause(deps.db, state.session.id, reason).catch(() => { });
127
140
  await waitForResume(state);
128
- if (state.status._tag === 'paused')
141
+ // 同暂停点:等待期间被中止(stopped)或 signal-only 中止(仍 paused)
142
+ // 一律结束本 run,绝不把已中止的 run 复位成 running 再发 LLM 请求。
143
+ const afterBudgetWait = state.status;
144
+ if (afterBudgetWait._tag !== 'running')
129
145
  return;
130
146
  }
131
147
  else {
@@ -203,7 +219,7 @@ export async function* agentLoop(state, deps) {
203
219
  tools: state.tools,
204
220
  config: state.config,
205
221
  projectInfo: detectProjectInfo(deps.cwd),
206
- skills: [],
222
+ skills: deps.skills ?? [],
207
223
  };
208
224
  // spec §16.5:edit 工具模式偏好。仅当该轮启用 edit 且某模式历史成功率
209
225
  // 显著优于默认时,向 system prompt 追加偏好提示,引导模型选择高成功率模式。
@@ -1,9 +1,17 @@
1
1
  import { type Registry } from '../llm/registry.js';
2
2
  import type { Config } from '../shared/types/config.js';
3
3
  import type { ProviderConfig } from '../shared/types/llm.js';
4
- /** 把 config.providers 注册到新建的 LLM registry(修复此前空 registry 的遗漏)。 */
5
- declare function buildRegistryFromConfig(config: Config): Registry;
4
+ /** 注册单个 provider 配置条目(畸形/不可解条目静默跳过,绝不抛错)。
5
+ *
6
+ * 跳过而非击穿的理由:
7
+ * - 手改/旧版配置文件可能携带畸形条目(null/字符串/数组等):读取侧自愈兜底,
8
+ * 否则保存接口 500(syncRegistryFromConfig)或服务/CLI 无法启动。
9
+ * - 机器绑定密文换机后不可解(enc: 前缀的 apiKey 跨机同步/容器重建):跳过该
10
+ * provider 并告警,而非上抛击穿启动/请求路径——密文本就不可跨机解密。
11
+ */
6
12
  declare function registerProviderFromConfig(registry: Registry, p: ProviderConfig): void;
13
+ /** 把 config.providers 注册到新建的 LLM registry(server 启动与 CLI deps 共用)。 */
14
+ declare function buildRegistryFromConfig(config: Config): Registry;
7
15
  /**
8
16
  * config 变更后原子地同步 registry:在隔离的 next registry 上重建全部路由,
9
17
  * 完成后一次性替换 registry 内部 table 引用。运行中的 resolveRoute 任何时刻
@@ -0,0 +1,66 @@
1
+ // config.providers ↔ LLM registry 的构建/同步逻辑(单一权威实现)。
2
+ //
3
+ // 此前 server/registry-config.ts 与 cli/deps.ts 各持一份 registerProviderFromConfig:
4
+ // server 版带「畸形条目跳过」守卫(读取侧自愈兜底),CLI 版没有——手改/旧版
5
+ // config.json 里的畸形条目(null 条目、baseURL 非字符串)在 CLI 路径上让
6
+ // `p.name` / `p.baseURL.replace` 抛 TypeError,而 buildAgentDeps 无 try/catch:
7
+ // c0de chat / print / acp 启动即崩溃(server 路径因守卫而幸免)。
8
+ // 收敛到本模块:server 路由 / CLI deps 共用同一实现,杜绝再次漂移。
9
+ import { createRegistry, overrideToCapabilities, rebuildRegistry, registerProvider, } from '../llm/registry.js';
10
+ import { decryptSecretSafe } from './secret.js';
11
+ /** 注册单个 provider 配置条目(畸形/不可解条目静默跳过,绝不抛错)。
12
+ *
13
+ * 跳过而非击穿的理由:
14
+ * - 手改/旧版配置文件可能携带畸形条目(null/字符串/数组等):读取侧自愈兜底,
15
+ * 否则保存接口 500(syncRegistryFromConfig)或服务/CLI 无法启动。
16
+ * - 机器绑定密文换机后不可解(enc: 前缀的 apiKey 跨机同步/容器重建):跳过该
17
+ * provider 并告警,而非上抛击穿启动/请求路径——密文本就不可跨机解密。
18
+ */
19
+ function registerProviderFromConfig(registry, p) {
20
+ // 写入侧(applyScopedPatch 的 assertValidProviderList)已显式拒绝畸形条目,
21
+ // 此处是读取侧的自愈兜底。
22
+ if (p === null || typeof p !== 'object' || Array.isArray(p))
23
+ return;
24
+ // 兼容 config.json 中以 _tag 标识 provider 的格式(name 缺失时回退到 _tag)
25
+ const name = p.name || p._tag;
26
+ if (!name || typeof p.baseURL !== 'string' || !p.baseURL)
27
+ return;
28
+ const apiKey = typeof p.apiKey === 'string' && p.apiKey ? decryptSecretSafe(p.apiKey) : p.apiKey;
29
+ if (p.apiKey && apiKey === undefined) {
30
+ console.warn(`provider "${name}" 的 apiKey 无法在本机解密(配置来自其他机器或密文损坏),已跳过注册,请重新设置`);
31
+ return;
32
+ }
33
+ // baseURL 已含 /v1 时用 /chat/completions,避免 /v1/v1 双重前缀
34
+ const path = p.baseURL.replace(/\/+$/, '').endsWith('/v1') ? '/chat/completions' : undefined;
35
+ registerProvider(registry, {
36
+ name,
37
+ baseURL: p.baseURL,
38
+ apiKey: apiKey ?? p.apiKey ?? '',
39
+ ...(path ? { path } : {}),
40
+ // 传递用户配置的 per-model capabilities(contextWindow 等),
41
+ // 否则 resolveRoute 回退到 DEFAULT_MODEL_CAPABILITIES,可能导致预算过小。
42
+ ...(p.models ? { models: overrideToCapabilities(p.models) } : {}),
43
+ });
44
+ }
45
+ /** 把 config.providers 注册到新建的 LLM registry(server 启动与 CLI deps 共用)。 */
46
+ function buildRegistryFromConfig(config) {
47
+ const registry = createRegistry();
48
+ for (const p of config.providers) {
49
+ registerProviderFromConfig(registry, p);
50
+ }
51
+ return registry;
52
+ }
53
+ /**
54
+ * config 变更后原子地同步 registry:在隔离的 next registry 上重建全部路由,
55
+ * 完成后一次性替换 registry 内部 table 引用。运行中的 resolveRoute 任何时刻
56
+ * 看到的都是完整的旧表或完整的新表,不会读到「已清空但未注册完」的半状态,
57
+ * 因此不会把本可用的 provider 误判为 NoRoute。ServerContext 立即生效,无需重启。
58
+ */
59
+ function syncRegistryFromConfig(registry, config) {
60
+ rebuildRegistry(registry, (next) => {
61
+ for (const p of config.providers) {
62
+ registerProviderFromConfig(next, p);
63
+ }
64
+ });
65
+ }
66
+ export { buildRegistryFromConfig, registerProviderFromConfig, syncRegistryFromConfig };
@@ -3,7 +3,8 @@
3
3
  * 防止 apiKey / token 等明文出现在聊天界面或终端输出中。
4
4
  */
5
5
  export declare function isSecretKey(key: string): boolean;
6
- /** 掩码敏感值:短值整体掩码;长值保留首尾各 4 位。非字符串统一返回 ****。 */
6
+ /** 掩码敏感值:短值整体掩码;长值保留首尾各 4 位。非字符串统一返回 ****。
7
+ * 首尾切点经 headChars/tailChars 内收,绝不产出孤立代理码元。 */
7
8
  export declare function maskSecret(value: unknown): unknown;
8
9
  /** 递归脱敏:键名命中敏感模式的字段被掩码,其余结构原样保留。 */
9
10
  export declare function redactSecrets(value: unknown, key?: string): unknown;
@@ -2,18 +2,20 @@
2
2
  * 敏感字段脱敏工具:/config 斜杠命令与 `c0de config get` 展示配置时使用,
3
3
  * 防止 apiKey / token 等明文出现在聊天界面或终端输出中。
4
4
  */
5
+ import { headChars, tailChars } from '../shared/utils/string.js';
5
6
  /** 敏感字段名模式(大小写不敏感,匹配完整键名)。 */
6
7
  const SECRET_KEY_RE = /(api[_-]?key|token|password|passwd|secret|authorization|auth[_-]?token)$/i;
7
8
  export function isSecretKey(key) {
8
9
  return SECRET_KEY_RE.test(key);
9
10
  }
10
- /** 掩码敏感值:短值整体掩码;长值保留首尾各 4 位。非字符串统一返回 ****。 */
11
+ /** 掩码敏感值:短值整体掩码;长值保留首尾各 4 位。非字符串统一返回 ****。
12
+ * 首尾切点经 headChars/tailChars 内收,绝不产出孤立代理码元。 */
11
13
  export function maskSecret(value) {
12
14
  if (typeof value !== 'string')
13
15
  return '****';
14
16
  if (value.length <= 8)
15
17
  return '****';
16
- return `${value.slice(0, 4)}****${value.slice(-4)}`;
18
+ return `${headChars(value, 4)}****${tailChars(value, 4)}`;
17
19
  }
18
20
  /** 递归脱敏:键名命中敏感模式的字段被掩码,其余结构原样保留。 */
19
21
  export function redactSecrets(value, key) {
@@ -10,3 +10,8 @@ export declare function encryptSecret(plaintext: string): string;
10
10
  * 解密存储值。带 `enc:` 前缀则解密;否则原样返回(明文兼容,平滑迁移)。
11
11
  */
12
12
  export declare function decryptSecret(stored: string): string;
13
+ /** 解密存储值;失败(机器绑定密钥换机/密文损坏)返回 undefined 而非上抛。
14
+ * 机器绑定意味着密文不可跨机器解密——跨机同步配置(dotfiles/git)后 enc: 值
15
+ * 在本机必然 GCM 校验失败。消费方(registry 构建、websearch key 解析)据此
16
+ * 跳过该 provider/视为无 key 并提示重设,而不是让解密异常击穿启动/请求路径。 */
17
+ export declare function decryptSecretSafe(stored: string): string | undefined;
@@ -49,3 +49,17 @@ export function decryptSecret(stored) {
49
49
  decipher.setAuthTag(tag);
50
50
  return Buffer.concat([decipher.update(enc), decipher.final()]).toString('utf8');
51
51
  }
52
+ /** 解密存储值;失败(机器绑定密钥换机/密文损坏)返回 undefined 而非上抛。
53
+ * 机器绑定意味着密文不可跨机器解密——跨机同步配置(dotfiles/git)后 enc: 值
54
+ * 在本机必然 GCM 校验失败。消费方(registry 构建、websearch key 解析)据此
55
+ * 跳过该 provider/视为无 key 并提示重设,而不是让解密异常击穿启动/请求路径。 */
56
+ export function decryptSecretSafe(stored) {
57
+ if (!stored.startsWith(PREFIX))
58
+ return stored;
59
+ try {
60
+ return decryptSecret(stored);
61
+ }
62
+ catch {
63
+ return undefined;
64
+ }
65
+ }
@@ -0,0 +1,2 @@
1
+ /** 发现当前项目可见的技能名(项目级 + 全局级,去重)。 */
2
+ export declare function discoverSkills(cwd: string): string[];
@@ -0,0 +1,61 @@
1
+ // 技能发现(system-prompt-overhaul plan 遗留的 skills 段数据源 + 主设计 §25
2
+ // 「技能发现」的最小落地):扫描项目 `.c0de/skills/` 与全局 `<config>/skills/`
3
+ // 下每个含 SKILL.md 的目录,取 frontmatter `name`(缺省用目录名)作为技能名,
4
+ // 由宿主注入 LoopDeps.skills → PromptContext.skills → system prompt 的
5
+ // ## Loaded Skills 段。进程生命周期内按 cwd 缓存(技能是 system prompt 材料,
6
+ // 运行中热加载无意义)。
7
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { resolveGlobalConfigDir } from './config.js';
10
+ const SKILL_FILE = 'SKILL.md';
11
+ /** 从 SKILL.md 的 YAML frontmatter 取 name;无 frontmatter/无 name 用目录名。 */
12
+ function parseSkillName(md, fallback) {
13
+ const fm = /^---\s*\n([\s\S]*?)\n---/.exec(md);
14
+ if (fm) {
15
+ const name = /^name:\s*(.+)$/m.exec(fm[1] ?? '');
16
+ if (name?.[1])
17
+ return name[1].trim();
18
+ }
19
+ return fallback;
20
+ }
21
+ /** 扫描一个技能目录:`<dir>/<name>/SKILL.md`。单个条目读取失败跳过。 */
22
+ function scanDir(dir) {
23
+ if (!existsSync(dir))
24
+ return [];
25
+ let entries;
26
+ try {
27
+ entries = readdirSync(dir);
28
+ }
29
+ catch {
30
+ return [];
31
+ }
32
+ const out = [];
33
+ for (const entry of entries) {
34
+ try {
35
+ const path = join(dir, entry);
36
+ if (!statSync(path).isDirectory())
37
+ continue;
38
+ const skillMd = join(path, SKILL_FILE);
39
+ if (!existsSync(skillMd))
40
+ continue;
41
+ out.push(parseSkillName(readFileSync(skillMd, 'utf8'), entry));
42
+ }
43
+ catch {
44
+ // 单个技能损坏不阻断其余
45
+ }
46
+ }
47
+ return out.sort();
48
+ }
49
+ const cache = new Map();
50
+ /** 发现当前项目可见的技能名(项目级 + 全局级,去重)。 */
51
+ export function discoverSkills(cwd) {
52
+ const cached = cache.get(cwd);
53
+ if (cached)
54
+ return cached;
55
+ const names = Array.from(new Set([
56
+ ...scanDir(join(cwd, '.c0de', 'skills')),
57
+ ...scanDir(join(resolveGlobalConfigDir(), 'skills')),
58
+ ]));
59
+ cache.set(cwd, names);
60
+ return names;
61
+ }
@@ -1,5 +1,6 @@
1
1
  import { resolve } from 'node:path';
2
2
  import { createSession, getSession } from '../session/session.js';
3
+ import { isUuid } from '../shared/utils/string.js';
3
4
  import { PROJECT_BOUND_TOOLS, resolveEnabledToolNames } from '../tools/index.js';
4
5
  import { createAgent } from './agent.js';
5
6
  import { BUILTIN_WORKFLOWS, createWorkflowRegistry, discoverWorkflows, executeWorkflow, reloadRegistry, saveWorkflow, workflowSessionTitle, } from './workflows/index.js';
@@ -17,6 +18,11 @@ function parseSlashInput(input) {
17
18
  * 防 Web 端误输/被注入 id 清空或分支其他项目的会话。CLI 无当前会话上下文
18
19
  * (sessionId 缺省)时放行——终端单用户显式操作。 */
19
20
  async function assertSameProjectSession(db, currentId, targetId) {
21
+ // 会话 id 是 uuid 列:非 UUID 经 getSession 的参数化查询在 PG 侧抛 22P02,
22
+ // 错误文本(含 SQL 细节)被透出为斜杠命令的错误消息——格式非法与「不存在」
23
+ // 同属资源不存在,统一为面向用户/模型的干净提示。
24
+ if (!isUuid(targetId))
25
+ throw new Error(`目标会话不存在:${targetId}`);
20
26
  const target = await getSession(db, targetId);
21
27
  if (!target)
22
28
  throw new Error(`目标会话不存在:${targetId}`);
@@ -207,7 +213,7 @@ const forkCommand = {
207
213
  }
208
214
  const { forkSession } = await import('../session/branch.js');
209
215
  try {
210
- const forked = await forkSession(ctx.deps.db, sessionId, messageIndex);
216
+ const forked = await forkSession(ctx.deps.db, sessionId, messageIndex, ctx.deps.hookRunner);
211
217
  return { _tag: 'success', message: `已分支到新会话:${forked.id}` };
212
218
  }
213
219
  catch (error) {
@@ -258,25 +264,32 @@ const configCommand = {
258
264
  message: 'security 是服务端全局参数,仅在全局作用域生效。请在全局配置(~/.c0de/config.json)中设置,或用 c0de config set --global 写入。',
259
265
  };
260
266
  }
261
- const scopes = loadConfigScopes(ctx.cwd);
262
- const value = coerce(parts.slice(1).join(' '));
263
- const next = applyScopedPatch(scopes.project ?? {}, setPathPatch(key, value));
264
- await saveConfigScoped('project', ctx.cwd, next);
265
- // P1:/config 写入项目作用域是用户显式操作——已信任项目刷新风险指纹,
266
- // 防「信任 → 改设置 → 再信任」自锁复检(与 /workflow create 同口径)。
267
267
  try {
268
- const { getByDirectory, trustProject } = await import('../project/index.js');
269
- const p = await getByDirectory(ctx.deps.db, ctx.cwd);
270
- if (p?.trustedAt != null)
271
- await trustProject(ctx.deps.db, p.id);
268
+ const scopes = loadConfigScopes(ctx.cwd);
269
+ const value = coerce(parts.slice(1).join(' '));
270
+ const next = applyScopedPatch(scopes.project ?? {}, setPathPatch(key, value));
271
+ await saveConfigScoped('project', ctx.cwd, next);
272
+ // P1:/config 写入项目作用域是用户显式操作——已信任项目刷新风险指纹,
273
+ // 防「信任 → 改设置 → 再信任」自锁复检(与 /workflow create 同口径)。
274
+ try {
275
+ const { getByDirectory, trustProject } = await import('../project/index.js');
276
+ const p = await getByDirectory(ctx.deps.db, ctx.cwd);
277
+ if (p?.trustedAt != null)
278
+ await trustProject(ctx.deps.db, p.id);
279
+ }
280
+ catch {
281
+ // 指纹刷新失败不阻塞配置写入结果(最坏回到 fail-closed 复检路径)
282
+ }
283
+ return {
284
+ _tag: 'success',
285
+ message: `${value === null ? '已取消设置' : '已设置'} ${key} (scope: project)`,
286
+ };
272
287
  }
273
- catch {
274
- // 指纹刷新失败不阻塞配置写入结果(最坏回到 fail-closed 复检路径)
288
+ catch (error) {
289
+ // 非有限数值(1e999 → Infinity)等写入面校验失败:作为命令错误反馈给
290
+ // 模型/用户,而非击穿 SSE 流显示「连接中断」。
291
+ return { _tag: 'error', message: error instanceof Error ? error.message : String(error) };
275
292
  }
276
- return {
277
- _tag: 'success',
278
- message: `${value === null ? '已取消设置' : '已设置'} ${key} (scope: project)`,
279
- };
280
293
  },
281
294
  };
282
295
  const workflowCommand = {
@@ -487,7 +500,7 @@ const workflowCommand = {
487
500
  plugins: ctx.config.plugins.enabled,
488
501
  agentName: 'default',
489
502
  };
490
- const session = await createSession(ctx.deps.db, workflowSessionTitle(name), workflowProjectId, 'workflow', undefined, undefined, ctx.cwd);
503
+ const session = await createSession(ctx.deps.db, workflowSessionTitle(name), workflowProjectId, 'workflow', undefined, undefined, ctx.cwd, undefined, ctx.deps.hookRunner);
491
504
  const parent = await createAgent(session, agentConfig, ctx.deps);
492
505
  return executeWorkflow({
493
506
  registry,
@@ -1,6 +1,7 @@
1
1
  import { chat as defaultChat } from '../llm/provider.js';
2
2
  import { buildFallbackChain } from '../llm/routing.js';
3
3
  import { updateSessionTitle } from '../session/session.js';
4
+ import { headChars } from '../shared/utils/string.js';
4
5
  /** 会话默认占位标题;仍是该值时才有资格自动生成。 */
5
6
  const DEFAULT_SESSION_TITLE = 'New Session';
6
7
  /** 标题最长字符数,超出截断并加省略号。 */
@@ -72,7 +73,8 @@ function cleanTitle(raw) {
72
73
  .map((l) => l.trim())
73
74
  .find((l) => l.length > 0) ?? '';
74
75
  if (firstLine.length > TITLE_MAX_LENGTH) {
75
- return `${firstLine.slice(0, TITLE_MAX_LENGTH - 3)}...`;
76
+ // headChars 内收切点,避免截断点落在代理对中间产出孤立码元
77
+ return `${headChars(firstLine, TITLE_MAX_LENGTH - 3)}...`;
76
78
  }
77
79
  return firstLine;
78
80
  }
@@ -130,7 +132,7 @@ async function fallbackTitleFromMessage(db, sessionId, firstMessage) {
130
132
  if (!firstLine)
131
133
  return;
132
134
  const title = firstLine.length > TITLE_MAX_LENGTH
133
- ? `${firstLine.slice(0, TITLE_MAX_LENGTH - 3)}...`
135
+ ? `${headChars(firstLine, TITLE_MAX_LENGTH - 3)}...`
134
136
  : firstLine;
135
137
  await updateSessionTitle(db, sessionId, title).catch(() => { });
136
138
  }
@@ -31,10 +31,11 @@ type ApplyTodoTagsResult = {
31
31
  errors: string[];
32
32
  hasView: boolean;
33
33
  };
34
- /** Parse all todo tags from text, preserving document order. */
34
+ /** Parse all todo tags from text, preserving document order.
35
+ * Prose 感知:先剥离围栏代码块与行内代码——助手解释标签语法时的
36
+ * ```/`` 示例不得触发真实状态变更(幽灵 done/start)。与 workflowz
37
+ * 关键词检测共用 markdown.ts 的剥离。 */
35
38
  export declare function parseTodoTags(text: string): ParsedTodoTag[];
36
- /** Resolve a seq string ("1-2" or "1") to a phase and/or task.
37
- * Returns undefined if out of bounds or unparseable. */
38
39
  export declare function resolveSeq(phases: TodoPhase[], seq: string | undefined): {
39
40
  phase: TodoPhase;
40
41
  task?: TodoItem;
@@ -1,6 +1,7 @@
1
1
  // todo-tags: parse <todo:*> tags from assistant text and apply them to todo phases.
2
2
  // Acts as a format-conversion layer on top of todo.ts's applyParams state machine.
3
3
  // Tags are NOT stripped from the text — they remain visible to the user.
4
+ import { stripMarkdownCode } from '../shared/utils/markdown-code.js';
4
5
  import { applyParams, clonePhases, } from '../tools/builtin/todo.js';
5
6
  // =============================================================================
6
7
  // Tag parser
@@ -33,10 +34,13 @@ function parseInitInner(inner) {
33
34
  }
34
35
  return phases;
35
36
  }
36
- /** Parse all todo tags from text, preserving document order. */
37
+ /** Parse all todo tags from text, preserving document order.
38
+ * Prose 感知:先剥离围栏代码块与行内代码——助手解释标签语法时的
39
+ * ```/`` 示例不得触发真实状态变更(幽灵 done/start)。与 workflowz
40
+ * 关键词检测共用 markdown.ts 的剥离。 */
37
41
  export function parseTodoTags(text) {
38
42
  const tags = [];
39
- for (const m of text.matchAll(TAG_RE)) {
43
+ for (const m of stripMarkdownCode(text).matchAll(TAG_RE)) {
40
44
  const op = m[1] ?? '';
41
45
  const attrs = m[2] ?? '';
42
46
  const inner = m[3];
@@ -49,7 +53,7 @@ export function parseTodoTags(text) {
49
53
  break;
50
54
  case 'append': {
51
55
  const phaseMatch = /phase="([^"]+)"/.exec(attrs);
52
- const phaseSeq = phaseMatch ? Number.parseInt(phaseMatch[1] ?? '', 10) : Number.NaN;
56
+ const phaseSeq = phaseMatch ? parseSeqNumber(phaseMatch[1] ?? '') : Number.NaN;
53
57
  tags.push({ op: 'append', phaseSeq, items: parseItems(inner ?? '') });
54
58
  break;
55
59
  }
@@ -74,12 +78,22 @@ export function parseTodoTags(text) {
74
78
  // =============================================================================
75
79
  /** Resolve a seq string ("1-2" or "1") to a phase and/or task.
76
80
  * Returns undefined if out of bounds or unparseable. */
81
+ /**
82
+ * 严格解析 seq 段:仅接受十进制非负整数整体。
83
+ * parseInt 会静默接受尾随垃圾/小数前缀/十六进制('1.5'→1、'1x'→1、'0x2'→16),
84
+ * 畸形 seq 静默命中真实任务/阶段——统一先全串校验再转数值。
85
+ */
86
+ function parseSeqNumber(s) {
87
+ return /^\d+$/.test(s) ? Number(s) : Number.NaN;
88
+ }
77
89
  export function resolveSeq(phases, seq) {
78
90
  if (!seq)
79
91
  return undefined;
80
- const parts = seq.split('-').map((s) => Number.parseInt(s.trim(), 10));
92
+ const parts = seq.split('-').map(parseSeqNumber);
93
+ if (parts.some(Number.isNaN) || parts.length > 2)
94
+ return undefined;
81
95
  const phaseNum = parts[0];
82
- if (phaseNum === undefined || Number.isNaN(phaseNum))
96
+ if (phaseNum === undefined)
83
97
  return undefined;
84
98
  const phaseIdx = phaseNum - 1;
85
99
  if (phaseIdx < 0 || phaseIdx >= phases.length)
@@ -90,7 +104,7 @@ export function resolveSeq(phases, seq) {
90
104
  if (parts.length === 1)
91
105
  return { phase };
92
106
  const taskNum = parts[1];
93
- if (taskNum === undefined || Number.isNaN(taskNum))
107
+ if (taskNum === undefined)
94
108
  return undefined;
95
109
  const taskIdx = taskNum - 1;
96
110
  if (taskIdx < 0 || taskIdx >= phase.tasks.length)