c0de-agent 1.10.0 → 1.12.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 (124) hide show
  1. package/README.md +28 -6
  2. package/dist/cli/commands/acp.js +3 -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 +95 -1
  6. package/dist/cli/commands/config.d.ts +2 -9
  7. package/dist/cli/commands/config.js +25 -54
  8. package/dist/cli/commands/init.js +4 -2
  9. package/dist/cli/commands/plugin.js +8 -4
  10. package/dist/cli/commands/sessions.d.ts +10 -0
  11. package/dist/cli/commands/sessions.js +94 -0
  12. package/dist/cli/commands/update.js +18 -10
  13. package/dist/cli/index.js +51 -4
  14. package/dist/cli/modes/print.js +19 -2
  15. package/dist/core/compact.d.ts +2 -0
  16. package/dist/core/compact.js +1 -0
  17. package/dist/core/config-path.d.ts +9 -0
  18. package/dist/core/config-path.js +48 -0
  19. package/dist/core/config.d.ts +20 -3
  20. package/dist/core/config.js +103 -14
  21. package/dist/core/index.d.ts +1 -1
  22. package/dist/core/index.js +1 -1
  23. package/dist/core/loop/compaction.d.ts +2 -1
  24. package/dist/core/loop/compaction.js +19 -6
  25. package/dist/core/loop/stream-collect.js +9 -1
  26. package/dist/core/loop/subagent.js +23 -5
  27. package/dist/core/loop.js +13 -3
  28. package/dist/core/redact.d.ts +11 -0
  29. package/dist/core/redact.js +49 -0
  30. package/dist/core/slash.js +167 -63
  31. package/dist/core/title.js +22 -1
  32. package/dist/core/types.d.ts +4 -0
  33. package/dist/db/client.d.ts +4 -0
  34. package/dist/db/client.js +5 -0
  35. package/dist/db/schema.d.ts +51 -0
  36. package/dist/db/schema.js +6 -0
  37. package/dist/kanban/index.d.ts +1 -1
  38. package/dist/kanban/index.js +1 -1
  39. package/dist/kanban/store.d.ts +5 -1
  40. package/dist/kanban/store.js +76 -2
  41. package/dist/llm/index.d.ts +1 -1
  42. package/dist/llm/index.js +1 -1
  43. package/dist/llm/provider.js +1 -1
  44. package/dist/llm/retry.d.ts +5 -2
  45. package/dist/llm/retry.js +6 -5
  46. package/dist/llm/routing.d.ts +24 -1
  47. package/dist/llm/routing.js +29 -1
  48. package/dist/project/index.d.ts +1 -1
  49. package/dist/project/index.js +1 -1
  50. package/dist/project/project.d.ts +14 -0
  51. package/dist/project/project.js +77 -1
  52. package/dist/server/agent-manager.d.ts +14 -0
  53. package/dist/server/agent-manager.js +41 -0
  54. package/dist/server/app.js +12 -2
  55. package/dist/server/auth-manager.d.ts +85 -0
  56. package/dist/server/auth-manager.js +289 -0
  57. package/dist/server/context.js +4 -0
  58. package/dist/server/middleware/auth.d.ts +4 -3
  59. package/dist/server/middleware/auth.js +5 -5
  60. package/dist/server/permission/interactive.d.ts +7 -2
  61. package/dist/server/permission/interactive.js +25 -5
  62. package/dist/server/permission/store.d.ts +15 -1
  63. package/dist/server/permission/store.js +31 -2
  64. package/dist/server/registry-config.d.ts +14 -0
  65. package/dist/server/registry-config.js +44 -0
  66. package/dist/server/routes/auth.d.ts +4 -0
  67. package/dist/server/routes/auth.js +124 -0
  68. package/dist/server/routes/chat.d.ts +18 -1
  69. package/dist/server/routes/chat.js +295 -37
  70. package/dist/server/routes/commands.d.ts +4 -2
  71. package/dist/server/routes/commands.js +10 -3
  72. package/dist/server/routes/config.js +143 -15
  73. package/dist/server/routes/files.js +22 -4
  74. package/dist/server/routes/kanban.js +68 -6
  75. package/dist/server/routes/permissions.d.ts +0 -1
  76. package/dist/server/routes/permissions.js +153 -4
  77. package/dist/server/routes/project.js +106 -3
  78. package/dist/server/routes/provider.js +48 -3
  79. package/dist/server/routes/session.js +319 -19
  80. package/dist/server/routes/terminal.js +17 -2
  81. package/dist/server/routes/update.d.ts +11 -5
  82. package/dist/server/routes/update.js +98 -16
  83. package/dist/server/server.d.ts +4 -12
  84. package/dist/server/server.js +235 -100
  85. package/dist/server/types.d.ts +15 -1
  86. package/dist/session/archive.d.ts +4 -2
  87. package/dist/session/archive.js +13 -1
  88. package/dist/session/branch.d.ts +1 -0
  89. package/dist/session/branch.js +32 -7
  90. package/dist/session/import.d.ts +44 -0
  91. package/dist/session/import.js +83 -0
  92. package/dist/session/index.d.ts +1 -1
  93. package/dist/session/index.js +1 -1
  94. package/dist/session/jobs.d.ts +12 -0
  95. package/dist/session/jobs.js +53 -0
  96. package/dist/session/message.d.ts +7 -1
  97. package/dist/session/message.js +21 -1
  98. package/dist/session/session.d.ts +143 -6
  99. package/dist/session/session.js +561 -11
  100. package/dist/session/snapshot.d.ts +5 -2
  101. package/dist/session/snapshot.js +10 -2
  102. package/dist/session/types.d.ts +12 -2
  103. package/dist/shared/types/agent.d.ts +22 -0
  104. package/dist/shared/types/config.d.ts +10 -1
  105. package/dist/shared/types/kanban.d.ts +13 -0
  106. package/dist/shared/types/message.d.ts +15 -2
  107. package/dist/tools/index.js +2 -3
  108. package/dist/tools/websearch/index.js +6 -3
  109. package/dist/update/index.d.ts +3 -3
  110. package/dist/update/index.js +2 -2
  111. package/dist/update/ipc.d.ts +19 -2
  112. package/dist/update/ipc.js +43 -6
  113. package/dist/update/snapshot.d.ts +5 -0
  114. package/dist/update/snapshot.js +10 -0
  115. package/dist/update/updater.d.ts +69 -8
  116. package/dist/update/updater.js +263 -32
  117. package/drizzle/0006_sour_susan_delgado.sql +2 -0
  118. package/drizzle/0007_same_wilson_fisk.sql +2 -0
  119. package/drizzle/0008_tranquil_lily_hollister.sql +1 -0
  120. package/drizzle/meta/0006_snapshot.json +867 -0
  121. package/drizzle/meta/0007_snapshot.json +869 -0
  122. package/drizzle/meta/0008_snapshot.json +875 -0
  123. package/drizzle/meta/_journal.json +21 -0
  124. package/package.json +41 -42
