c0de-agent 1.13.1 → 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
@@ -3,6 +3,7 @@ import { eq } from 'drizzle-orm';
3
3
  import { Hono } from 'hono';
4
4
  import { createSummarizer, runCompaction } from '../../core/compact.js';
5
5
  import { loadConfigScopes, mergeConfig } from '../../core/config.js';
6
+ import { buildRegistryFromConfig } from '../../core/provider-registry.js';
6
7
  import { sessions } from '../../db/schema.js';
7
8
  import { buildFallbackChain } from '../../llm/routing.js';
8
9
  import { fromDirectory } from '../../project/index.js';
@@ -10,13 +11,14 @@ import { getProject } from '../../project/project.js';
10
11
  import { archiveOriginalEntries, listArchives } from '../../session/archive.js';
11
12
  import { BranchPointOutOfRangeError, forkSession, getBranches, getTree, } from '../../session/branch.js';
12
13
  import { importSessionData } from '../../session/import.js';
13
- import { deleteEntriesByIds, getMessages, getSteeringAsMessages, insertEntry, } from '../../session/message.js';
14
+ import { getMessages, getSteeringAsMessages, updateEntryContent } from '../../session/message.js';
14
15
  import { createSession, emptyTrash, getLLMSegments, getSession, listDeletedSessions, listOrphanDeletedSessions, listSessions, listSessionsByProject, permanentlyDeleteSession, purgeEmptySession, rebindSession, restoreSessionCore, searchSessions, softDeleteSession, touchLastOpened, touchTrashSeen, updateSessionTitle, } from '../../session/session.js';
15
16
  import { applyShakeRegions, collectShakeRegions, DEFAULT_SHAKE_CONFIG, toRegionViews, } from '../../session/shake.js';
16
17
  import { estimateMessageTokens } from '../../session/token.js';
17
18
  import { generateId } from '../../shared/index.js';
19
+ import { isUuid } from '../../shared/utils/string.js';
18
20
  import { apiError } from '../middleware/error.js';
19
- import { buildRegistryFromConfig } from '../registry-config.js';
21
+ import { readJsonObject } from '../util/json-body.js';
20
22
  import { hasBusySession, resolveAgentCwd } from './chat.js';
21
23
  function createSessionRoute(ctx) {
22
24
  const app = new Hono();
@@ -119,10 +121,16 @@ function createSessionRoute(ctx) {
119
121
  });
120
122
  // 创建会话
