c0de-agent 1.9.0 → 1.11.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 (109) hide show
  1. package/README.md +25 -8
  2. package/dist/cli/commands/acp.js +5 -3
  3. package/dist/cli/commands/auth.d.ts +17 -0
  4. package/dist/cli/commands/auth.js +73 -0
  5. package/dist/cli/commands/chat.js +10 -0
  6. package/dist/cli/commands/config.d.ts +2 -9
  7. package/dist/cli/commands/config.js +15 -54
  8. package/dist/cli/commands/init.js +4 -2
  9. package/dist/cli/commands/plugin.js +8 -4
  10. package/dist/cli/commands/serve.d.ts +1 -0
  11. package/dist/cli/commands/serve.js +4 -1
  12. package/dist/cli/commands/sessions.d.ts +10 -0
  13. package/dist/cli/commands/sessions.js +35 -0
  14. package/dist/cli/commands/update.js +18 -10
  15. package/dist/cli/deps.d.ts +7 -5
  16. package/dist/cli/deps.js +23 -7
  17. package/dist/cli/index.d.ts +1 -1
  18. package/dist/cli/index.js +96 -37
  19. package/dist/cli/modes/print.d.ts +3 -1
  20. package/dist/cli/modes/print.js +31 -4
  21. package/dist/core/agent.js +3 -4
  22. package/dist/core/config-path.d.ts +9 -0
  23. package/dist/core/config-path.js +48 -0
  24. package/dist/core/config.d.ts +27 -2
  25. package/dist/core/config.js +83 -13
  26. package/dist/core/index.d.ts +1 -1
  27. package/dist/core/index.js +1 -1
  28. package/dist/core/loop.js +14 -4
  29. package/dist/core/slash.js +92 -30
  30. package/dist/core/title.js +15 -0
  31. package/dist/core/types.d.ts +2 -0
  32. package/dist/core/workflows/discovery.d.ts +1 -1
  33. package/dist/core/workflows/index.d.ts +1 -1
  34. package/dist/db/client.d.ts +4 -0
  35. package/dist/db/client.js +5 -0
  36. package/dist/db/schema.d.ts +51 -0
  37. package/dist/db/schema.js +7 -1
  38. package/dist/server/agent-manager.d.ts +20 -0
  39. package/dist/server/agent-manager.js +63 -11
  40. package/dist/server/app.js +12 -1
  41. package/dist/server/auth-manager.d.ts +79 -0
  42. package/dist/server/auth-manager.js +270 -0
  43. package/dist/server/context.js +3 -0
  44. package/dist/server/middleware/auth.d.ts +4 -3
  45. package/dist/server/middleware/auth.js +5 -5
  46. package/dist/server/middleware/cors.d.ts +11 -2
  47. package/dist/server/middleware/cors.js +15 -12
  48. package/dist/server/permission/interactive.d.ts +2 -0
  49. package/dist/server/permission/interactive.js +5 -1
  50. package/dist/server/permission/store.d.ts +4 -0
  51. package/dist/server/permission/store.js +1 -0
  52. package/dist/server/routes/auth.d.ts +4 -0
  53. package/dist/server/routes/auth.js +94 -0
  54. package/dist/server/routes/chat.d.ts +6 -1
  55. package/dist/server/routes/chat.js +294 -153
  56. package/dist/server/routes/config.js +60 -8
  57. package/dist/server/routes/permissions.d.ts +4 -1
  58. package/dist/server/routes/permissions.js +27 -4
  59. package/dist/server/routes/project.js +56 -2
  60. package/dist/server/routes/session.js +37 -9
  61. package/dist/server/routes/terminal.js +29 -2
  62. package/dist/server/routes/update.d.ts +1 -0
  63. package/dist/server/routes/update.js +33 -4
  64. package/dist/server/routes/workflows.js +2 -2
  65. package/dist/server/server.d.ts +24 -1
  66. package/dist/server/server.js +149 -29
  67. package/dist/server/types.d.ts +17 -2
  68. package/dist/session/archive.d.ts +1 -1
  69. package/dist/session/branch.d.ts +11 -2
  70. package/dist/session/branch.js +107 -31
  71. package/dist/session/compaction.js +1 -0
  72. package/dist/session/context.d.ts +3 -2
  73. package/dist/session/context.js +36 -3
  74. package/dist/session/index.d.ts +1 -1
  75. package/dist/session/index.js +1 -1
  76. package/dist/session/session.d.ts +25 -5
  77. package/dist/session/session.js +129 -10
  78. package/dist/session/snapshot.d.ts +9 -3
  79. package/dist/session/snapshot.js +45 -4
  80. package/dist/session/squash.js +1 -0
  81. package/dist/session/types.d.ts +3 -1
  82. package/dist/shared/types/agent.d.ts +11 -0
  83. package/dist/shared/types/config.d.ts +12 -6
  84. package/dist/shared/types/message.d.ts +6 -2
  85. package/dist/shared/utils/path.d.ts +2 -3
  86. package/dist/shared/utils/path.js +20 -4
  87. package/dist/tools/builtin/todo.js +5 -4
  88. package/dist/tools/index.d.ts +14 -3
  89. package/dist/tools/index.js +35 -14
  90. package/dist/update/index.d.ts +2 -2
  91. package/dist/update/index.js +2 -2
  92. package/dist/update/ipc.d.ts +38 -5
  93. package/dist/update/ipc.js +56 -7
  94. package/dist/update/snapshot.d.ts +4 -0
  95. package/dist/update/snapshot.js +8 -0
  96. package/dist/update/updater.d.ts +61 -10
  97. package/dist/update/updater.js +236 -25
  98. package/dist/update/version.d.ts +6 -1
  99. package/dist/update/version.js +18 -3
  100. package/drizzle/0005_snapshot_mtime.sql +1 -0
  101. package/drizzle/0006_sour_susan_delgado.sql +2 -0
  102. package/drizzle/0007_same_wilson_fisk.sql +2 -0
  103. package/drizzle/meta/0005_snapshot.json +855 -0
  104. package/drizzle/meta/0006_snapshot.json +867 -0
  105. package/drizzle/meta/0007_snapshot.json +869 -0
  106. package/drizzle/meta/_journal.json +21 -0
  107. package/package.json +1 -7
  108. package/dist/mcp/index.d.ts +0 -1
  109. package/dist/mcp/index.js +0 -3