@@ -0,0 +1,124 @@
1
+ // src/server/routes/auth.ts
2
+ // P2-16:认证引导 + 设备配对审批端点。
3
+ //
4
+ // 公开端点(未认证,中间件 publicPaths 放行):
5
+ // POST /api/auth/register 首设备注册:凭 bootstrap token 换发设备 token
6
+ // POST /api/auth/pairing/request 新设备发起配对:返回 pairingId + 6 位配对码
7
+ // GET /api/auth/pairing/status?pairingId=xxx 新设备轮询审批结果
8
+ //
9
+ // 需认证端点(已授权设备调用):
10
+ // GET /api/auth/pairing 列出待审批配对
11
+ // POST /api/auth/pairing/approve 审批通过(签发设备 token)
12
+ // POST /api/auth/pairing/deny 拒绝配对
13
+ // GET /api/auth/devices 列出已授权设备
14
+ // DELETE /api/auth/devices/:id 撤销设备(立即生效)
15
+ import { Hono } from 'hono';
16
+ import { apiError } from '../middleware/error.js';
17
+ /** 尽力而为的请求来源:x-forwarded-for 首跳(反代场景),否则视为本地回环。仅展示/软限流。 */
18
+ function requestSource(c) {
19
+ const fwd = c.req.header('x-forwarded-for');
20
+ if (fwd) {
21
+ const first = fwd.split(',')[0]?.trim();
22
+ if (first)
23
+ return first;
24
+ }
25
+ return 'local';
26
+ }
27
+ function createAuthRoute(ctx) {
28
+ const app = new Hono();
29
+ // 首设备注册:凭 bootstrap token(URL ?token=)换发设备 token,bootstrap 随即轮换失效。
30
+ app.post('/register', async (c) => {
31
+ if (!ctx.authManager) {
32
+ return apiError(c, 400, 'AUTH_DISABLED', '认证未启用');
33
+ }
34
+ const body = (await c.req.json().catch(() => ({})));
35
+ if (!body.token) {
36
+ return apiError(c, 400, 'BAD_REQUEST', 'token is required');
37
+ }
38
+ const deviceToken = await ctx.authManager.registerFirstDevice(body.token, body.deviceName ?? '设备');
39
+ if (!deviceToken) {
40
+ // bootstrap 已失效/已注册过设备 → 引导走配对审批
41
+ return apiError(c, 403, 'BOOTSTRAP_CONSUMED', 'bootstrap token 已失效(已有设备注册)。请在新设备发起配对,由已授权设备审批。');
42
+ }
43
+ // P1-3:回传注册成功的设备名,前端展示一次性确认(用户可核对注册的是否自己)。
44
+ return c.json({ deviceToken, deviceName: body.deviceName ?? '设备' });
45
+ });
46
+ // 新设备发起配对请求(公开)。
47
+ app.post('/pairing/request', async (c) => {
48
+ if (!ctx.authManager) {
49
+ return apiError(c, 400, 'AUTH_DISABLED', '认证未启用');
50
+ }
51
+ const body = (await c.req.json().catch(() => ({})));
52
+ const result = ctx.authManager.requestPairing(body.deviceName ?? '新设备', requestSource(c));
53
+ if (!result) {
54
+ return apiError(c, 429, 'PAIRING_LIMIT', '待审批的配对请求过多,请稍后再试');
55
+ }
56
+ return c.json(result);
57
+ });
58
+ // 新设备轮询配对审批结果(公开)。
59
+ app.get('/pairing/status', (c) => {
60
+ if (!ctx.authManager) {
61
+ return apiError(c, 400, 'AUTH_DISABLED', '认证未启用');
62
+ }
63
+ const pairingId = c.req.query('pairingId') ?? '';
64
+ if (!pairingId)
65
+ return apiError(c, 400, 'BAD_REQUEST', 'pairingId is required');
66
+ const status = ctx.authManager.pairingStatus(pairingId);
67
+ if (status.status === 'not_found') {
68
+ return apiError(c, 404, 'PAIRING_NOT_FOUND', '配对请求不存在或已过期');
69
+ }
70
+ return c.json(status);
71
+ });
72
+ // 列出待审批配对(需认证)。
73
+ app.get('/pairing', (c) => {
74
+ if (!ctx.authManager) {
75
+ return apiError(c, 400, 'AUTH_DISABLED', '认证未启用');
76
+ }
77
+ return c.json({ pairings: ctx.authManager.listPairings() });
78
+ });
79
+ // 审批通过(需认证)。
80
+ app.post('/pairing/approve', async (c) => {
81
+ if (!ctx.authManager) {
82
+ return apiError(c, 400, 'AUTH_DISABLED', '认证未启用');
83
+ }
84
+ const body = (await c.req.json().catch(() => ({})));
85
+ if (!body.pairingId)
86
+ return apiError(c, 400, 'BAD_REQUEST', 'pairingId is required');
87
+ const ok = ctx.authManager.approvePairing(body.pairingId);
88
+ if (!ok)
89
+ return apiError(c, 404, 'PAIRING_NOT_FOUND', '配对请求不存在或已过期');
90
+ return c.json({ ok: true });
91
+ });
92
+ // 拒绝配对(需认证)。
93
+ app.post('/pairing/deny', async (c) => {
94
+ if (!ctx.authManager) {
95
+ return apiError(c, 400, 'AUTH_DISABLED', '认证未启用');
96
+ }
97
+ const body = (await c.req.json().catch(() => ({})));
98
+ if (!body.pairingId)
99
+ return apiError(c, 400, 'BAD_REQUEST', 'pairingId is required');
100
+ const ok = ctx.authManager.denyPairing(body.pairingId);
101
+ if (!ok)
102
+ return apiError(c, 404, 'PAIRING_NOT_FOUND', '配对请求不存在或已过期');
103
+ return c.json({ ok: true });
104
+ });
105
+ // 列出已授权设备(需认证;设置页「已授权设备」面板)。
106
+ app.get('/devices', (c) => {
107
+ if (!ctx.authManager) {
108
+ return apiError(c, 400, 'AUTH_DISABLED', '认证未启用');
109
+ }
110
+ return c.json({ devices: ctx.authManager.listDevices() });
111
+ });
112
+ // 撤销设备(需认证)。撤销唯一设备后如需重新注册首设备,请运行 c0de auth reset。
113
+ app.delete('/devices/:id', async (c) => {
114
+ if (!ctx.authManager) {
115
+ return apiError(c, 400, 'AUTH_DISABLED', '认证未启用');
116
+ }
117
+ const ok = ctx.authManager.revokeDevice(c.req.param('id'));
118
+ if (!ok)
119
+ return apiError(c, 404, 'NOT_FOUND', 'Device not found');
120
+ return c.json({ ok: true });
121
+ });
122
+ return app;
123
+ }
124
+ export { createAuthRoute };
@@ -1,8 +1,25 @@
1
1
  import { Hono } from 'hono';