121
123
  app.post('/', async (c) => {
122
- const body = (await c.req.json().catch(() => ({})));
123
- const title = body.title ?? 'New Session';
124
- const directory = body.directory;
125
- const explicitProjectId = body.projectId;
124
+ // 畸形 JSON / null / 非对象 body → 400(此前 null body 在 body.title 处 500)。
125
+ const parsed = await readJsonObject(c);
126
+ if (!parsed.ok)
127
+ return parsed.response;
128
+ const { body } = parsed;
129
+ // 非字符串字段(数字 title/directory/projectId)此前经 as 断言进 fromDirectory/
130
+ // createSession:path.resolve 与 text 列参数对非字符串抛错 → 500。按缺省处理。
131
+ const title = typeof body.title === 'string' ? body.title : 'New Session';
132
+ const directory = typeof body.directory === 'string' ? body.directory : undefined;
133
+ const explicitProjectId = typeof body.projectId === 'string' ? body.projectId : undefined;
126
134
  let projectId;
127
135
  if (directory) {
128
136
  const project = await fromDirectory(ctx.db, directory);
@@ -136,7 +144,17 @@ function createSessionRoute(ctx) {
136
144
  if (!projectId) {
137
145
  return apiError(c, 400, 'PROJECT_REQUIRED', '创建会话必须指定项目:传 directory(自动解析)或 projectId');
138
146
  }
139
- const session = await createSession(ctx.db, title, projectId);
147
+ // 显式 projectId 指向 sessions.projectId 外键:不存在的项目此前直接穿透
148
+ // createSession 的 insert,PG 侧 FK violation(23503)击穿 500 并回显 SQL
149
+ // 错误细节——先校验存在性,统一 404(与 kanban guardProject、import/rebind
150
+ // 的目标项目校验同口径)。directory 路径经 fromDirectory 自动 upsert,恒有效。
151
+ if (explicitProjectId) {
152
+ const project = await getProject(ctx.db, explicitProjectId);
153
+ if (!project) {
154
+ return apiError(c, 404, 'PROJECT_NOT_FOUND', '目标项目不存在');
155
+ }
156
+ }
157
+ const session = await createSession(ctx.db, title, projectId, undefined, undefined, undefined, undefined, undefined, ctx.hookRunner);
140
158
  return c.json(session, 201);
141
159
  });
142
160
  // 列出会话
@@ -193,6 +211,20 @@ function createSessionRoute(ctx) {
193
211
  const count = await emptyTrash(ctx.db, projectId);
194
212
  return c.json({ ok: true, deleted: count });
195
213
  });
214
+ // 路径参数 id 是 sessions.id(uuid 列):非 UUID 直接 404。
215
+ // 此前非 UUID 经 drizzle 参数化查询在 PG 侧抛 22P02(invalid input syntax for
216
+ // type uuid),Hono 兜成 500 且把 SQL 错误细节回给客户端;对调用方而言
217
+ // 「不存在的会话」与「格式非法的 id」同属资源不存在,统一 404。
218
+ // 必须注册在上方全部静态路径(/import、/tree、/search、/deleted*)之后:
219
+ // Hono 按注册序分派,静态路由先命中即返回,不会被本守卫拦截。
220
+ const guardSessionId = async (c, next) => {
221
+ if (!isUuid(c.req.param('id') ?? '')) {
222
+ return apiError(c, 404, 'NOT_FOUND', '会话不存在或已删除');
223
+ }
224
+ await next();
225
+ };
226
+ app.use('/:id', guardSessionId);
227
+ app.use('/:id/*', guardSessionId);
196
228
  // 获取会话详情
197
229
  app.get('/:id', async (c) => {
198
230
  try {
@@ -209,8 +241,10 @@ function createSessionRoute(ctx) {
209
241
  // P2-5:会话重命名(此前标题只能由 LLM 自动生成,用户无法修改)
210
242
  app.patch('/:id', async (c) => {
211
243
  const id = c.req.param('id');
212
- const body = (await c.req.json().catch(() => ({})));
213
- const title = typeof body.title === 'string' ? body.title.trim() : '';
244
+ const parsed = await readJsonObject(c);
245
+ if (!parsed.ok)
246
+ return parsed.response;
247
+ const title = typeof parsed.body.title === 'string' ? parsed.body.title.trim() : '';
214
248
  if (!title)
215
249
  return apiError(c, 400, 'BAD_REQUEST', 'title is required');
216
250
  if (title.length > 120) {
@@ -247,8 +281,11 @@ function createSessionRoute(ctx) {
247
281
  if (busy === 'starting') {
248
282
  return apiError(c, 409, 'RUN_STARTING', '该会话的对话正在启动,请稍后重试');
249
283
  }
250
- const body = await c.req.json().catch(() => ({}));
251
- let messageIndex = body.messageIndex;
284
+ const parsed = await readJsonObject(c);
285
+ if (!parsed.ok)
286
+ return parsed.response;
287
+ const rawIndex = parsed.body.messageIndex;
288
+ let messageIndex = typeof rawIndex === 'number' && Number.isFinite(rawIndex) ? rawIndex : undefined;
252
289
  if (messageIndex === undefined || !Number.isFinite(messageIndex)) {
253
290
  const messages = await getMessages(ctx.db, id);
254
291
  if (messages.length === 0) {
@@ -257,7 +294,7 @@ function createSessionRoute(ctx) {
257
294
  messageIndex = messages.length - 1;
258
295
  }
259
296
  try {
260
- const forked = await forkSession(ctx.db, id, messageIndex);
297
+ const forked = await forkSession(ctx.db, id, messageIndex, ctx.hookRunner);
261
298
  return c.json(forked, 201);
262
299
  }
263
300
  catch (error) {
@@ -393,7 +430,10 @@ function createSessionRoute(ctx) {
393
430
  // 显式跳过重建、直接归属到请求项目(用户在确认框中二选一)。
394
431
  app.post('/:id/restore', async (c) => {
395
432
  const id = c.req.param('id');
396
- const body = (await c.req.json().catch(() => ({})));
433
+ const parsed = await readJsonObject(c);
434
+ if (!parsed.ok)
435
+ return parsed.response;
436
+ const { body } = parsed;
397
437
  const requestProjectId = typeof body.projectId === 'string' && body.projectId ? body.projectId : undefined;
398
438
  const preferCurrentProject = body.restoreMode === 'current-project';
399
439
  const result = await restoreSessionCore(ctx.db, id);
@@ -455,8 +495,12 @@ function createSessionRoute(ctx) {
455
495
  if (busy === 'starting') {
456
496
  return apiError(c, 409, 'RUN_STARTING', '该会话的对话正在启动,请稍后重试');
457
497
  }
458
- const body = (await c.req.json().catch(() => ({})));
459
- const projectId = typeof body.projectId === 'string' && body.projectId ? body.projectId : '';
498
+ const parsed = await readJsonObject(c);
499
+ if (!parsed.ok)
500
+ return parsed.response;
501
+ const projectId = typeof parsed.body.projectId === 'string' && parsed.body.projectId
502
+ ? parsed.body.projectId
503
+ : '';
460
504
  if (!projectId)
461
505
  return apiError(c, 400, 'PROJECT_REQUIRED', 'projectId is required');
462
506
  const project = await getProject(ctx.db, projectId);
@@ -618,7 +662,15 @@ function createSessionRoute(ctx) {
618
662
  }
619
663
  if (!session)
620
664
  return apiError(c, 404, 'NOT_FOUND', 'Session not found');
621
- const body = await c.req.json().catch(() => ({}));
665
+ const parsed = await readJsonObject(c);
666
+ if (!parsed.ok)
667
+ return parsed.response;
668
+ const { body } = parsed;
669
+ // regionIds 非数组(字符串等)会让后续 .filter 抛 TypeError 500——
670
+ // 显式 400(与 regionIds 不匹配同口径)。
671
+ if (body.regionIds !== undefined && !Array.isArray(body.regionIds)) {
672
+ return apiError(c, 400, 'INVALID_REGIONS', 'regionIds 必须是数组');
673
+ }
622
674
  const regionIds = body.regionIds ?? [];
623
675
  const messages = await getMessages(ctx.db, id);
624
676
  const manualConfig = { ...DEFAULT_SHAKE_CONFIG, protectTokens: 0, minSavings: 0 };
@@ -640,19 +692,13 @@ function createSessionRoute(ctx) {
640
692
  const totalTokens = selected.reduce((sum, r) => sum + r.tokens, 0);
641
693
  await archiveOriginalEntries(ctx.db, id, originalMessages, 'shake', `Shaken ${selected.length} regions, saved ${totalTokens} tokens`, archiveId);
642
694
  const shakenMessages = applyShakeRegions(messages, selected);
643
- await deleteEntriesByIds(ctx.db, affectedIds);
695
+ // 就地改写受影响条目(此前「删除 + 原时间戳重插」):重插的行落到物理末尾,
696
+ // 时间戳并列的会话(fork 复制/跨机导入的历史数据)里被 shake 的早期消息会
697
+ // 跳到时间线最后——内容替换不应改变条目位置。
644
698
  for (const msg of shakenMessages) {
645
699
  if (!affectedIds.includes(msg.id))
646
700
  continue;
647
- await insertEntry(ctx.db, {
648
- id: msg.id,
649
- sessionId: id,
650
- tag: 'message',
651
- role: msg.role,
652
- content: msg.content,
653
- tokenCount: estimateMessageTokens(msg.content),
654
- createdAt: new Date(msg.createdAt),
655
- });
701
+ await updateEntryContent(ctx.db, msg.id, msg.content, estimateMessageTokens(msg.content));
656
702
  }
657
703
  return c.json({ shaken: selected.length, archiveId });
658
704
  });
@@ -4,6 +4,7 @@ import { isAbsolute } from 'node:path';
4
4
  import { Hono } from 'hono';
5
5
  import { getProject } from '../../project/index.js';
6
6
  import { apiError } from '../middleware/error.js';
7
+ import { readJsonObject } from '../util/json-body.js';
7
8
  /** 允许的 shell 白名单(P2-6:拒绝路径分隔符与任意二进制)。 */
8
9
  const ALLOWED_SHELLS = new Set(['bash', 'zsh', 'fish', 'sh']);
9
10
  /** 创建终端路由(REST 部分;WebSocket 升级在 server.ts 中处理)。 */
@@ -16,7 +17,11 @@ function createTerminalRoute(ctx) {
16
17
  });
17
18
  // 创建新 PTY 会话
18
19
  app.post('/', async (c) => {
19
- const body = await c.req.json().catch(() => ({}));
20
+ // 畸形 JSON / null / 非对象 body → 400(此前 null body 在 body.projectId 处 500)。
21
+ const parsed = await readJsonObject(c);
22
+ if (!parsed.ok)
23
+ return parsed.response;
24
+ const { body } = parsed;
20
25
  const projectId = typeof body.projectId === 'string' && body.projectId.length > 0 ? body.projectId : undefined;
21
26
  // P2-13:未显式传 cwd 且带 projectId 时,默认目录跟随项目 worktree
22
27
  // (与 chat 的 resolveAgentCwd 行为一致;项目缺失/目录失效时回退 ctx.cwd)。
@@ -67,7 +72,10 @@ function createTerminalRoute(ctx) {
67
72
  const info = mgr.get(id);
68
73
  if (!info)
69
74
  return apiError(c, 404, 'PTY_NOT_FOUND', 'Terminal not found');
70
- const body = await c.req.json().catch(() => ({}));
75
+ const parsed = await readJsonObject(c);
76
+ if (!parsed.ok)
77
+ return parsed.response;
78
+ const { body } = parsed;
71
79
  if (body.cols != null && body.rows != null) {
72
80
  const cols = Number(body.cols);
73
81
  const rows = Number(body.rows);
@@ -3,7 +3,9 @@ import { Hono } from 'hono';
3
3
  import { appendMessage, getMessages } from '../../session/message.js';
4
4
  import { getSession } from '../../session/session.js';
5
5
  import { generateId } from '../../shared/index.js';
6
+ import { isUuid } from '../../shared/utils/string.js';
6
7
  import { formatSummary, getLatestTodoPhasesFromMessages, todoTool, } from '../../tools/builtin/todo.js';
8
+ import { validateInput } from '../../tools/validate.js';
7
9
  import { apiError } from '../middleware/error.js';
8
10
  /** 构造仅供 todo tool execute 使用的最小 ToolContext。 */
9
11
  function makeTodoCtx(phases, abort) {
@@ -22,6 +24,15 @@ function makeTodoCtx(phases, abort) {
22
24
  }
23
25
  function createTodoRoute(ctx) {
24
26
  const app = new Hono();
27
+ // 路径参数 sessionId 是 sessions.id(uuid 列):非 UUID 直接 404。
28
+ // 此前非 UUID 经 drizzle 查询在 PG 侧抛 22P02(invalid input syntax for type
29
+ // uuid)→ Hono 500 且回显 SQL 错误细节;格式非法与「会话不存在」同属 404。
30
+ app.use('/:sessionId', async (c, next) => {
31
+ if (!isUuid(c.req.param('sessionId') ?? '')) {
32
+ return apiError(c, 404, 'NOT_FOUND', '会话不存在或已删除');
33
+ }
34
+ await next();
35
+ });
25
36
  // GET /:sessionId — 获取当前 todo 状态
26
37
  app.get('/:sessionId', async (c) => {
27
38
  const sessionId = c.req.param('sessionId');
@@ -47,6 +58,13 @@ function createTodoRoute(ctx) {
47
58
  if (!session) {
48
59
  return apiError(c, 404, 'NOT_FOUND', 'Session not found');
49
60
  }
61
+ // 与工具执行管线同口径校验入参:本路由绕过 executor 直调 todoTool.execute,
62
+ // 此前畸形 body(list 非数组 → initPhases 对字符串 for-of 后 .map TypeError 500;
63
+ // items 含非字符串 → 毒化状态原样持久化)无任何校验。非法显式 400 让前端可操作。
64
+ const validation = validateInput(todoTool.parameters, body);
65
+ if (!validation.valid) {
66
+ return apiError(c, 400, 'INVALID_TODO_INPUT', `无效的 todo 操作参数:${validation.error}`);
67
+ }
50
68
  // 恢复当前 phases
51
69
  const run = ctx.agentManager.get(sessionId);
52
70
  let currentPhases;
@@ -35,8 +35,19 @@ function createToolRoute(ctx) {
35
35
  });
36
36
  // 确认工具执行权限
37
37
  app.post('/confirm', async (c) => {
38
- const body = await c.req.json();
39
- const ok = ctx.permissionStore.resolve(body.toolCallId, body.approved);
38
+ // 畸形 JSON / 非对象 body → 400(与其他写端点同口径:.catch + 显式类型校验)。
39
+ // 此前裸 await c.req.json():畸形 JSON 击穿 Hono 500,null body 在
40
+ // body.toolCallId 处 TypeError 500。
41
+ const body = (await c.req.json().catch(() => null));
42
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) {
43
+ return apiError(c, 400, 'BAD_REQUEST', '请求体必须是 JSON 对象');
44
+ }
45
+ if (typeof body.toolCallId !== 'string' || body.toolCallId.length === 0) {
46
+ return apiError(c, 400, 'BAD_REQUEST', 'toolCallId is required');
47
+ }
48
+ // 仅显式 true 放行:非布尔("false" 字符串等 truthy 垃圾)按拒绝处理——
49
+ // 绝不让客户端序列化缺陷把「拒绝」翻成「放行」。
50
+ const ok = ctx.permissionStore.resolve(body.toolCallId, body.approved === true);
40
51
  if (!ok) {
41
52
  return apiError(c, 404, 'NOT_FOUND', 'No pending permission for this tool call');
42
53
  }
@@ -3,6 +3,7 @@ import { getSession } from '../../session/session.js';
3
3
  import { getCurrentVersion, manualInstallCommand, performHandoff, performInstall, serializeSessions, } from '../../update/index.js';
4
4
  import { apiError } from '../middleware/error.js';
5
5
  import { captureForegroundCommand } from '../terminal/pty-manager.js';
6
+ import { readJsonObject } from '../util/json-body.js';
6
7
  /**
7
8
  * GET /api/update — 返回后台调度器缓存的版本检查结果(spec §18.1)。
8
9
  * 路由本身不触发 npm registry 请求;调度器周期性 checkNow 写入缓存,
@@ -107,7 +108,10 @@ function createUpdateRoute(ctx) {
107
108
  return apiError(c, 409, 'HOT_UPDATE_UNAVAILABLE', '热更新仅在独立 serve 进程可用(dev 模式请手动更新)');
108
109
  }
109
110
  // P3-7:用户勾选「更新后自动重启」的终端 id;命中者在快照中携带前台命令。
110
- const body = (await c.req.json().catch(() => ({})));
111
+ const parsed = await readJsonObject(c);
112
+ if (!parsed.ok)
113
+ return parsed.response;
114
+ const body = parsed.body;
111
115
  const rerunIds = new Set(Array.isArray(body.rerunTerminalIds)
112
116
  ? body.rerunTerminalIds.filter((x) => typeof x === 'string')
113
117
  : []);
@@ -5,15 +5,18 @@ import { Hono } from 'hono';
5
5
  import { streamSSE } from 'hono/streaming';
6
6
  import { createAgent } from '../../core/agent.js';
7
7
  import { loadConfigScopes, mergeConfig, resolveGlobalConfigDir } from '../../core/config.js';
8
+ import { buildRegistryFromConfig } from '../../core/provider-registry.js';
9
+ import { discoverSkills } from '../../core/skills.js';
8
10
  import { discoverWorkflows, executeWorkflow, resolveWorkflow, saveWorkflow, workflowSessionTitle, } from '../../core/workflows/index.js';
9
11
  import { reloadRegistry } from '../../core/workflows/registry.js';
12
+ import { createDebugSpawn } from '../../dap/index.js';
10
13
  import { getByDirectory, getProject, trustProject } from '../../project/project.js';
11
14
  import { enrichProjectRiskWithGlobal, projectTrustCurrent, projectTrustNeeded, } from '../../project/trust.js';
12
15
  import { createSession, updateSessionLastRun } from '../../session/session.js';
13
16
  import { PROJECT_BOUND_TOOLS, resolveEnabledToolNames } from '../../tools/index.js';
14
17
  import { apiError } from '../middleware/error.js';
15
18
  import { createInteractivePermissionChecker } from '../permission/interactive.js';
16
- import { buildRegistryFromConfig } from '../registry-config.js';
19
+ import { readJsonObject } from '../util/json-body.js';
17
20
  import { childRunBridge, startHeartbeat } from './chat.js';
18
21
  /**
19
22
  * 按名字查找运行中的工作流 run:遍历 busy 作用域占位(workflowBusyByScope 的
@@ -108,8 +111,10 @@ function createWorkflowsRoute(ctx) {
108
111
  if (!registry) {
109
112
  return apiError(c, 500, 'NOT_INITIALIZED', 'Workflow registry not initialized');
110
113
  }
111
- const body = await c.req.json().catch(() => ({}));
112
- const { name, source, target, projectId, overwrite } = body;
114
+ const parsed = await readJsonObject(c);
115
+ if (!parsed.ok)
116
+ return parsed.response;
117
+ const { name, source, target, projectId, overwrite } = parsed.body;
113
118
  if (!name || typeof name !== 'string') {
114
119
  return apiError(c, 400, 'BAD_REQUEST', 'Missing required field: name');
115
120
  }
@@ -263,8 +268,10 @@ function createWorkflowsRoute(ctx) {
263
268
  if (!entry) {
264
269
  return apiError(c, 404, 'NOT_FOUND', `Workflow "${name}" not found`);
265
270
  }
266
- const body = await c.req.json().catch(() => ({}));
267
- const args = body.args ?? '';
271
+ const parsed = await readJsonObject(c);
272
+ if (!parsed.ok)
273
+ return parsed.response;
274
+ const args = typeof parsed.body.args === 'string' ? parsed.body.args : '';
268
275
  // 会话级配置:按 agent cwd 解析项目作用域合并(与 chat 路由同口径);
269
276
  // 项目配置含 providers 时构建/复用项目级 LLM 注册表。
270
277
  const sessionProjectScope = agentCwd === ctx.cwd ? undefined : loadConfigScopes(agentCwd).project;
@@ -297,7 +304,7 @@ function createWorkflowsRoute(ctx) {
297
304
  try {
298
305
  session = await createSession(ctx.db, workflowSessionTitle(name), project?.id ?? undefined, 'workflow', undefined, undefined, project?.worktree ?? agentCwd,
299
306
  // 中断恢复指引需要工作流名(重启后会话内无 user 消息可重发)。
300
- { workflowName: name });
307
+ { workflowName: name }, ctx.hookRunner);
301
308
  }
302
309
  catch (e) {
303
310
  ctx.workflowBusyByScope.delete(scopeKey);
@@ -396,6 +403,10 @@ function createWorkflowsRoute(ctx) {
396
403
  cwd: agentCwd,
397
404
  agentRegistry: ctx.agentRegistry,
398
405
  registerChildRun: childRunBridge(ctx.agentManager),
406
+ // 技能发现(system prompt ## Loaded Skills 段数据源)。
407
+ skills: discoverSkills(agentCwd),
408
+ // DAP(spec §21):与主 chat 通道同口径注入真实适配器 spawn。
409
+ debugSpawn: createDebugSpawn(),
399
410
  // 预算护栏:与 chat 路由同口径(金额/token 任一轴 pause/abort 即启用)。
400
411
  ...(sessionConfig.usage?.budgetAction === 'pause' ||
401
412
  sessionConfig.usage?.budgetAction === 'abort' ||
@@ -1,8 +1,8 @@
1
1
  import type { Hono } from 'hono';
2
+ import { buildRegistryFromConfig, syncRegistryFromConfig } from '../core/provider-registry.js';
2
3
  import type { DB } from '../db/client.js';
3
4
  import type { Config } from '../shared/types/config.js';
4
5
  import { checkForUpdate, createHandoffServer } from '../update/index.js';
5
- import { buildRegistryFromConfig, syncRegistryFromConfig } from './registry-config.js';
6
6
  import type { ServerContext } from './types.js';
7
7
  type StartServerOptions = {
8
8
  port?: number;
@@ -8,9 +8,11 @@ import { serve } from '@hono/node-server';
8
8
  import { WebSocketServer } from 'ws';
9
9
  import { BUILTIN_AGENTS, createAgentRegistry } from '../core/agents/index.js';
10
10
  import { loadConfig, loadConfigScopes } from '../core/config.js';
11
+ import { buildRegistryFromConfig, syncRegistryFromConfig } from '../core/provider-registry.js';
11
12
  import { createAndPopulateRegistry } from '../core/workflows/index.js';
12
13
  import { createDB, migrateDB } from '../db/index.js';
13
14
  import { purgeDeletedKanbanBoards } from '../kanban/index.js';
15
+ import { collectScopedMCPServers, disconnectMCPServer, registerMCPServers } from '../mcp/index.js';
14
16
  import { initPlugins } from '../plugins/index.js';
15
17
  import { getByDirectory } from '../project/index.js';
16
18
  import { projectTrustCurrent } from '../project/trust.js';
@@ -24,7 +26,6 @@ import { createApp } from './app.js';
24
26
  import { createAuthManager } from './auth-manager.js';
25
27
  import { isAllowedOrigin } from './middleware/cors.js';
26
28
  import { createPermissionStore } from './permission/store.js';
27
- import { buildRegistryFromConfig, syncRegistryFromConfig } from './registry-config.js';
28
29
  import { PTYManager } from './terminal/pty-manager.js';
29
30
  /** serve 默认端口:避开 node 生态常用端口(3000/4000/5173/8000/8080…),
30
31
  * 降低与其他本地服务撞端口的概率。显式 --port 始终优先。 */
@@ -188,13 +189,18 @@ async function buildServerContext(db, opts = {}) {
188
189
  catch {
189
190
  // 信任状态查询失败 → 保持未信任(宁可少加载插件)
190
191
  }
191
- const { pluginRegistry, hookRunner } = await initPlugins({
192
+ const { pluginRegistry, hookRunner, config: resolvedConfig, } = await initPlugins({
192
193
  cwd,
193
194
  config,
194
195
  toolRegistry,
195
196
  llmRegistry,
196
197
  projectTrusted,
197
198
  });
199
+ // MCP 客户端(spec §6):把配置中的 MCP 服务器工具适配进 toolRegistry,对 agent
200
+ // 透明。全局作用域(用户本机显式配置)始终连接;项目作用域(随 git clone 传播的
201
+ // 任意命令执行面)仅在项目已信任时连接——与插件/工作流同口径。逐台隔离:单台
202
+ // 失败告警跳过,不拖垮启动。
203
+ const { sessions: mcpSessions } = await registerMCPServers(toolRegistry, collectScopedMCPServers(loadConfigScopes(cwd), projectTrusted));
198
204
  // 工作流注册表:三级发现(builtin → global → project),eager 初始化。
199
205
  // 此前是惰性 getter 只注册 builtin,导致项目级 .c0de/workflows/*.js 永远不可见。
200
206
  // P0 信任边界:项目级工作流与 .c0de/plugins 同为仓库自带任意代码执行面,
@@ -204,14 +210,15 @@ async function buildServerContext(db, opts = {}) {
204
210
  const dataDir = resolveDbDir();
205
211
  // 先解析/生成 bootstrap token(落盘),再创建 authManager 读取——
206
212
  // 首启时文件由 resolveAuthToken 生成,顺序颠倒会导致 bootstrap 读到空值。
207
- const resolvedToken = resolveAuthToken(config, dataDir);
213
+ // 自 initPlugins 之后一律用 resolvedConfig(config:resolve hook 的产物)。
214
+ const resolvedToken = resolveAuthToken(resolvedConfig, dataDir);
208
215
  const authManager = createAuthManager({
209
216
  dataDir,
210
- ...(config.security.token && config.security.token.length > 0
211
- ? { staticToken: config.security.token }
217
+ ...(resolvedConfig.security.token && resolvedConfig.security.token.length > 0
218
+ ? { staticToken: resolvedConfig.security.token }
212
219
  : {}),
213
- ...(config.security.firstDeviceTtlMs !== undefined
214
- ? { firstDeviceTtlMs: config.security.firstDeviceTtlMs }
220
+ ...(resolvedConfig.security.firstDeviceTtlMs !== undefined
221
+ ? { firstDeviceTtlMs: resolvedConfig.security.firstDeviceTtlMs }
215
222
  : {}),
216
223
  });
217
224
  const ptyManager = new PTYManager();
@@ -244,7 +251,7 @@ async function buildServerContext(db, opts = {}) {
244
251
  }
245
252
  const ctx = {
246
253
  db,
247
- config,
254
+ config: resolvedConfig,
248
255
  toolRegistry,
249
256
  llmRegistry,
250
257
  urlRegistry,
@@ -254,20 +261,22 @@ async function buildServerContext(db, opts = {}) {
254
261
  // P3:权限双层超时可配置(config.permission.timeoutMs/expireGraceMs);
255
262
  // 缺省回退 store 内置默认(5 分钟 + 25 分钟)。
256
263
  permissionStore: createPermissionStore({
257
- ...(config.permission.timeoutMs !== undefined && config.permission.timeoutMs > 0
258
- ? { timeoutMs: config.permission.timeoutMs }
264
+ ...(resolvedConfig.permission.timeoutMs !== undefined &&
265
+ resolvedConfig.permission.timeoutMs > 0
266
+ ? { timeoutMs: resolvedConfig.permission.timeoutMs }
259
267
  : {}),
260
- ...(config.permission.expireGraceMs !== undefined && config.permission.expireGraceMs > 0
261
- ? { expireGraceMs: config.permission.expireGraceMs }
268
+ ...(resolvedConfig.permission.expireGraceMs !== undefined &&
269
+ resolvedConfig.permission.expireGraceMs > 0
270
+ ? { expireGraceMs: resolvedConfig.permission.expireGraceMs }
262
271
  : {}),
263
272
  }),
264
- permissionMode: config.permission.defaultMode,
273
+ permissionMode: resolvedConfig.permission.defaultMode,
265
274
  sessionPermissionModes: new Map(),
266
275
  sessionAlwaysAllow: new Map(),
267
276
  workflowBusyBySession: new Map(),
268
277
  workflowBusyByScope: new Map(),
269
278
  authToken: resolvedToken,
270
- authManager: config.security.authEnabled === false ? undefined : authManager,
279
+ authManager: resolvedConfig.security.authEnabled === false ? undefined : authManager,
271
280
  // Agent 注册表:内置 4 个默认 agent;项目/用户自定义 agent 可在启动后补充加载。
272
281
  agentRegistry: (() => {
273
282
  const reg = createAgentRegistry();
@@ -279,11 +288,13 @@ async function buildServerContext(db, opts = {}) {
279
288
  // spec §18.1 后台版本检查调度器;config.update.enabled 控制是否启动。
280
289
  updateScheduler: createUpdateScheduler({
281
290
  checkFn: opts.checkForUpdateFn ?? checkForUpdate,
282
- intervalMs: config.update.intervalMs,
283
- initialDelayMs: config.update.initialDelayMs,
291
+ intervalMs: resolvedConfig.update.intervalMs,
292
+ initialDelayMs: resolvedConfig.update.initialDelayMs,
284
293
  }),
285
294
  cwd,
286
295
  ptyManager,
296
+ // MCP 会话句柄:关闭时统一断开(stdio 子进程由 close 杀死)。
297
+ ...(mcpSessions.length > 0 ? { mcpSessions } : {}),
287
298
  };
288
299
  // handoff server 不在此创建:它需要持有主 HTTP server 引用以触发完整关停,
289
300
  // 由 startServer 在 serve() 之后创建(dev 模式走 vite,不创建)。
@@ -291,12 +302,15 @@ async function buildServerContext(db, opts = {}) {
291
302
  ctx,
292
303
  dispose: async () => {
293
304
  // dev 重建前调用:中止活跃 run + settle pending permission +
294
- // 停 scheduler + 关 devices.json watcher。**不 close db**(调用方持有)。
305
+ // 停 scheduler + 关 devices.json watcher + 断开 MCP 连接。
306
+ // **不 close db**(调用方持有)。
295
307
  ctx.agentManager.dispose();
296
308
  ctx.permissionStore.dispose();
297
309
  ctx.updateScheduler.stop();
298
310
  ctx.ptyManager.dispose();
299
311
  ctx.authManager?.dispose();
312
+ for (const session of ctx.mcpSessions ?? [])
313
+ disconnectMCPServer(session);
300
314
  },
301
315
  };
302
316
  }
@@ -34,6 +34,9 @@ export interface CreatePTYOptions {
34
34
  * /etc/passwd(getpwuid),是用户真实登录 shell 的可靠来源。
35
35
  */
36
36
  export declare function detectShell(): string;
37
+ /** 截断标题到 MAX_TITLE_LEN 内。省略号本身计入上限(此前 slice(100) + '…'
38
+ * 产出 101 字符,违反上限契约)。切点经 headChars 内收,不拆代理对。 */
39
+ export declare function truncateTitle(title: string): string;
37
40
  /** 把 argv 拼回可安全经 shell 重放的命令行(导出便于单测安全转义)。 */
38
41
  export declare function argvToCommand(argv: string[]): string;
39
42
  /**
@@ -4,6 +4,7 @@ import { mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node
4
4
  import { tmpdir, userInfo } from 'node:os';
5
5
  import { basename, join } from 'node:path';
6
6
  import { spawn } from 'node-pty';
7
+ import { headChars, tailChars } from '../../shared/utils/string.js';
7
8
  /**
8
9
  * 为指定 shell 创建 OSC 133 prompt 标记注入脚本。
9
10
  *
@@ -93,9 +94,11 @@ const MAX_ROWS = 500;
93
94
  const MAX_TITLE_LEN = 100;
94
95
  /** scrollback 环形缓冲最大字节数(约 50KB)。 */
95
96
  const SCROLLBACK_MAX = 50_000;
96
- function truncateTitle(title) {
97
+ /** 截断标题到 MAX_TITLE_LEN 内。省略号本身计入上限(此前 slice(100) + '…'
98
+ * 产出 101 字符,违反上限契约)。切点经 headChars 内收,不拆代理对。 */
99
+ export function truncateTitle(title) {
97
100
  const clean = title.replace(/[\r\n]/g, ' ').trim();
98
- return clean.length > MAX_TITLE_LEN ? `${clean.slice(0, MAX_TITLE_LEN)}…` : clean;
101
+ return clean.length > MAX_TITLE_LEN ? `${headChars(clean, MAX_TITLE_LEN - 1)}…` : clean;
99
102
  }
100
103
  /** POSIX 单引号转义:仅安全字符(字母数字与常见路径/别名符号)原样,否则单引号包裹。 */
101
104
  function shellQuote(arg) {
@@ -187,7 +190,9 @@ export class PTYManager {
187
190
  pty.onData((data) => {
188
191
  entry.scrollback += data;
189
192
  if (entry.scrollback.length > SCROLLBACK_MAX) {
190
- entry.scrollback = entry.scrollback.slice(-SCROLLBACK_MAX);
193
+ // tailChars 内收切点:环形缓冲修剪不落在代理对中间——否则回放给新挂载
194
+ // 终端的历史头部以 U+FFFD 损坏显示(slice(-N) 按 UTF-16 码元硬切)。
195
+ entry.scrollback = tailChars(entry.scrollback, SCROLLBACK_MAX);
191
196
  }
192
197
  for (const ws of entry.sockets) {
193
198
  if (ws.readyState === ws.OPEN) {
@@ -73,6 +73,8 @@ type ServerContext = {
73
73
  cwd: string;
74
74
  /** 终端 PTY 管理器(Web 终端面板用)。 */
75
75
  ptyManager: PTYManager;
76
+ /** 已连接的 MCP 会话(spec §6);dispose 时统一断开。 */
77
+ mcpSessions?: import('../mcp/types.js').MCPSession[];
76
78
  /** 测试注入:覆盖 LLM chat stream。生产环境为 undefined。 */
77
79
  chatStream?: typeof chatStreamFn;
78
80
  };
@@ -0,0 +1,31 @@
1
+ import type { Context } from 'hono';
2
+ /** readJsonObject 的判别结果:ok=false 时 response 为可直接 return 的 400。 */
3
+ export type JsonObjectResult = {
4
+ ok: true;
5
+ body: Record<string, unknown>;
6
+ } | {
7
+ ok: false;
8
+ response: Response;
9
+ };
10
+ /**
11
+ * 读取「必须是 JSON 对象」的请求体。
12
+ *
13
+ * 此前各写端点用 `await c.req.json().catch(() => ({}))` 兜底畸形 JSON——但
14
+ * `JSON.parse('null')` 是合法 JSON,`c.req.json()` 会成功解析出 `null`,
15
+ * catch 不触发:随后 `body.xxx` 在 null 上取属性直接 TypeError 击穿 Hono 500。
16
+ * 数组/字符串/数字 body 同型(`body.foo` 静默 undefined,字段被当作缺省,
17
+ * 与「请求体畸形」应有的显式反馈不符)。
18
+ *
19
+ * 口径:
20
+ * - 无 body / 空 body(curl -X POST 不带载荷)→ `{}`:这些端点的字段均为可选,
21
+ * 保持既有契约(既有测试与脚本依赖无载荷 POST)。
22
+ * - 畸形 JSON / null / 数组 / 标量 body → 400(此前 null 击穿 500、
23
+ * 其余静默当空对象)。
24
+ * - 合法 JSON 对象 → 原样返回。
25
+ *
26
+ * 调用方写法:
27
+ * const parsed = await readJsonObject(c)
28
+ * if (!parsed.ok) return parsed.response
29
+ * const { body } = parsed
30
+ */
31
+ export declare function readJsonObject(c: Context): Promise<JsonObjectResult>;
@@ -0,0 +1,38 @@
1
+ import { apiError } from '../middleware/error.js';
2
+ /**
3
+ * 读取「必须是 JSON 对象」的请求体。
4
+ *
5
+ * 此前各写端点用 `await c.req.json().catch(() => ({}))` 兜底畸形 JSON——但
6
+ * `JSON.parse('null')` 是合法 JSON,`c.req.json()` 会成功解析出 `null`,
7
+ * catch 不触发:随后 `body.xxx` 在 null 上取属性直接 TypeError 击穿 Hono 500。
8
+ * 数组/字符串/数字 body 同型(`body.foo` 静默 undefined,字段被当作缺省,
9
+ * 与「请求体畸形」应有的显式反馈不符)。
10
+ *
11
+ * 口径:
12
+ * - 无 body / 空 body(curl -X POST 不带载荷)→ `{}`:这些端点的字段均为可选,
13
+ * 保持既有契约(既有测试与脚本依赖无载荷 POST)。
14
+ * - 畸形 JSON / null / 数组 / 标量 body → 400(此前 null 击穿 500、
15
+ * 其余静默当空对象)。
16
+ * - 合法 JSON 对象 → 原样返回。
17
+ *
18
+ * 调用方写法:
19
+ * const parsed = await readJsonObject(c)
20
+ * if (!parsed.ok) return parsed.response
21
+ * const { body } = parsed
22
+ */
23
+ export async function readJsonObject(c) {
24
+ const raw = await c.req.text().catch(() => '');
25
+ if (raw.trim().length === 0)
26
+ return { ok: true, body: {} };
27
+ let parsed;
28
+ try {
29
+ parsed = JSON.parse(raw);
30
+ }
31
+ catch {
32
+ return { ok: false, response: apiError(c, 400, 'BAD_REQUEST', '请求体必须是合法 JSON') };
33
+ }
34
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
35
+ return { ok: false, response: apiError(c, 400, 'BAD_REQUEST', '请求体必须是 JSON 对象') };
36
+ }
37
+ return { ok: true, body: parsed };
38
+ }