@@ -1,4 +1,5 @@
1
- import { readFile } from 'node:fs/promises';
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile, stat } from 'node:fs/promises';
2
3
  import { Hono } from 'hono';
3
4
  import { streamSSE } from 'hono/streaming';
4
5
  import { createAgent, runAgent } from '../../core/agent.js';
@@ -6,20 +7,37 @@ import { compactContext } from '../../core/loop.js';
6
7
  import { createSlashRegistry, parseSlashInput } from '../../core/slash.js';
7
8
  import { injectSteering } from '../../core/steering.js';
8
9
  import { buildWorkflowNotice, containsWorkflow } from '../../core/workflow.js';
10
+ import { resolveRoute } from '../../llm/registry.js';
9
11
  import { getProject } from '../../project/project.js';
10
12
  import { getLLMSegments, getSession, updateSessionLastRun } from '../../session/session.js';
11
13
  import { upsertFileSnapshot } from '../../session/snapshot.js';
14
+ import { resolveEnabledToolNames } from '../../tools/index.js';
12
15
  import { autoAllowChecker } from '../../tools/permission.js';
13
- import { listTools } from '../../tools/registry.js';
14
16
  import { apiError } from '../middleware/error.js';
15
17
  import { createInteractivePermissionChecker } from '../permission/interactive.js';
16
18
  import { safeResolve } from '../util/safe-path.js';
17
- /** 按 session.projectId 解析 agent 工作目录;无项目回退 ctx.cwd。 */
19
+ /** 按 session.projectId / worktreePath 解析 agent 工作目录:
20
+ * - 有 projectId → 项目 worktree(缺失/目录失效时抛错,由上层转 409)。
21
+ * - 无 projectId(项目已删除后从回收站恢复的会话)→ 用 worktreePath 兜底:
22
+ * 目录失效同样抛错,绝不静默回退 serve cwd 导致 agent 在错误目录执行工具。
23
+ * - 两者皆无(从未绑定项目的会话,如 CLI print)→ 回退 ctx.cwd。 */
18
24
  export async function resolveAgentCwd(ctx, session) {
19
- if (!session.projectId)
20
- return ctx.cwd;
25
+ if (!session.projectId) {
26
+ if (!session.worktreePath)
27
+ return ctx.cwd;
28
+ if (!existsSync(session.worktreePath)) {
29
+ throw new Error(`WORKTREE_MISSING: 会话工作目录已失效(${session.worktreePath})。请检查目录是否被移动或删除`);
30
+ }
31
+ return session.worktreePath;
32
+ }
21
33
  const project = await getProject(ctx.db, session.projectId);
22
- return project?.worktree ?? ctx.cwd;
34
+ if (!project) {
35
+ throw new Error(`PROJECT_MISSING: 会话所属项目已不存在(projectId=${session.projectId})`);
36
+ }
37
+ if (!existsSync(project.worktree)) {
38
+ throw new Error(`WORKTREE_MISSING: 项目工作目录已失效(${project.worktree})。请检查目录是否被移动或删除`);
39
+ }
40
+ return project.worktree;
23
41
  }