2
+ import { type SSEStreamingApi } from 'hono/streaming';
2
3
  import type { ServerContext } from '../types.js';
3
- /** 按 session.projectId 解析 agent 工作目录;无项目回退 ctx.cwd。 */
4
+ /** 按 session.projectId / worktreePath 解析 agent 工作目录:
5
+ * - 有 projectId → 项目 worktree(缺失/目录失效时抛错,由上层转 409)。
6
+ * - 无 projectId(项目已删除后从回收站恢复的会话)→ 用 worktreePath 兜底:
7
+ * 目录失效同样抛错,绝不静默回退 serve cwd 导致 agent 在错误目录执行工具。
8
+ * - 两者皆无(从未绑定项目的会话,如 CLI print)→ 回退 ctx.cwd。 */
4
9
  export declare function resolveAgentCwd(ctx: ServerContext, session: {
5
10
  projectId: string | null;
11
+ worktreePath?: string | null;
6
12
  }): Promise<string>;
13
+ /**
14
+ * SSE 心跳间隔。前端有 90s 静默看门狗(web/services/chat.ts),而服务端各
15
+ * 等待语义远长于此:bash 默认超时 120s、权限确认 5 分钟、子 agent 执行数分钟。
16
+ * 这些等待期间没有业务事件帧——若无心跳,前端会在 90s 时 abort 连接,
17
+ * 服务端 stream.onAbort 随即中止 agent,长命令/权限弹窗/多 agent 全部误杀。
18
+ * 30s 心跳保证看门狗只对「连接真正死亡」(连续 3 个心跳周期无数据)生效。
19
+ */
20
+ declare const SSE_HEARTBEAT_INTERVAL_MS = 30000;
21
+ /** 启动心跳定时器;返回停止函数(幂等)。流关闭后写入会被吞掉,安全。 */
22
+ declare function startHeartbeat(stream: SSEStreamingApi): () => void;
23
+ export { SSE_HEARTBEAT_INTERVAL_MS, startHeartbeat };
7
24
  declare function createChatRoute(ctx: ServerContext): Hono;
8
25
  export { createChatRoute };
@@ -1,26 +1,63 @@
1
+ import { existsSync } from 'node:fs';
1
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
+ import { loadConfigScopes, mergeConfig } from '../../core/config.js';
5
7
  import { compactContext } from '../../core/loop.js';
6
8
  import { createSlashRegistry, parseSlashInput } from '../../core/slash.js';
7
9
  import { injectSteering } from '../../core/steering.js';
8
10
  import { buildWorkflowNotice, containsWorkflow } from '../../core/workflow.js';
11
+ import { resolveRoute } from '../../llm/registry.js';
9
12
  import { getProject } from '../../project/project.js';
13
+ import { insertEntry } from '../../session/message.js';
10
14
  import { getLLMSegments, getSession, updateSessionLastRun } from '../../session/session.js';
11
15
  import { upsertFileSnapshot } from '../../session/snapshot.js';
12
16
  import { resolveEnabledToolNames } from '../../tools/index.js';
13
17
  import { autoAllowChecker } from '../../tools/permission.js';
14
18
  import { apiError } from '../middleware/error.js';
15
19
  import { createInteractivePermissionChecker } from '../permission/interactive.js';
20
+ import { buildRegistryFromConfig } from '../registry-config.js';
16
21
  import { safeResolve } from '../util/safe-path.js';