24
42
  function createChatRoute(ctx) {
25
43
  const app = new Hono();
@@ -42,16 +60,48 @@ function createChatRoute(ctx) {
42
60
  return apiError(c, 404, 'NOT_FOUND', 'Session not found');
43
61
  }
44
62
  // 提前解析 cwd(slash 拦截与 agent 路径都需使用)
45
- const cwd = await resolveAgentCwd(ctx, session);
63
+ // P1-9:worktree 失效/项目缺失 → 明确 409,不静默回退服务端 cwd。
64
+ let cwd;
65
+ try {
66
+ cwd = await resolveAgentCwd(ctx, session);
67
+ }
68
+ catch (err) {
69
+ const msg = err instanceof Error ? err.message : String(err);
70
+ if (msg.startsWith('PROJECT_MISSING')) {
71
+ return apiError(c, 409, 'PROJECT_MISSING', msg.replace('PROJECT_MISSING: ', ''));
72
+ }
73
+ if (msg.startsWith('WORKTREE_MISSING')) {
74
+ return apiError(c, 409, 'WORKTREE_MISSING', msg.replace('WORKTREE_MISSING: ', ''));
75
+ }
76
+ return apiError(c, 500, 'CWD_RESOLVE_FAILED', msg);
77
+ }
46
78
  // 斜杠命令拦截
47
79
  const parsed = parseSlashInput(message);
48
80
  if (parsed) {
49
81
  const registry = createSlashRegistry();
50
82
  const cmd = registry.get(parsed.name);
51
83
  if (cmd) {
84
+ // P2-4:执行 config.slashCommands.enabled 过滤(此前该配置无任何消费方)。
85
+ // enabled 为空 = 全部启用(与 tools.enabled 语义一致);名称兼容带/不带前缀斜杠。
86
+ const enabledList = ctx.config.slashCommands?.enabled ?? [];
87
+ const enabledSet = new Set(enabledList.map((n) => (n.startsWith('/') ? n.slice(1) : n)));
88
+ if (enabledSet.size > 0 && !enabledSet.has(parsed.name)) {
89
+ return streamSSE(c, async (stream) => {
90
+ await stream.writeSSE({
91
+ event: 'text_delta',
92
+ data: JSON.stringify({
93
+ _tag: 'text_delta',
94
+ text: `斜杠命令 /${parsed.name} 未启用(config.slashCommands.enabled)`,
95
+ }),
96
+ });
97
+ await stream.writeSSE({ event: 'done', data: JSON.stringify({ _tag: 'done' }) });
98
+ });
99
+ }
52
100
  const commandCtx = {
53
101
  cwd,
54
102
  config: ctx.config,
103
+ // 当前会话 id:/clear /fork 等命令默认作用于当前会话(P2-4)。
104
+ sessionId,
55
105
  // 内置斜杠命令(/clear、/fork、/config)仅需 db + config;
56
106
  // 但 /workflow run 会走 executeWorkflow → buildWorkflowContext → runSubAgent,
57
107
  // 该路径需要 agentRegistry 来派生子 agent,因此必须注入。
@@ -160,6 +210,7 @@ function createChatRoute(ctx) {
160
210
  }
161
211
  }
162
212
  // @文件上下文:读取文件内容写入快照,后续 getSessionContext→injectSnapshots 自动注入。
213
+ // 快照记录文件 mtime——注入前比对磁盘,过期自动刷新(P1-5)。
163
214
  // 路径越界或读取失败静默跳过,不阻塞主对话流。
164
215
  const files = body.files;
165
216
  if (files?.length) {
@@ -168,8 +219,13 @@ function createChatRoute(ctx) {
168
219
  if (!resolved)
169
220
  continue;
170
221
  try {
222
+ // 先 stat 再 readFile(与 session/context.ts 刷新路径一致):若先读后
223
+ // stat,文件恰在两次调用间被写会存「旧内容 + 新 mtime」,注入前的
224
+ // mtime 相等判定使旧内容被永久钉住;先 stat 后读最坏存「新内容 +
225
+ // 旧 mtime」,下次注入仍会触发刷新,可自愈。
226
+ const st = await stat(resolved);
171
227
  const content = await readFile(resolved, 'utf-8');
172
- await upsertFileSnapshot(ctx.db, sessionId, p, content);
228
+ await upsertFileSnapshot(ctx.db, sessionId, p, content, st.mtimeMs);
173
229
  }
174
230
  catch {
175
231
  // 文件读取失败静默跳过
@@ -178,175 +234,260 @@ function createChatRoute(ctx) {
178
234
  }
179
235
  const provider = body.provider ?? ctx.config.defaultProvider;
180
236
  const model = body.model ?? ctx.config.defaultModel;
181
- // 前端 ToolToggle 全选时不传 tools(undefined),语义为「启用全部注册工具」。
182
- // 显式传 [] 才是禁用全部。config.tools.enabled 仅 CLI 模式使用,此处不回退它,
183
- // 避免配置里 enabled:[] 把 Web 全选误降级为无工具(LLM 无法 function call)。
184
- const tools = body.tools ??
185
- listTools(ctx.toolRegistry, { config: {}, cwd }).map((t) => t.name);
186
- // primary agent 解析(spec: agent-frontend-switching §4.3)
187
- const agentName = body.agent ?? 'default';
188
- const agentDef = ctx.agentRegistry.get(agentName);
189
- if (!agentDef || agentDef.mode === 'subagent') {
190
- return apiError(c, 400, 'INVALID_AGENT', `Unknown or non-primary agent: ${agentName}`);
191
- }
192
- // agent def 覆盖:tools(plan 限只读)、model(可选)
193
- const resolvedTools = agentDef.tools ?? tools;
194
- const resolvedModel = agentDef.model ?? model;
195
- // 分段预检:切换 provider/model/tools 将开新段(前缀失效→缓存 miss),
196
- // 需用户显式确认(confirmSegmentBreak)。首轮无活跃段时跳过。
197
- const existingRun = ctx.agentManager.get(sessionId);
198
- const segs = existingRun ? existingRun.state.segments : await getLLMSegments(ctx.db, sessionId);
199
- const active = segs[segs.length - 1];
200
- if (active) {
201
- const reqTools = new Set(resolvedTools);
202
- const segTools = new Set(active.tools.map((t) => t.name));
203
- const toolsDiffer = reqTools.size !== segTools.size || [...reqTools].some((t) => !segTools.has(t));
204
- const modelDiffer = active.provider !== provider || active.model !== resolvedModel;
205
- // 旧段 agentName undefined 视为 'default'
206
- const agentDiffer = (active.agentName ?? 'default') !== agentName;
207
- const confirmed = body.confirmSegmentBreak === true;
208
- if ((modelDiffer || toolsDiffer || agentDiffer) && !confirmed) {
209
- return apiError(c, 409, 'SEGMENT_BREAK_REQUIRED', '切换模型/工具将开始新的上下文段(缓存失效),需用户确认', {
210
- activeSegment: {
211
- provider: active.provider,
212
- model: active.model,
213
- tools: active.tools.map((t) => t.name),
214
- },
237
+ // P0-1 首次运行引导:provider 未配置/未注册时立即返回可操作错误,
238
+ // 不让错误延迟到 SSE 流内以英文 NoRoute 冒出。前端据此引导用户去设置页。
239
+ // 注:测试注入 ctx.chatStream 时跳过——测试 registry 空壳不代表生产未配置。
240
+ if (!ctx.chatStream) {
241
+ try {
242
+ resolveRoute(ctx.llmRegistry, provider, model);
243
+ }
244
+ catch {
245
+ const configured = ctx.config.providers
246
+ .map((p) => p.name)
247
+ .filter(Boolean)
248
+ .join(', ');
249
+ return apiError(c, 400, 'NO_PROVIDER_CONFIGURED', `未配置可用的 AI 服务(provider: ${provider})。请前往「设置 → Provider」添加 API 服务并测试连接`, {
250
+ provider,
251
+ configuredProviders: ctx.config.providers.map((p) => p.name).filter(Boolean),
252
+ ...(configured ? { hint: `已配置:${configured}` } : {}),
215
253
  });
216
254
  }
217
255
  }
218
- return streamSSE(c, async (stream) => {
219
- // 权限检查器:ask 权限通过 SSE 通知前端,阻塞等待确认
220
- const permissionChecker = createInteractivePermissionChecker(ctx.permissionStore, {
221
- getMode: () => ctx.permissionMode,
222
- onPermissionRequired: async (req) => {
223
- await stream.writeSSE({
224
- event: 'permission_required',
225
- data: JSON.stringify({ _tag: 'permission_required', ...req }),
226
- });
227
- },
228
- });
229
- // cwd 已在斜杠拦截前解析,此处直接复用
230
- // 构建 agent 依赖(注入测试用 chatStream)
231
- const deps = {
232
- db: ctx.db,
233
- llmRegistry: ctx.llmRegistry,
234
- toolRegistry: ctx.toolRegistry,
235
- urlRegistry: ctx.urlRegistry,
236
- hookRunner: ctx.hookRunner,
237
- permission: permissionChecker,
238
- config: ctx.config,
239
- agentRegistry: ctx.agentRegistry,
240
- cwd,
241
- ...(ctx.chatStream ? { chatStream: ctx.chatStream } : {}),
242
- };
243
- const agentConfig = {
244
- provider,
245
- model: resolvedModel,
246
- tools: resolvedTools,
247
- plugins: ctx.config.plugins.enabled,
248
- agentName,
249
- ...(agentDef.systemPrompt ? { agentRolePrompt: agentDef.systemPrompt } : {}),
250
- };
251
- const state = await createAgent(session, agentConfig, deps);
252
- // 持久化 run 状态:status='running' 写入 DB。服务重启后此字段仍为 'running'
253
- // 而进程无活跃 run → 检测为 interrupted。finally 中标记 'completed'。
254
- const runStartedAt = Date.now();
255
- await updateSessionLastRun(ctx.db, sessionId, {
256
- status: 'running',
257
- ...(agentName !== 'default' ? { agentName } : {}),
258
- provider,
259
- model: resolvedModel,
260
- startedAt: runStartedAt,
261
- });
262
- ctx.agentManager.register({ sessionId, state, deps });
263
- // workflowz 关键词检测:用户消息包含独立关键词时注入工作流通知(steering),
264
- // 引导模型用 task 工具批量 fan-out 做确定性多子 agent 分解。
265
- if (containsWorkflow(message)) {
266
- const wfList = ctx.workflowRegistry
267
- ? ctx.workflowRegistry.list().map((w) => ({
268
- name: w.meta.name,
269
- description: w.meta.description,
270
- }))
271
- : [];
272
- injectSteering(state, buildWorkflowNotice(wfList));
256
+ // 并发守卫(P0-4):同步原子占位 tryAcquire(Map has+set 一气呵成、无 await)。
257
+ // 旧实现先 get 检查、真正 register 在 SSE 回调内,两者之间隔多个 await
258
+ // (getLLMSegments、createAgent 等):双发 POST 在窗口内均通过守卫,后注册
259
+ // 覆盖前者(agentManager runs.set),且 A 结束时 unregister 把仍在跑的 B
260
+ // 删掉,abort/steer/pause 对 B 全部失效。占位失败说明该会话已被占用
261
+ // (活跃 run 或另一请求的占位),立即 409。
262
+ if (!ctx.agentManager.tryAcquire(sessionId)) {
263
+ return apiError(c, 409, 'RUN_ACTIVE', '该会话已有进行中的对话');
264
+ }
265
+ // 占位持有期(tryAcquire → SSE 回调 register)内的任何提前返回或抛错都必须
266
+ // 释放占位,否则该会话被永久 409 锁死。handedOff 标记所有权已移交 SSE 回调
267
+ // (其 finally 负责幂等释放),移交前的全部路径在 finally 统一兜底释放。
268
+ let handedOff = false;
269
+ try {
270
+ // 工具解析(P1-1):config.tools.enabled 非空时作为默认集;空 = 全部注册工具。
271
+ // disabled 已在 registry 层过滤(createDefaultRegistry),此处兜底。
272
+ const tools = resolveEnabledToolNames(ctx.toolRegistry, ctx.config, body.tools);
273
+ // primary agent 解析(spec: agent-frontend-switching §4.3)
274
+ const agentName = body.agent ?? 'default';
275
+ const agentDef = ctx.agentRegistry.get(agentName);
276
+ if (!agentDef || agentDef.mode === 'subagent') {
277
+ return apiError(c, 400, 'INVALID_AGENT', `Unknown or non-primary agent: ${agentName}`);
273
278
  }
274
- // 客户端断开时中止 agent
275
- stream.onAbort(() => {
276
- ctx.agentManager.abort(sessionId);
277
- });
278
- // 跟踪 done 事件是否已发送:agentLoop 正常完成时 yield done;
279
- // 但 error/abort/max_turns 等路径不 yield done,需在 finally 补发。
280
- // 否则前端 isStreaming 永远不会变 false,按钮卡在「终止」态。
281
- let doneSent = false;
282
- try {
283
- for await (const event of runAgent(state, userContent, deps)) {
284
- if (event._tag === 'done')
285
- doneSent = true;
286
- await stream.writeSSE({
287
- event: event._tag,
288
- data: JSON.stringify(event),
279
+ // agent def 覆盖:tools(plan 限只读)、model(可选)
280
+ const resolvedTools = agentDef.tools ?? tools;
281
+ const resolvedModel = agentDef.model ?? model;
282
+ // 分段预检:切换 provider/model/tools 将开新段(前缀失效→缓存 miss),
283
+ // 需用户显式确认(confirmSegmentBreak)。首轮无活跃段时跳过。
284
+ // 守卫 409 短路后 agentManager.get 恒为 undefined,原 existingRun 三元
285
+ // 恒走 else 分支(死代码,已删除)。
286
+ const segs = await getLLMSegments(ctx.db, sessionId);
287
+ const active = segs[segs.length - 1];
288
+ if (active) {
289
+ const reqTools = new Set(resolvedTools);
290
+ const segTools = new Set(active.tools.map((t) => t.name));
291
+ const toolsDiffer = reqTools.size !== segTools.size || [...reqTools].some((t) => !segTools.has(t));
292
+ const modelDiffer = active.provider !== provider || active.model !== resolvedModel;
293
+ // 旧段 agentName undefined 视为 'default'
294
+ const agentDiffer = (active.agentName ?? 'default') !== agentName;
295
+ const confirmed = body.confirmSegmentBreak === true;
296
+ if ((modelDiffer || toolsDiffer || agentDiffer) && !confirmed) {
297
+ return apiError(c, 409, 'SEGMENT_BREAK_REQUIRED', '切换模型/工具将开始新的上下文段(缓存失效),需用户确认', {
298
+ activeSegment: {
299
+ provider: active.provider,
300
+ model: active.model,
301
+ tools: active.tools.map((t) => t.name),
302
+ },
289
303
  });
290
304
  }
291
305
  }
292
- catch (err) {
293
- const message = err instanceof Error
294
- ? err.message
295
- : typeof err === 'object' && err !== null && 'message' in err
296
- ? String(err.message)
297
- : String(err);
298
- await stream.writeSSE({
299
- event: 'error',
300
- data: JSON.stringify({
301
- _tag: 'error',
302
- error: {
303
- _tag: 'unexpected',
304
- message,
306
+ const response = streamSSE(c, async (stream) => {
307
+ // P0-4:回调从首行起全部纳入 try/finally——createAgent、updateSessionLastRun
308
+ // 等启动阶段失败同样必须释放占位并补发 error/done(hono 的 stream 包装器
309
+ // 只 console.error 不善后),不得泄漏占位或让前端卡在 streaming 态。
310
+ let doneSent = false;
311
+ let runStartedAt;
312
+ try {
313
+ // 权限检查器:ask 权限通过 SSE 通知前端,阻塞等待确认。
314
+ // P1-5:模式按会话隔离(sessionPermissionModes 覆盖优先)。
315
+ const permissionChecker = createInteractivePermissionChecker(ctx.permissionStore, {
316
+ getMode: () => ctx.sessionPermissionModes.get(sessionId) ?? ctx.permissionMode,
317
+ onPermissionRequired: async (req) => {
318
+ await stream.writeSSE({
319
+ event: 'permission_required',
320
+ data: JSON.stringify({ _tag: 'permission_required', ...req }),
321
+ });
305
322
  },
306
- }),
307
- });
308
- }
309
- finally {
310
- // 先从 agentManager 注销:必须最先执行,确保客户端断开使 writeSSE reject
311
- // 时 agent 状态(state/deps/AbortController)不会泄漏到 Map(unregister 仅做
312
- // Map.delete、不依赖 state,放最前安全)。
313
- ctx.agentManager.unregister(sessionId);
314
- // agentLoop 的 error/abort/max_turns 路径不 yield done,在此补发。
315
- // 正常完成路径已在循环中 yield done,doneSent=true 时跳过避免重复。
316
- // 客户端已断开时 writeSSE 会 reject,吞掉以保证后续 updateSessionLastRun 执行。
317
- if (!doneSent) {
323
+ // P1-6:确认超时(5 分钟)被自动拒绝时通知前端,避免用户误以为已执行
324
+ onPermissionTimeout: (req) => {
325
+ stream
326
+ .writeSSE({
327
+ event: 'permission_timeout',
328
+ data: JSON.stringify({ _tag: 'permission_timeout', ...req }),
329
+ })
330
+ .catch(() => { });
331
+ },
332
+ });
333
+ // cwd 已在斜杠拦截前解析,此处直接复用
334
+ // 构建 agent 依赖(注入测试用 chatStream)
335
+ const deps = {
336
+ db: ctx.db,
337
+ llmRegistry: ctx.llmRegistry,
338
+ toolRegistry: ctx.toolRegistry,
339
+ urlRegistry: ctx.urlRegistry,
340
+ hookRunner: ctx.hookRunner,
341
+ permission: permissionChecker,
342
+ config: ctx.config,
343
+ agentRegistry: ctx.agentRegistry,
344
+ cwd,
345
+ ...(ctx.chatStream ? { chatStream: ctx.chatStream } : {}),
346
+ };
347
+ const agentConfig = {
348
+ provider,
349
+ model: resolvedModel,
350
+ tools: resolvedTools,
351
+ plugins: ctx.config.plugins.enabled,
352
+ agentName,
353
+ ...(agentDef.systemPrompt ? { agentRolePrompt: agentDef.systemPrompt } : {}),
354
+ };
355
+ const state = await createAgent(session, agentConfig, deps);
356
+ // 持久化 run 状态:status='running' 写入 DB。服务重启后此字段仍为 'running'
357
+ // 而进程无活跃 run → 检测为 interrupted。finally 中标记 'completed'。
358
+ runStartedAt = Date.now();
359
+ await updateSessionLastRun(ctx.db, sessionId, {
360
+ status: 'running',
361
+ ...(agentName !== 'default' ? { agentName } : {}),
362
+ provider,
363
+ model: resolvedModel,
364
+ startedAt: runStartedAt,
365
+ });
366
+ state.lastRunStartedAt = runStartedAt;
367
+ // 填充 tryAcquire 留下的占位(原 register 语义:按 sessionId 覆盖写入)
368
+ ctx.agentManager.register({ sessionId, state, deps });
369
+ // workflowz 关键词检测:用户消息包含独立关键词时注入工作流通知(steering),
370
+ // 引导模型用 task 工具批量 fan-out 做确定性多子 agent 分解。
371
+ if (containsWorkflow(message)) {
372
+ const wfList = ctx.workflowRegistry
373
+ ? ctx.workflowRegistry.list().map((w) => ({
374
+ name: w.meta.name,
375
+ description: w.meta.description,
376
+ }))
377
+ : [];
378
+ injectSteering(state, buildWorkflowNotice(wfList));
379
+ }
380
+ // 客户端断开时中止 agent
381
+ stream.onAbort(() => {
382
+ ctx.agentManager.abort(sessionId);
383
+ });
384
+ // 跟踪 done 事件是否已发送:agentLoop 正常完成时 yield done;
385
+ // 但 error/abort/max_turns 等路径不 yield done,需在 finally 补发。
386
+ // 否则前端 isStreaming 永远不会变 false,按钮卡在「终止」态。
387
+ try {
388
+ for await (const event of runAgent(state, userContent, deps)) {
389
+ if (event._tag === 'done')
390
+ doneSent = true;
391
+ await stream.writeSSE({
392
+ event: event._tag,
393
+ data: JSON.stringify(event),
394
+ });
395
+ }
396
+ }
397
+ catch (err) {
398
+ const message = err instanceof Error
399
+ ? err.message
400
+ : typeof err === 'object' && err !== null && 'message' in err
401
+ ? String(err.message)
402
+ : String(err);
403
+ await stream.writeSSE({
404
+ event: 'error',
405
+ data: JSON.stringify({
406
+ _tag: 'error',
407
+ error: {
408
+ _tag: 'unexpected',
409
+ message,
410
+ },
411
+ }),
412
+ });
413
+ }
414
+ }
415
+ catch (err) {
416
+ // 启动阶段(createAgent/updateSessionLastRun 等)失败:补发 error 事件。
417
+ // 客户端已断开时 writeSSE 会 reject,吞掉以保证 finally 善后执行。
318
418
  await stream
319
- .writeSSE({ event: 'done', data: JSON.stringify({ _tag: 'done' }) })
419
+ .writeSSE({
420
+ event: 'error',
421
+ data: JSON.stringify({
422
+ _tag: 'error',
423
+ error: {
424
+ _tag: 'unexpected',
425
+ message: err instanceof Error ? err.message : String(err),
426
+ },
427
+ }),
428
+ })
320
429
  .catch(() => { });
321
430
  }
322
- // 无论正常完成、错误还是 abort,只要服务还活着就标记 completed。
323
- // 只有服务崩溃/重启才会留下 status='running' → 下次加载检测为 interrupted。
324
- await updateSessionLastRun(ctx.db, sessionId, {
325
- status: 'completed',
326
- ...(agentName !== 'default' ? { agentName } : {}),
327
- provider,
328
- model: resolvedModel,
329
- startedAt: runStartedAt,
330
- }).catch(() => { });
331
- }
332
- });
431
+ finally {
432
+ // 先从 agentManager 释放(占位与活跃 run 均幂等):必须最先执行,确保
433
+ // 客户端断开使 writeSSE reject 时 agent 状态(state/deps/AbortController)
434
+ // 不会泄漏到 Map(unregister 仅做 Map.delete、不依赖 state,放最前安全)。
435
+ ctx.agentManager.unregister(sessionId);
436
+ // agentLoop 的 error/abort/max_turns 路径不 yield done,在此补发。
437
+ // 正常完成路径已在循环中 yield done,doneSent=true 时跳过避免重复。
438
+ // 客户端已断开时 writeSSE 会 reject,吞掉以保证后续 updateSessionLastRun 执行。
439
+ if (!doneSent) {
440
+ await stream
441
+ .writeSSE({ event: 'done', data: JSON.stringify({ _tag: 'done' }) })
442
+ .catch(() => { });
443
+ }
444
+ // 无论正常完成、错误还是 abort,只要服务还活着就标记 completed。
445
+ // 只有服务崩溃/重启才会留下 status='running' → 下次加载检测为 interrupted。
446
+ // runStartedAt 未定义说明 run 从未真正启动(createAgent 即失败),DB 中
447
+ // 无 'running' 标记需要清理,跳过写入。
448
+ if (runStartedAt !== undefined) {
449
+ await updateSessionLastRun(ctx.db, sessionId, {
450
+ status: 'completed',
451
+ ...(agentName !== 'default' ? { agentName } : {}),
452
+ provider,
453
+ model: resolvedModel,
454
+ startedAt: runStartedAt,
455
+ }).catch(() => { });
456
+ }
457
+ }
458
+ });
459
+ // streamSSE 已同步启动回调(回调的 register/finally 从此接管占位填充与
460
+ // 释放),所有权移交:本层 finally 不再兜底释放。
461
+ handedOff = true;
462
+ return response;
463
+ }
464
+ finally {
465
+ if (!handedOff)
466
+ ctx.agentManager.unregister(sessionId);
467
+ }
333
468
  });
334
469
  // 控制端点
470
+ // P0-4:占位中的会话(tryAcquire 后、register 前)run 尚无 state 可供
471
+ // abort/pause/resume/steer 操作——返回明确 409 而非静默 false 或崩溃。
472
+ const runStarting = (c, sessionId) => ctx.agentManager.isStarting(sessionId)
473
+ ? apiError(c, 409, 'RUN_STARTING', '该会话的对话正在启动,请稍后重试')
474
+ : undefined;
335
475
  app.post('/abort', async (c) => {
336
476
  const { sessionId } = await c.req.json();
337
- return c.json({ aborted: ctx.agentManager.abort(sessionId) });
477
+ return runStarting(c, sessionId) ?? c.json({ aborted: ctx.agentManager.abort(sessionId) });
338
478
  });
339
479
  app.post('/pause', async (c) => {
340
480
  const { sessionId } = await c.req.json();
341
- return c.json({ paused: ctx.agentManager.pause(sessionId) });
481
+ return runStarting(c, sessionId) ?? c.json({ paused: ctx.agentManager.pause(sessionId) });
342
482
  });
343
483
  app.post('/resume', async (c) => {
344
484
  const { sessionId } = await c.req.json();
345
- return c.json({ resumed: ctx.agentManager.resume(sessionId) });
485
+ return runStarting(c, sessionId) ?? c.json({ resumed: ctx.agentManager.resume(sessionId) });
346
486
  });
347
487
  app.post('/steer', async (c) => {
348
488
  const body = await c.req.json();
349
- return c.json({ steered: ctx.agentManager.steer(body.sessionId, body.message) });
489
+ return (runStarting(c, body.sessionId) ??
490
+ c.json({ steered: ctx.agentManager.steer(body.sessionId, body.message) }));
350
491
  });
351
492
  return app;
352
493
  }
@@ -1,26 +1,78 @@
1
1
  import { Hono } from 'hono';
2
- import { mergeConfig, saveConfig } from '../../core/config.js';
3
- import { encryptSecret, isEncryptedSecret } from '../../core/secret.js';
2
+ import { applyScopedPatch, loadConfigScopes, mergeConfig, saveConfigScoped, } from '../../core/config.js';
3
+ import { decryptSecret, encryptSecret, isEncryptedSecret } from '../../core/secret.js';
4
+ import { apiError } from '../middleware/error.js';
4
5
  import { syncRegistryFromConfig } from '../server.js';
6
+ /** 检查每个 provider 的 apiKey 能否在本机解密(机器绑定密钥换机/容器重建后会失败)。 */
7
+ function providerApiKeyWarnings(providers) {
8
+ const warnings = [];
9
+ for (const p of providers) {
10
+ if (!p.apiKey || !isEncryptedSecret(p.apiKey))
11
+ continue;
12
+ try {
13
+ decryptSecret(p.apiKey);
14
+ }
15
+ catch {
16
+ warnings.push(`provider "${p.name || '(未命名)'}" 的 API Key 无法在本机解密(密钥与机器绑定)。` +
17
+ '若配置来自其他机器或容器重建,请重新填写 API Key。');
18
+ }
19
+ }
20
+ return warnings;
21
+ }
5
22
  function createConfigRoute(ctx) {
6
23
  const app = new Hono();
24
+ // GET / — 合并后配置 + 作用域信息 + apiKey 解密警告
7
25
  app.get('/', (c) => {
8
- return c.json(ctx.config);
26
+ const scopes = loadConfigScopes(ctx.cwd);
27
+ return c.json({
28
+ config: ctx.config,
29
+ scopes: {
30
+ global: scopes.global ?? null,
31
+ project: scopes.project ?? null,
32
+ },
33
+ warnings: providerApiKeyWarnings(ctx.config.providers),
34
+ });
9
35
  });
36
+ // PATCH / — 按作用域写入。body.scope: 'global' | 'project'(默认 project,兼容旧客户端)。
10
37
  app.patch('/', async (c) => {
11
- const patch = (await c.req.json());
38
+ const body = (await c.req.json());
39
+ const scope = body.scope === 'global' ? 'global' : 'project';
40
+ const { scope: _omit, ...patch } = body;
12
41
  // spec §24.2:provider apiKey 落盘前加密,明文不持久化。
13
42
  // 已加密(enc: 前缀)或无 apiKey 的透传。
14
43
  if (Array.isArray(patch.providers)) {
15
44
  patch.providers = patch.providers.map((p) => p.apiKey && !isEncryptedSecret(p.apiKey) ? { ...p, apiKey: encryptSecret(p.apiKey) } : p);
16
45
  }
17
- ctx.config = mergeConfig(ctx.config, patch);
46
+ // 按作用域最小落盘:patch 只合并进指定作用域原始文件,
47
+ // 不把合并结果(含默认值/另一作用域配置)整体序列化进文件。
48
+ // null 值 = 删除该作用域中的键(回落到另一作用域/默认值)。
49
+ const scopes = loadConfigScopes(ctx.cwd);
50
+ const nextScoped = scope === 'global'
51
+ ? applyScopedPatch(scopes.global ?? {}, patch)
52
+ : applyScopedPatch(scopes.project ?? {}, patch);
53
+ // P2-3:落盘失败必须反馈给前端(此前静默吞掉,UI 显示已保存但重启后丢失)。
54
+ // 先落盘、成功后才更新内存配置与 registry,保证「已保存」反馈与磁盘状态一致。
55
+ try {
56
+ await saveConfigScoped(scope, ctx.cwd, nextScoped);
57
+ }
58
+ catch (err) {
59
+ return apiError(c, 500, 'CONFIG_SAVE_FAILED', `配置保存失败:${err instanceof Error ? err.message : String(err)}`);
60
+ }
61
+ ctx.config =
62
+ scope === 'global'
63
+ ? mergeConfig(nextScoped, scopes.project)
64
+ : mergeConfig(scopes.global, nextScoped);
18
65
  // providers 变更后原地同步 registry,使运行中的连接立即生效
19
66
  syncRegistryFromConfig(ctx.llmRegistry, ctx.config);
20
- await saveConfig(ctx.config, 'project', ctx.cwd).catch(() => {
21
- // 保存失败不影响内存配置
67
+ // 安全类配置(token/authEnabled/allowedOrigins)由 authManager 在启动时一次性读取,
68
+ // 运行时修改不生效——告知前端提示重启。
69
+ const needsRestart = patch.security !== undefined;
70
+ return c.json({
71
+ config: ctx.config,
72
+ scope,
73
+ needsRestart,
74
+ warnings: providerApiKeyWarnings(ctx.config.providers),
22
75
  });
23
- return c.json(ctx.config);
24
76
  });
25
77
  return app;
26
78
  }
@@ -1,5 +1,8 @@
1
1
  import { Hono } from 'hono';
2
2
  import type { ServerContext } from '../types.js';
3
- /** GET/PUT /api/permissions — 全局授权模式(default 逐个确认 / auto 自动放行 ask 工具)。 */
3
+ /** GET/PUT /api/permissions — 授权模式(default 逐个确认 / auto 自动放行 ask 工具)。
4
+ * - 根路径:默认模式(全局,启动时取 config.permission.defaultMode)
5
+ * - /:sessionId:会话级覆盖(P1-5 按会话隔离 auto 高风险状态)
6
+ */
4
7
  declare function createPermissionsRoute(ctx: ServerContext): Hono;
5
8
  export { createPermissionsRoute };