17
- /** 按 session.projectId 解析 agent 工作目录;无项目回退 ctx.cwd。 */
22
+ /** 按 session.projectId / worktreePath 解析 agent 工作目录:
23
+ * - 有 projectId → 项目 worktree(缺失/目录失效时抛错,由上层转 409)。
24
+ * - 无 projectId(项目已删除后从回收站恢复的会话)→ 用 worktreePath 兜底:
25
+ * 目录失效同样抛错,绝不静默回退 serve cwd 导致 agent 在错误目录执行工具。
26
+ * - 两者皆无(从未绑定项目的会话,如 CLI print)→ 回退 ctx.cwd。 */
18
27
  export async function resolveAgentCwd(ctx, session) {
19
- if (!session.projectId)
20
- return ctx.cwd;
28
+ if (!session.projectId) {
29
+ if (!session.worktreePath)
30
+ return ctx.cwd;
31
+ if (!existsSync(session.worktreePath)) {
32
+ throw new Error(`WORKTREE_MISSING: 会话工作目录已失效(${session.worktreePath})。请检查目录是否被移动或删除`);
33
+ }
34
+ return session.worktreePath;
35
+ }
21
36
  const project = await getProject(ctx.db, session.projectId);
22
- return project?.worktree ?? ctx.cwd;
37
+ if (!project) {
38
+ throw new Error(`PROJECT_MISSING: 会话所属项目已不存在(projectId=${session.projectId})`);
39
+ }
40
+ if (!existsSync(project.worktree)) {
41
+ throw new Error(`WORKTREE_MISSING: 项目工作目录已失效(${project.worktree})。请检查目录是否被移动或删除`);
42
+ }
43
+ return project.worktree;
44
+ }
45
+ /**
46
+ * SSE 心跳间隔。前端有 90s 静默看门狗(web/services/chat.ts),而服务端各
47
+ * 等待语义远长于此:bash 默认超时 120s、权限确认 5 分钟、子 agent 执行数分钟。
48
+ * 这些等待期间没有业务事件帧——若无心跳,前端会在 90s 时 abort 连接,
49
+ * 服务端 stream.onAbort 随即中止 agent,长命令/权限弹窗/多 agent 全部误杀。
50
+ * 30s 心跳保证看门狗只对「连接真正死亡」(连续 3 个心跳周期无数据)生效。
51
+ */
52
+ const SSE_HEARTBEAT_INTERVAL_MS = 30_000;
53
+ /** 启动心跳定时器;返回停止函数(幂等)。流关闭后写入会被吞掉,安全。 */
54
+ function startHeartbeat(stream) {
55
+ const timer = setInterval(() => {
56
+ stream.writeSSE({ event: 'heartbeat', data: '' }).catch(() => { }); // 流已关闭/客户端断开时写入失败,忽略
57
+ }, SSE_HEARTBEAT_INTERVAL_MS);
58
+ return () => clearInterval(timer);
23
59
  }
60
+ export { SSE_HEARTBEAT_INTERVAL_MS, startHeartbeat };
24
61
  function createChatRoute(ctx) {
25
62
  const app = new Hono();
26
63
  // POST / — SSE 流式聊天
@@ -28,8 +65,25 @@ function createChatRoute(ctx) {
28
65
  const body = await c.req.json().catch(() => ({}));
29
66
  const sessionId = body.sessionId;
30
67
  const message = body.message;
31
- if (!sessionId || !message) {
32
- return apiError(c, 400, 'BAD_REQUEST', 'sessionId and message are required');
68
+ const images = body.images;
69
+ // P1-5:纯图片消息放行(Composer 允许只带图片不带文本),
70
+ // 仅当文本与图片皆无时拒绝,错误信息说明可操作原因。
71
+ if (!sessionId || (!message && !images?.length)) {
72
+ return apiError(c, 400, 'BAD_REQUEST', '消息内容不能为空:请输入文字或附带图片后再发送');
73
+ }
74
+ // P0:图片大小/数量服务端限额(前端已限制,此为兜底——API 直调/旧客户端)。
75
+ if (images) {
76
+ const MAX_IMAGE_COUNT = 6;
77
+ const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
78
+ if (images.length > MAX_IMAGE_COUNT) {
79
+ return apiError(c, 400, 'TOO_MANY_IMAGES', `最多 ${MAX_IMAGE_COUNT} 张图片(收到 ${images.length} 张)`);
80
+ }
81
+ for (const img of images) {
82
+ if (typeof img.data === 'string' && img.data.length > MAX_IMAGE_BYTES) {
83
+ const mb = (img.data.length / (1024 * 1024)).toFixed(1);
84
+ return apiError(c, 400, 'IMAGE_TOO_LARGE', `图片大小 ${mb}MB 超过上限 ${MAX_IMAGE_BYTES / (1024 * 1024)}MB`);
85
+ }
86
+ }
33
87
  }
34
88
  let session;
35
89
  try {
@@ -41,46 +95,163 @@ function createChatRoute(ctx) {
41
95
  if (!session) {
42
96
  return apiError(c, 404, 'NOT_FOUND', 'Session not found');
43
97
  }
98
+ // P2:会话级权限覆盖持久化在 metadata——重启后从 DB 恢复到内存 Map,
99
+ // 使 getMode 的「会话覆盖」优先级跨重启成立。
100
+ if (session.metadata.permissionMode === 'auto' ||
101
+ session.metadata.permissionMode === 'default') {
102
+ ctx.sessionPermissionModes.set(sessionId, session.metadata.permissionMode);
103
+ }
104
+ // P2:会话级「始终允许」白名单同样持久化于 metadata,checker 经 getter 实时读取
105
+ // (run 期间用户可追加;此处仅在本会话 Map 尚未加载时注入,已有值优先)。
106
+ if (!ctx.sessionAlwaysAllow.has(sessionId)) {
107
+ const list = session.metadata.alwaysAllow;
108
+ ctx.sessionAlwaysAllow.set(sessionId, Array.isArray(list) ? list.filter((t) => typeof t === 'string') : []);
109
+ }
44
110
  // 提前解析 cwd(slash 拦截与 agent 路径都需使用)
45
- const cwd = await resolveAgentCwd(ctx, session);
46
- // 斜杠命令拦截
47
- const parsed = parseSlashInput(message);
111
+ // P1-9:worktree 失效/项目缺失 → 明确 409,不静默回退服务端 cwd。
112
+ let cwd;
113
+ try {
114
+ cwd = await resolveAgentCwd(ctx, session);
115
+ }
116
+ catch (err) {
117
+ const msg = err instanceof Error ? err.message : String(err);
118
+ if (msg.startsWith('PROJECT_MISSING')) {
119
+ return apiError(c, 409, 'PROJECT_MISSING', msg.replace('PROJECT_MISSING: ', ''));
120
+ }
121
+ if (msg.startsWith('WORKTREE_MISSING')) {
122
+ return apiError(c, 409, 'WORKTREE_MISSING', msg.replace('WORKTREE_MISSING: ', ''));
123
+ }
124
+ return apiError(c, 500, 'CWD_RESOLVE_FAILED', msg);
125
+ }
126
+ // P1 多项目配置:会话级配置按会话工作目录解析(该项目 .c0de/config.json 的原始
127
+ // 作用域合并到服务级配置之上)。此前所有项目共用启动目录配置——在其他项目里
128
+ // 修改「项目配置」实际写的是启动项目。cwd === ctx.cwd 时直接复用服务级配置。
129
+ // 权限默认模式单独取原始 project 作用域:项目文件未显式设置时回退运行时全局
130
+ // 模式(启动默认或 PUT /api/permissions 覆盖),避免启动项目配置泄漏到其他项目。
131
+ const sessionProjectScope = cwd === ctx.cwd ? undefined : loadConfigScopes(cwd).project;
132
+ const sessionConfig = sessionProjectScope
133
+ ? mergeConfig(ctx.config, sessionProjectScope)
134
+ : ctx.config;
135
+ const sessionDefaultMode = sessionProjectScope?.permission?.defaultMode;
136
+ // P1-1:会话项目注册表——项目级配置含 providers 时,按该项目合并配置
137
+ // 构建/复用 LLM 注册表(否则项目配置的 provider 永远 NoRoute)。
138
+ // 服务启动目录项目直接复用服务级注册表。
139
+ let sessionRegistry = ctx.llmRegistry;
140
+ if (sessionProjectScope && session.projectId) {
141
+ const pid = session.projectId;
142
+ const cached = ctx.projectRegistries?.get(pid);
143
+ if (cached) {
144
+ sessionRegistry = cached;
145
+ }
146
+ else if (sessionConfig.providers.length > 0) {
147
+ sessionRegistry = buildRegistryFromConfig(sessionConfig);
148
+ ctx.projectRegistries?.set(pid, sessionRegistry);
149
+ }
150
+ }
151
+ // 斜杠命令拦截(纯图片消息无文本,跳过命令解析)
152
+ const parsed = parseSlashInput(message ?? '');
48
153
  if (parsed) {
49
154
  const registry = createSlashRegistry();
50
155
  const cmd = registry.get(parsed.name);
51
156
  if (cmd) {
157
+ // P3:变更型命令(清空/压缩/分支直接改写消息树)与活跃 run 的并发
158
+ // 写入存在竞态——斜杠拦截早于 tryAcquire,须在此显式拒绝。
159
+ const MUTATING_SLASH = new Set(['compact', 'clear', 'fork']);
160
+ if (MUTATING_SLASH.has(parsed.name) &&
161
+ (ctx.agentManager.get(sessionId) || ctx.agentManager.isStarting(sessionId))) {
162
+ return apiError(c, 409, 'RUN_ACTIVE', `该会话已有进行中的对话,请等待完成或中止后再执行 /${parsed.name}`);
163
+ }
164
+ // P2-4:执行 config.slashCommands.enabled 过滤(此前该配置无任何消费方)。
165
+ // enabled 为空 = 全部启用(与 tools.enabled 语义一致);名称兼容带/不带前缀斜杠。
166
+ const enabledList = sessionConfig.slashCommands?.enabled ?? [];
167
+ const enabledSet = new Set(enabledList.map((n) => (n.startsWith('/') ? n.slice(1) : n)));
168
+ if (enabledSet.size > 0 && !enabledSet.has(parsed.name)) {
169
+ return streamSSE(c, async (stream) => {
170
+ await stream.writeSSE({
171
+ event: 'text_delta',
172
+ data: JSON.stringify({
173
+ _tag: 'text_delta',
174
+ text: `斜杠命令 /${parsed.name} 未启用(config.slashCommands.enabled)`,
175
+ }),
176
+ });
177
+ await stream.writeSSE({ event: 'done', data: JSON.stringify({ _tag: 'done' }) });
178
+ });
179
+ }
52
180
  const commandCtx = {
53
181
  cwd,
54
- config: ctx.config,
182
+ config: sessionConfig,
183
+ // 当前会话 id:/clear /fork 等命令默认作用于当前会话(P2-4)。
184
+ sessionId,
185
+ // 消费渠道:/model 等命令按渠道给指引(Web 有底部模型选择器)。
186
+ channel: 'web',
55
187
  // 内置斜杠命令(/clear、/fork、/config)仅需 db + config;
56
188
  // 但 /workflow run 会走 executeWorkflow → buildWorkflowContext → runSubAgent,
57
189
  // 该路径需要 agentRegistry 来派生子 agent,因此必须注入。
58
- // permission/toolRegistry 用 autoAllow 凑齐类型(子命令不触发交互权限)。
59
190
  workflowRegistry: ctx.workflowRegistry,
60
191
  deps: {
61
192
  db: ctx.db,
62
- config: ctx.config,
193
+ config: sessionConfig,
63
194
  cwd,
195
+ // P1 工作流权限:占位 checker 在 streamSSE 回调内被替换为交互式
196
+ // checker——/workflow run 的子 agent 才能对 ask 工具弹窗确认。
64
197
  permission: autoAllowChecker,
65
198
  toolRegistry: ctx.toolRegistry,
66
- llmRegistry: ctx.llmRegistry,
199
+ llmRegistry: sessionRegistry,
67
200
  agentRegistry: ctx.agentRegistry,
68
201
  },
69
202
  };
70
203
  return streamSSE(c, async (stream) => {
204
+ const stopHeartbeat = startHeartbeat(stream);
71
205
  try {
72
- const result = await cmd.execute(parsed.args, commandCtx);
206
+ // P1 工作流权限:子命令(尤其 /workflow run)的子 agent 继承此 checker。
207
+ // 此前用 autoAllowChecker:ask 工具返回 permission_required 结果但无
208
+ // 确认通道(confirm 是 no-op),写/执行工具在工作流里静默不可用。
209
+ // 现在与主 agent 一致:permission_required 经 SSE 弹窗,用户确认后
210
+ // 经 /api/tools/confirm 消费全局 store。
211
+ const permissionChecker = createInteractivePermissionChecker(ctx.permissionStore, {
212
+ getMode: () => ctx.sessionPermissionModes.get(sessionId) ??
213
+ sessionDefaultMode ??
214
+ ctx.permissionMode,
215
+ alwaysAllow: () => ctx.sessionAlwaysAllow.get(sessionId) ?? [],
216
+ onPermissionRequired: async (req) => {
217
+ await stream.writeSSE({
218
+ event: 'permission_required',
219
+ data: JSON.stringify({ _tag: 'permission_required', ...req }),
220
+ });
221
+ },
222
+ onPermissionTimeout: (req) => {
223
+ stream
224
+ .writeSSE({
225
+ event: 'permission_timeout',
226
+ data: JSON.stringify({ _tag: 'permission_timeout', ...req }),
227
+ })
228
+ .catch(() => { });
229
+ },
230
+ onPermissionExpired: (req) => {
231
+ stream
232
+ .writeSSE({
233
+ event: 'permission_expired',
234
+ data: JSON.stringify({ _tag: 'permission_expired', ...req }),
235
+ })
236
+ .catch(() => { });
237
+ },
238
+ });
239
+ const streamCommandCtx = {
240
+ ...commandCtx,
241
+ deps: { ...commandCtx.deps, permission: permissionChecker },
242
+ };
243
+ const result = await cmd.execute(parsed.args, streamCommandCtx);
73
244
  if (result._tag === 'compact') {
74
245
  // /compact:手动触发上下文压缩。复用 loop.compactContext(createSummarizer +
75
246
  // runCompaction),不创建主 agent、不进入 LLM turn 循环(不把 /compact 当作
76
247
  // user 消息发给模型)。
77
- const provider = body.provider ?? ctx.config.defaultProvider;
78
- const model = body.model ?? ctx.config.defaultModel;
248
+ const provider = body.provider ?? sessionConfig.defaultProvider;
249
+ const model = body.model ?? sessionConfig.defaultModel;
79
250
  const agentConfig = {
80
251
  provider,
81
252
  model,
82
253
  tools: [],
83
- plugins: ctx.config.plugins.enabled,
254
+ plugins: sessionConfig.plugins.enabled,
84
255
  agentName: 'default',
85
256
  };
86
257
  const compactState = await createAgent(session, agentConfig, commandCtx.deps);
@@ -133,13 +304,16 @@ function createChatRoute(ctx) {
133
304
  });
134
305
  await stream.writeSSE({ event: 'done', data: JSON.stringify({ _tag: 'done' }) });
135
306
  }
307
+ finally {
308
+ stopHeartbeat();
309
+ }
136
310
  });
137
311
  }
138
312
  // 未知斜杠命令:回退为正常消息发给 agent
139
313
  }
140
- // 构建多模态 user content:文本在前,images(dataURL base64)在后
141
- const userContent = [{ _tag: 'text', text: message }];
142
- const images = body.images;
314
+ // 构建多模态 user content:文本在前(可为空串/缺省,纯图片消息),
315
+ // images(dataURL base64)在后。文本为空时跳过 text part,避免把空文本发给模型。
316
+ const userContent = message ? [{ _tag: 'text', text: message }] : [];
143
317
  if (images?.length) {
144
318
  for (const img of images) {
145
319
  userContent.push({ _tag: 'image', mediaType: img.mediaType, data: img.data });
@@ -151,12 +325,18 @@ function createChatRoute(ctx) {
151
325
  const valid = mentionedAgents
152
326
  .map((n) => ctx.agentRegistry.get(n))
153
327
  .filter((d) => Boolean(d && d.mode !== 'primary'));
154
- if (valid.length > 0) {
155
- const names = valid.map((d) => d.name).join(', ');
156
- const first = userContent[0];
157
- if (first && first._tag === 'text') {
158
- first.text = `[User requested subagent(s): ${names}]\n\n${first.text}`;
159
- }
328
+ // P2:全部无效(拼错/未注册/primary)→ 明确 400,此前静默忽略,用户以为已派发
329
+ if (valid.length === 0) {
330
+ return apiError(c, 400, 'INVALID_AGENT_MENTION', `未知或非 subagent 类型的 agent 提及:${mentionedAgents.join(', ')}`);
331
+ }
332
+ const names = valid.map((d) => d.name).join(', ');
333
+ const first = userContent[0];
334
+ if (first && first._tag === 'text') {
335
+ first.text = `[User requested subagent(s): ${names}]\n\n${first.text}`;
336
+ }
337
+ else {
338
+ // 纯图片消息:前置文本 part 承载 subagent 指令,避免指令静默丢失
339
+ userContent.unshift({ _tag: 'text', text: `[User requested subagent(s): ${names}]` });
160
340
  }
161
341
  }
162
342
  // @文件上下文:读取文件内容写入快照,后续 getSessionContext→injectSnapshots 自动注入。
@@ -182,8 +362,41 @@ function createChatRoute(ctx) {
182
362
  }
183
363
  }
184
364
  }
185
- const provider = body.provider ?? ctx.config.defaultProvider;
186
- const model = body.model ?? ctx.config.defaultModel;
365
+ // P1-1:默认 provider/model 按会话项目配置解析(此前用服务启动目录配置,
366
+ // 其他项目的 defaultProvider 设置被静默忽略)。
367
+ const provider = body.provider ?? sessionConfig.defaultProvider;
368
+ const model = body.model ?? sessionConfig.defaultModel;
369
+ // P0-1 首次运行引导:provider 未配置/未注册时立即返回可操作错误,
370
+ // 不让错误延迟到 SSE 流内以英文 NoRoute 冒出。前端据此引导用户去设置页。
371
+ // 注:测试注入 ctx.chatStream 时跳过——测试 registry 空壳不代表生产未配置。
372
+ // P2-4:区分两种失败——完全没有配置 provider vs 配置了但名字不匹配。
373
+ if (!ctx.chatStream) {
374
+ try {
375
+ resolveRoute(sessionRegistry, provider, model);
376
+ }
377
+ catch {
378
+ const configured = sessionConfig.providers
379
+ .map((p) => p.name)
380
+ .filter(Boolean)
381
+ .join(', ');
382
+ const hasAny = sessionConfig.providers.length > 0;
383
+ return apiError(c, 400, hasAny ? 'PROVIDER_NOT_FOUND' : 'NO_PROVIDER_CONFIGURED', hasAny
384
+ ? `未找到名为「${provider}」的 AI 服务。已配置:${configured}。` +
385
+ '请检查拼写,或前往「设置 → Provider」添加该服务。'
386
+ : `未配置可用的 AI 服务(provider: ${provider})。请前往「设置 → Provider」添加 API 服务并测试连接`, {
387
+ provider,
388
+ configuredProviders: sessionConfig.providers.map((p) => p.name).filter(Boolean),
389
+ ...(configured ? { hint: `已配置:${configured}` } : {}),
390
+ });
391
+ }
392
+ // P2-4:provider 显式声明了模型清单但请求模型不在其中 → 大概率拼写错误。
393
+ // 未声明清单的 provider(自建网关等)接受任意模型名,不做校验。
394
+ const providerDef = sessionConfig.providers.find((p) => (p.name || p._tag) === provider);
395
+ const modelNames = providerDef?.models ? Object.keys(providerDef.models) : undefined;
396
+ if (modelNames && modelNames.length > 0 && !modelNames.includes(model)) {
397
+ return apiError(c, 400, 'MODEL_NOT_FOUND', `provider「${provider}」没有名为「${model}」的模型。已配置模型:${modelNames.join(', ')}`, { provider, model, knownModels: modelNames });
398
+ }
399
+ }
187
400
  // 并发守卫(P0-4):同步原子占位 tryAcquire(Map has+set 一气呵成、无 await)。
188
401
  // 旧实现先 get 检查、真正 register 在 SSE 回调内,两者之间隔多个 await
189
402
  // (getLLMSegments、createAgent 等):双发 POST 在窗口内均通过守卫,后注册
@@ -200,7 +413,8 @@ function createChatRoute(ctx) {
200
413
  try {
201
414
  // 工具解析(P1-1):config.tools.enabled 非空时作为默认集;空 = 全部注册工具。
202
415
  // disabled 已在 registry 层过滤(createDefaultRegistry),此处兜底。
203
- const tools = resolveEnabledToolNames(ctx.toolRegistry, ctx.config, body.tools);
416
+ // P1 多项目:按会话项目配置解析(此前用启动目录配置)。
417
+ const tools = resolveEnabledToolNames(ctx.toolRegistry, sessionConfig, body.tools);
204
418
  // primary agent 解析(spec: agent-frontend-switching §4.3)
205
419
  const agentName = body.agent ?? 'default';
206
420
  const agentDef = ctx.agentRegistry.get(agentName);
@@ -238,29 +452,53 @@ function createChatRoute(ctx) {
238
452
  // P0-4:回调从首行起全部纳入 try/finally——createAgent、updateSessionLastRun
239
453
  // 等启动阶段失败同样必须释放占位并补发 error/done(hono 的 stream 包装器
240
454
  // 只 console.error 不善后),不得泄漏占位或让前端卡在 streaming 态。
455
+ // P0 心跳:长工具执行/权限确认等待期间无业务帧,心跳防前端 90s 看门狗误杀。
456
+ const stopHeartbeat = startHeartbeat(stream);
241
457
  let doneSent = false;
242
458
  let runStartedAt;
243
459
  try {
244
- // 权限检查器:ask 权限通过 SSE 通知前端,阻塞等待确认
460
+ // 权限检查器:ask 权限通过 SSE 通知前端,阻塞等待确认。
461
+ // 模式优先级:会话级覆盖(PUT /:sessionId)> 会话项目配置 defaultMode
462
+ // > 运行时全局模式(启动默认或 PUT / 覆盖)。
245
463
  const permissionChecker = createInteractivePermissionChecker(ctx.permissionStore, {
246
- getMode: () => ctx.permissionMode,
464
+ getMode: () => ctx.sessionPermissionModes.get(sessionId) ?? sessionDefaultMode ?? ctx.permissionMode,
465
+ alwaysAllow: () => ctx.sessionAlwaysAllow.get(sessionId) ?? [],
247
466
  onPermissionRequired: async (req) => {
248
467
  await stream.writeSSE({
249
468
  event: 'permission_required',
250
469
  data: JSON.stringify({ _tag: 'permission_required', ...req }),
251
470
  });
252
471
  },
472
+ // P2-9:确认超时(5 分钟)仅通知前端(pending 保持,重开弹窗不重发消息)
473
+ onPermissionTimeout: (req) => {
474
+ stream
475
+ .writeSSE({
476
+ event: 'permission_timeout',
477
+ data: JSON.stringify({ _tag: 'permission_timeout', ...req }),
478
+ })
479
+ .catch(() => { });
480
+ },
481
+ // P0 双层超时:提示后仍无响应 → 兜底拒绝,SSE 通知前端清理弹窗状态
482
+ //(run 已继续,重开弹窗/拒绝按钮均失效,必须显式清理)。
483
+ onPermissionExpired: (req) => {
484
+ stream
485
+ .writeSSE({
486
+ event: 'permission_expired',
487
+ data: JSON.stringify({ _tag: 'permission_expired', ...req }),
488
+ })
489
+ .catch(() => { });
490
+ },
253
491
  });
254
492
  // cwd 已在斜杠拦截前解析,此处直接复用
255
493
  // 构建 agent 依赖(注入测试用 chatStream)
256
494
  const deps = {
257
495
  db: ctx.db,
258
- llmRegistry: ctx.llmRegistry,
496
+ llmRegistry: sessionRegistry,
259
497
  toolRegistry: ctx.toolRegistry,
260
498
  urlRegistry: ctx.urlRegistry,
261
499
  hookRunner: ctx.hookRunner,
262
500
  permission: permissionChecker,
263
- config: ctx.config,
501
+ config: sessionConfig,
264
502
  agentRegistry: ctx.agentRegistry,
265
503
  cwd,
266
504
  ...(ctx.chatStream ? { chatStream: ctx.chatStream } : {}),
@@ -269,7 +507,7 @@ function createChatRoute(ctx) {
269
507
  provider,
270
508
  model: resolvedModel,
271
509
  tools: resolvedTools,
272
- plugins: ctx.config.plugins.enabled,
510
+ plugins: sessionConfig.plugins.enabled,
273
511
  agentName,
274
512
  ...(agentDef.systemPrompt ? { agentRolePrompt: agentDef.systemPrompt } : {}),
275
513
  };
@@ -284,11 +522,12 @@ function createChatRoute(ctx) {
284
522
  model: resolvedModel,
285
523
  startedAt: runStartedAt,
286
524
  });
525
+ state.lastRunStartedAt = runStartedAt;
287
526
  // 填充 tryAcquire 留下的占位(原 register 语义:按 sessionId 覆盖写入)
288
527
  ctx.agentManager.register({ sessionId, state, deps });
289
528
  // workflowz 关键词检测:用户消息包含独立关键词时注入工作流通知(steering),
290
529
  // 引导模型用 task 工具批量 fan-out 做确定性多子 agent 分解。
291
- if (containsWorkflow(message)) {
530
+ if (message && containsWorkflow(message)) {
292
531
  const wfList = ctx.workflowRegistry
293
532
  ? ctx.workflowRegistry.list().map((w) => ({
294
533
  name: w.meta.name,
@@ -349,6 +588,7 @@ function createChatRoute(ctx) {
349
588
  .catch(() => { });
350
589
  }
351
590
  finally {
591
+ stopHeartbeat();
352
592
  // 先从 agentManager 释放(占位与活跃 run 均幂等):必须最先执行,确保
353
593
  // 客户端断开使 writeSSE reject 时 agent 状态(state/deps/AbortController)
354
594
  // 不会泄漏到 Map(unregister 仅做 Map.delete、不依赖 state,放最前安全)。
@@ -406,8 +646,26 @@ function createChatRoute(ctx) {
406
646
  });
407
647
  app.post('/steer', async (c) => {
408
648
  const body = await c.req.json();
409
- return (runStarting(c, body.sessionId) ??
410
- c.json({ steered: ctx.agentManager.steer(body.sessionId, body.message) }));
649
+ const starting = runStarting(c, body.sessionId);
650
+ if (starting)
651
+ return starting;
652
+ const message = typeof body.message === 'string' ? body.message.trim() : '';
653
+ if (!message)
654
+ return c.json({ steered: false });
655
+ const steered = ctx.agentManager.steer(body.sessionId, message);
656
+ // P0 闭环:steering 持久化为会话条目——用户运行中输入追加指令曾是内存瞬态,
657
+ // 刷新后彻底丢失;现在与消息同样入史(context 重建时作为 system 消息注入,
658
+ // 时间线经 /messages 渲染为 user steering 块)。
659
+ if (steered) {
660
+ await insertEntry(ctx.db, {
661
+ sessionId: body.sessionId,
662
+ tag: 'steering',
663
+ content: { text: message },
664
+ }).catch(() => {
665
+ // 持久化失败不阻塞本轮生效(指令已进入 run 队列)
666
+ });
667
+ }
668
+ return c.json({ steered });
411
669
  });
412
670
  return app;
413
671
  }
@@ -1,5 +1,7 @@
1
1
  import { Hono } from 'hono';
2
2
  import type { ServerContext } from '../types.js';
3
- /** GET / — 返回内置斜杠命令列表(name/description/argsHint)。 */
4
- declare function createCommandsRoute(_ctx: ServerContext): Hono;
3
+ /** GET / — 返回内置斜杠命令列表(name/description/argsHint)。
4
+ * 按 config.slashCommands.enabled 过滤:enabled 为空 = 全部启用;
5
+ * 否则只返回启用项,保证弹窗发现性 = 可执行性(避免展示必被拒的命令)。 */
6
+ declare function createCommandsRoute(ctx: ServerContext): Hono;
5
7
  export { createCommandsRoute };