c0de-agent 1.12.0 → 1.13.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 (142) hide show
  1. package/README.md +51 -23
  2. package/dist/cli/commands/acp.js +26 -8
  3. package/dist/cli/commands/chat.js +70 -6
  4. package/dist/cli/commands/config.d.ts +2 -0
  5. package/dist/cli/commands/config.js +46 -0
  6. package/dist/cli/commands/serve.js +11 -2
  7. package/dist/cli/commands/sessions.js +51 -4
  8. package/dist/cli/commands/trust.d.ts +11 -0
  9. package/dist/cli/commands/trust.js +31 -0
  10. package/dist/cli/commands/update.js +4 -0
  11. package/dist/cli/deps.d.ts +9 -6
  12. package/dist/cli/deps.js +66 -19
  13. package/dist/cli/index.d.ts +4 -1
  14. package/dist/cli/index.js +92 -12
  15. package/dist/cli/modes/acp.js +39 -8
  16. package/dist/cli/modes/print.d.ts +3 -0
  17. package/dist/cli/modes/print.js +71 -8
  18. package/dist/core/agent.d.ts +1 -1
  19. package/dist/core/agent.js +9 -4
  20. package/dist/core/agents/builtin.js +2 -2
  21. package/dist/core/agents/index.d.ts +0 -1
  22. package/dist/core/agents/index.js +0 -1
  23. package/dist/core/config.d.ts +43 -1
  24. package/dist/core/config.js +163 -14
  25. package/dist/core/loop/segment.d.ts +3 -1
  26. package/dist/core/loop/segment.js +24 -1
  27. package/dist/core/loop/subagent.js +65 -32
  28. package/dist/core/loop.d.ts +7 -0
  29. package/dist/core/loop.js +90 -6
  30. package/dist/core/slash.d.ts +10 -0
  31. package/dist/core/slash.js +150 -20
  32. package/dist/core/types.d.ts +20 -1
  33. package/dist/core/workflows/context.js +44 -4
  34. package/dist/core/workflows/discovery.d.ts +2 -2
  35. package/dist/core/workflows/discovery.js +5 -7
  36. package/dist/core/workflows/index.d.ts +2 -1
  37. package/dist/core/workflows/index.js +2 -1
  38. package/dist/core/workflows/registry.d.ts +13 -3
  39. package/dist/core/workflows/registry.js +14 -9
  40. package/dist/core/workflows/resolve.d.ts +22 -0
  41. package/dist/core/workflows/resolve.js +23 -0
  42. package/dist/core/workflows/runtime.d.ts +6 -1
  43. package/dist/core/workflows/runtime.js +19 -2
  44. package/dist/db/schema.d.ts +356 -1
  45. package/dist/db/schema.js +57 -3
  46. package/dist/kanban/index.d.ts +2 -1
  47. package/dist/kanban/index.js +1 -1
  48. package/dist/kanban/store.d.ts +84 -2
  49. package/dist/kanban/store.js +358 -25
  50. package/dist/llm/registry.d.ts +7 -1
  51. package/dist/llm/registry.js +7 -1
  52. package/dist/plugins/init.d.ts +7 -0
  53. package/dist/plugins/init.js +22 -1
  54. package/dist/plugins/loader.d.ts +7 -1
  55. package/dist/plugins/loader.js +12 -4
  56. package/dist/project/index.d.ts +1 -1
  57. package/dist/project/index.js +1 -1
  58. package/dist/project/project.d.ts +23 -0
  59. package/dist/project/project.js +65 -0
  60. package/dist/project/trust.d.ts +65 -0
  61. package/dist/project/trust.js +328 -0
  62. package/dist/server/agent-manager.d.ts +6 -2
  63. package/dist/server/agent-manager.js +4 -2
  64. package/dist/server/app.js +45 -30
  65. package/dist/server/auth-manager.d.ts +15 -4
  66. package/dist/server/auth-manager.js +41 -24
  67. package/dist/server/context.js +11 -1
  68. package/dist/server/index.d.ts +1 -1
  69. package/dist/server/index.js +1 -1
  70. package/dist/server/middleware/auth.d.ts +11 -1
  71. package/dist/server/middleware/auth.js +25 -3
  72. package/dist/server/permission/interactive.d.ts +5 -0
  73. package/dist/server/permission/interactive.js +4 -1
  74. package/dist/server/permission/store.d.ts +2 -0
  75. package/dist/server/permission/store.js +8 -0
  76. package/dist/server/routes/auth.js +35 -9
  77. package/dist/server/routes/chat.d.ts +16 -7
  78. package/dist/server/routes/chat.js +483 -28
  79. package/dist/server/routes/commands.d.ts +2 -2
  80. package/dist/server/routes/commands.js +4 -6
  81. package/dist/server/routes/config.js +81 -4
  82. package/dist/server/routes/files.js +120 -24
  83. package/dist/server/routes/kanban.js +152 -22
  84. package/dist/server/routes/permissions.js +7 -0
  85. package/dist/server/routes/project.js +76 -22
  86. package/dist/server/routes/provider.js +6 -0
  87. package/dist/server/routes/session.js +177 -13
  88. package/dist/server/routes/tool.js +24 -5
  89. package/dist/server/routes/update.js +58 -2
  90. package/dist/server/routes/usage.d.ts +4 -0
  91. package/dist/server/routes/usage.js +133 -0
  92. package/dist/server/routes/workflows.js +476 -87
  93. package/dist/server/server.d.ts +12 -1
  94. package/dist/server/server.js +194 -33
  95. package/dist/server/terminal/pty-manager.d.ts +12 -0
  96. package/dist/server/terminal/pty-manager.js +63 -7
  97. package/dist/server/types.d.ts +9 -0
  98. package/dist/session/branch.d.ts +2 -1
  99. package/dist/session/branch.js +75 -10
  100. package/dist/session/context.js +28 -2
  101. package/dist/session/index.d.ts +1 -1
  102. package/dist/session/index.js +1 -1
  103. package/dist/session/session.d.ts +67 -10
  104. package/dist/session/session.js +180 -67
  105. package/dist/session/types.d.ts +2 -0
  106. package/dist/session/usage.d.ts +62 -0
  107. package/dist/session/usage.js +194 -0
  108. package/dist/shared/types/agent.d.ts +19 -3
  109. package/dist/shared/types/config.d.ts +59 -4
  110. package/dist/shared/types/kanban.d.ts +4 -2
  111. package/dist/shared/types/message.d.ts +14 -0
  112. package/dist/tools/builtin/kanban.js +13 -2
  113. package/dist/tools/index.d.ts +14 -3
  114. package/dist/tools/index.js +23 -5
  115. package/dist/tools/websearch/index.d.ts +5 -1
  116. package/dist/tools/websearch/index.js +40 -4
  117. package/dist/tools/websearch/providers/brave.js +2 -2
  118. package/dist/tools/websearch/providers/duckduckgo.js +2 -1
  119. package/dist/tools/websearch/providers/tavily.js +2 -2
  120. package/dist/tools/websearch/types.d.ts +5 -1
  121. package/dist/tools/websearch/types.js +7 -1
  122. package/dist/update/scheduler.js +5 -2
  123. package/dist/update/snapshot.d.ts +17 -3
  124. package/dist/update/snapshot.js +3 -2
  125. package/dist/update/updater.d.ts +2 -0
  126. package/dist/update/updater.js +2 -1
  127. package/dist/update/version.d.ts +3 -0
  128. package/dist/update/version.js +9 -3
  129. package/drizzle/0010_usage_events_ledger.sql +17 -0
  130. package/drizzle/0011_slow_slapstick.sql +12 -0
  131. package/drizzle/0012_chief_timeslip.sql +1 -0
  132. package/drizzle/0013_swift_zeigeist.sql +1 -0
  133. package/drizzle/0014_flimsy_legion.sql +1 -0
  134. package/drizzle/meta/0010_snapshot.json +1010 -0
  135. package/drizzle/meta/0011_snapshot.json +1055 -0
  136. package/drizzle/meta/0012_snapshot.json +1062 -0
  137. package/drizzle/meta/0013_snapshot.json +1068 -0
  138. package/drizzle/meta/0014_snapshot.json +1075 -0
  139. package/drizzle/meta/_journal.json +35 -0
  140. package/package.json +24 -23
  141. package/dist/core/agents/discovery.d.ts +0 -6
  142. package/dist/core/agents/discovery.js +0 -96
@@ -27,6 +27,15 @@ type AuthManagerOptions = {
27
27
  firstDeviceTtlMs?: number;
28
28
  now?: () => number;
29
29
  };
30
+ /** 首设备注册结果:成功返回设备 token;失败返回可区分原因(路由据此给出
31
+ * 正确的恢复指引——「注册链接过期」与「已有设备」的补救动作完全不同)。 */
32
+ type FirstDeviceRegistration = {
33
+ ok: true;
34
+ deviceToken: string;
35
+ } | {
36
+ ok: false;
37
+ reason: 'static_mode' | 'invalid_bootstrap' | 'devices_exist' | 'ttl_expired';
38
+ };
30
39
  export type AuthManager = {
31
40
  /** 当前 bootstrap token(静态模式下即 staticToken)。 */
32
41
  readonly bootstrap: string | undefined;
@@ -37,8 +46,8 @@ export type AuthManager = {
37
46
  verifyHandoff(token: string | undefined): boolean;
38
47
  /** 首次设备注册:校验 bootstrap 后换发设备 token 并轮换 bootstrap。
39
48
  * 无设备时首个凭 bootstrap 的请求即视为首设备(免审批),后续请求需配对审批。
40
- * 返回新设备 token;bootstrap 已失效/已注册过 → 返回 null。 */
41
- registerFirstDevice(bootstrapToken: string, deviceName: string): Promise<string | null>;
49
+ * 失败返回具体原因(static_mode / invalid_bootstrap / devices_exist / ttl_expired)。 */
50
+ registerFirstDevice(bootstrapToken: string, deviceName: string): Promise<FirstDeviceRegistration>;
42
51
  /** 发起配对请求(未认证):返回 { pairingId, code };静态模式/未启用返回 null。
43
52
  * source 为请求来源(IP 等,尽力而为),用于审批展示与按来源限流。 */
44
53
  requestPairing(deviceName: string, source?: string): {
@@ -64,8 +73,10 @@ export type AuthManager = {
64
73
  createdAt: number;
65
74
  source: string;
66
75
  }>;
67
- /** 审批配对(需已认证设备调用):通过后为请求设备签发 token。 */
68
- approvePairing(pairingId: string): boolean;
76
+ /** 审批配对(需已认证设备调用):核对审批方输入的 6 位配对码,匹配才签发 token。
77
+ * P2-9:此前按 pairingId 直接批准,多请求并存时看错行批准即给错设备发 token;
78
+ * 码是展示信息不参与校验。现在码必须由审批方输入(新设备屏幕显示)——防误批。 */
79
+ approvePairing(pairingId: string, code: string): 'approved' | 'not_found' | 'expired' | 'code_mismatch' | 'already_handled';
69
80
  /** 拒绝配对。 */
70
81
  denyPairing(pairingId: string): boolean;
71
82
  /** 列出已授权设备(id/name/createdAt)。 */
@@ -133,9 +133,10 @@ export function createAuthManager(opts) {
133
133
  function cleanExpiredPairings() {
134
134
  const cutoff = now() - pairingTtlMs;
135
135
  for (const [id, p] of pending) {
136
- if (p.status === 'pending' && p.createdAt <= cutoff) {
136
+ // 不论状态一律过期清理:approved 但新设备始终未取走 token 的条目(客户端放弃轮询)
137
+ // 同样需要回收,否则长驻内存且其明文 token 无期限留存。
138
+ if (p.createdAt <= cutoff)
137
139
  pending.delete(id);
138
- }
139
140
  }
140
141
  }
141
142
  return {
@@ -160,17 +161,19 @@ export function createAuthManager(opts) {
160
161
  },
161
162
  async registerFirstDevice(bootstrapToken, deviceName) {
162
163
  if (staticToken && staticToken.length > 0)
163
- return null; // 静态模式无设备注册
164
- if (!verifyBootstrap(bootstrapToken))
165
- return null;
166
- // 已有设备 → bootstrap 已失效,不得再凭它注册(需配对审批)
164
+ return { ok: false, reason: 'static_mode' };
165
+ // 已有设备 → bootstrap 已失效(注册即轮换),不得再凭它注册(需配对审批)。
166
+ // 先于 verifyBootstrap 判定:轮换后的旧 URL token 属「已消费」而非「无效」,
167
+ // 两者给用户的恢复指引不同(配对审批 vs 检查 token)。
167
168
  if (devices.size > 0)
168
- return null;
169
+ return { ok: false, reason: 'devices_exist' };
170
+ if (!verifyBootstrap(bootstrapToken))
171
+ return { ok: false, reason: 'invalid_bootstrap' };
169
172
  // 首设备注册窗口:bootstrap 超过 firstDeviceTtlMs 后拒绝(缩短先到先得竞态窗口)。
170
173
  if (firstDeviceTtlMs > 0) {
171
174
  const age = bootstrapAge();
172
175
  if (age != null && age > firstDeviceTtlMs)
173
- return null;
176
+ return { ok: false, reason: 'ttl_expired' };
174
177
  }
175
178
  const deviceToken = randomBytes(32).toString('hex');
176
179
  const record = {
@@ -182,7 +185,7 @@ export function createAuthManager(opts) {
182
185
  devices.set(record.id, record);
183
186
  persist();
184
187
  rotateBootstrap(); // 注册成功即轮换,旧 bootstrap(URL/shell history 中)立即失效
185
- return deviceToken;
188
+ return { ok: true, deviceToken };
186
189
  },
187
190
  requestPairing(deviceName, source) {
188
191
  if (staticToken && staticToken.length > 0)
@@ -213,8 +216,24 @@ export function createAuthManager(opts) {
213
216
  const p = pending.get(pairingId);
214
217
  if (!p)
215
218
  return { status: 'not_found' };
216
- if (p.status === 'approved')
217
- return { status: 'approved', deviceToken: p.deviceToken ?? '' };
219
+ if (p.status === 'approved') {
220
+ // 交付点:把设备登记落盘与「新设备取走 token」原子化到同一时刻——
221
+ // 审批后、取 token 前服务重启时 pending 随内存清空(status=not_found),
222
+ // 不会留下拿不到 token 的僵尸设备条目;此后新设备重新发起配对即可。
223
+ const deviceToken = p.deviceToken ?? '';
224
+ if (deviceToken) {
225
+ const record = {
226
+ id: randomBytes(16).toString('hex'),
227
+ name: p.deviceName,
228
+ tokenHash: hashToken(deviceToken),
229
+ createdAt: now(),
230
+ };
231
+ devices.set(record.id, record);
232
+ persist();
233
+ pending.delete(pairingId);
234
+ }
235
+ return { status: 'approved', deviceToken };
236
+ }
218
237
  if (p.status === 'denied')
219
238
  return { status: 'denied' };
220
239
  if (p.createdAt <= now() - pairingTtlMs) {
@@ -235,28 +254,26 @@ export function createAuthManager(opts) {
235
254
  source: p.source,
236
255
  }));
237
256
  },
238
- approvePairing(pairingId) {
257
+ approvePairing(pairingId, code) {
239
258
  const p = pending.get(pairingId);
240
259
  if (!p)
241
- return false;
260
+ return 'not_found';
242
261
  if (p.status !== 'pending')
243
- return false;
262
+ return 'already_handled';
244
263
  if (p.createdAt <= now() - pairingTtlMs) {
245
264
  pending.delete(pairingId);
246
- return false;
265
+ return 'expired';
247
266
  }
267
+ if (String(code ?? '').trim() !== p.code)
268
+ return 'code_mismatch';
269
+ // P:登记推迟到新设备真正取走 token 时(pairingStatus 消费)。此前审批即落盘,
270
+ // 若审批后、新设备轮询前服务重启,devices.json 里会出现一个永远拿不到 token
271
+ // 的僵尸条目(pending 不落盘、明文 token 只在内存)——「已授权设备」列表与
272
+ // 实际可登录设备自相矛盾,用户只能撤销后重新配对。
248
273
  const deviceToken = randomBytes(32).toString('hex');
249
- const record = {
250
- id: randomBytes(16).toString('hex'),
251
- name: p.deviceName,
252
- tokenHash: hashToken(deviceToken),
253
- createdAt: now(),
254
- };
255
- devices.set(record.id, record);
256
274
  p.status = 'approved';
257
275
  p.deviceToken = deviceToken;
258
- persist();
259
- return true;
276
+ return 'approved';
260
277
  },
261
278
  denyPairing(pairingId) {
262
279
  const p = pending.get(pairingId);
@@ -33,10 +33,20 @@ function createServerContext(opts) {
33
33
  hookRunner,
34
34
  pluginRegistry: createPluginRegistry(hookRunner),
35
35
  agentManager: createAgentManager(),
36
- permissionStore: createPermissionStore(),
36
+ // P3:与生产 buildServerContext 同口径——配置的确认超时/宽限期生效。
37
+ permissionStore: createPermissionStore({
38
+ ...(config.permission.timeoutMs !== undefined && config.permission.timeoutMs > 0
39
+ ? { timeoutMs: config.permission.timeoutMs }
40
+ : {}),
41
+ ...(config.permission.expireGraceMs !== undefined && config.permission.expireGraceMs > 0
42
+ ? { expireGraceMs: config.permission.expireGraceMs }
43
+ : {}),
44
+ }),
37
45
  permissionMode: config.permission.defaultMode,
38
46
  sessionPermissionModes: new Map(),
39
47
  sessionAlwaysAllow: new Map(),
48
+ workflowBusyBySession: new Map(),
49
+ workflowBusyByScope: new Map(),
40
50
  // P1-1:项目级 LLM 注册表缓存(按项目合并配置构建,chat 路由按需创建)。
41
51
  projectRegistries: new Map(),
42
52
  // 测试工厂不解析/生成 token(不触碰用户全局数据目录)。
@@ -7,5 +7,5 @@ export { apiError, errorHandler } from './middleware/error.js';
7
7
  export type { InteractivePermissionChecker, InteractivePermissionCheckerOptions, PermissionRequest, } from './permission/interactive.js';
8
8
  export { createInteractivePermissionChecker } from './permission/interactive.js';
9
9
  export type { RunningServer, StartServerOptions } from './server.js';
10
- export { startServer } from './server.js';
10
+ export { DEFAULT_SERVE_PORT, startServer } from './server.js';
11
11
  export type { APIErrorBody, ChatRequest, ConfirmRequest, ControlRequest, ServerContext, SteerRequest, } from './types.js';
@@ -4,4 +4,4 @@ export { createApp } from './app.js';
4
4
  export { createServerContext } from './context.js';
5
5
  export { apiError, errorHandler } from './middleware/error.js';
6
6
  export { createInteractivePermissionChecker } from './permission/interactive.js';
7
- export { startServer } from './server.js';
7
+ export { DEFAULT_SERVE_PORT, startServer } from './server.js';
@@ -4,11 +4,21 @@ export type AuthOptions = {
4
4
  publicPaths?: string[];
5
5
  /** P2-16:自定义校验函数(authManager.verify)。提供时忽略 token 字符串比较。 */
6
6
  verify?: (token: string | undefined) => boolean;
7
+ /** 认证未启用时的跨域写防线(盲 CSRF):CORS 回显只拦「读」——恶意网页对本地
8
+ * 服务的 POST/DELETE 等请求副作用仍会执行,只是读不到响应。开启认证(token/verify
9
+ * 存在)时凭 token 校验已构成防线,此项不生效。 */
10
+ allowedOrigins?: string[];
11
+ /** 允许经 `?token=` 查询参数认证的路径判定(P1 媒体预览修复:浏览器
12
+ * <img>/<audio>/<video>/<embed> 元素无法携带 Authorization 头,与终端
13
+ * WebSocket 的 query token 同口径)。仅当 Authorization 头缺失/无效时才
14
+ * 检查 query token——头优先,不会因路径匹配而放行任意 query token。 */
15
+ queryTokenPath?: (path: string) => boolean;
7
16
  };
8
17
  /**
9
18
  * Bearer token 认证中间件(spec §24.2「认证」)。
10
19
  *
11
- * - 未配置 token(undefined/空)→ 放行,适配本地开发场景。
20
+ * - 未配置 token(undefined/空)→ 放行,适配本地开发场景;但对「写」请求校验
21
+ * Origin(仅本地回环 + allowedOrigins),补上 authEnabled=false 时 CORS 不设防的盲写面。
12
22
  * - 配置了 token → 校验 `Authorization: Bearer <token>`,不匹配返回 401。
13
23
  * - verify 提供时(P2-16 设备 token)→ 以 verify 结果为准。
14
24
  * - publicPaths 中的路径始终放行(如健康检查、认证/配对引导端点)。
@@ -1,8 +1,10 @@
1
+ import { isAllowedOrigin } from './cors.js';
1
2
  import { apiError } from './error.js';
2
3
  /**
3
4
  * Bearer token 认证中间件(spec §24.2「认证」)。
4
5
  *
5
- * - 未配置 token(undefined/空)→ 放行,适配本地开发场景。
6
+ * - 未配置 token(undefined/空)→ 放行,适配本地开发场景;但对「写」请求校验
7
+ * Origin(仅本地回环 + allowedOrigins),补上 authEnabled=false 时 CORS 不设防的盲写面。
6
8
  * - 配置了 token → 校验 `Authorization: Bearer <token>`,不匹配返回 401。
7
9
  * - verify 提供时(P2-16 设备 token)→ 以 verify 结果为准。
8
10
  * - publicPaths 中的路径始终放行(如健康检查、认证/配对引导端点)。
@@ -10,8 +12,19 @@ import { apiError } from './error.js';
10
12
  export function createAuthMiddleware(token, opts = {}) {
11
13
  const expected = token && token.length > 0 ? `Bearer ${token}` : '';
12
14
  const publicPaths = new Set(opts.publicPaths ?? ['/api/health']);
15
+ const queryTokenPath = opts.queryTokenPath;
13
16
  return async (c, next) => {
14
17
  if (!expected && !opts.verify) {
18
+ // 认证关闭:无 token 可验证。带 Origin 的非安全方法(浏览器跨域写)需显式放行
19
+ // 本地/已配置 origin;无 Origin 的请求(curl/CLI/脚本/服务间调用)不受影响。
20
+ const method = c.req.method;
21
+ const stateChanging = method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS';
22
+ if (stateChanging) {
23
+ const origin = c.req.header('Origin') ?? '';
24
+ if (origin.length > 0 && !isAllowedOrigin(origin, opts.allowedOrigins)) {
25
+ return apiError(c, 403, 'ORIGIN_FORBIDDEN', 'Cross-origin write rejected while authentication is disabled');
26
+ }
27
+ }
15
28
  await next();
16
29
  return;
17
30
  }
@@ -19,8 +32,17 @@ export function createAuthMiddleware(token, opts = {}) {
19
32
  await next();
20
33
  return;
21
34
  }
22
- const auth = c.req.header('Authorization') ?? '';
23
- const ok = opts.verify ? opts.verify(auth.replace(/^Bearer\s+/i, '')) : auth === expected;
35
+ const headerAuth = c.req.header('Authorization') ?? '';
36
+ let ok = opts.verify
37
+ ? opts.verify(headerAuth.replace(/^Bearer\s+/i, ''))
38
+ : headerAuth === expected;
39
+ // P1 媒体预览:浏览器媒体元素无 Authorization 头——对判定命中的路径尝试
40
+ // query token 认证。头已有效则不再检查(头优先);路径不匹配时 query token
41
+ // 不参与判定,仅在白名单路径上接受 query 认证。
42
+ if (!ok && queryTokenPath?.(c.req.path)) {
43
+ const queryToken = c.req.query('token') ?? '';
44
+ ok = opts.verify ? opts.verify(queryToken) : `Bearer ${queryToken}` === expected;
45
+ }
24
46
  if (!ok) {
25
47
  return apiError(c, 401, 'UNAUTHORIZED', 'Missing or invalid bearer token');
26
48
  }
@@ -16,6 +16,11 @@ type InteractivePermissionCheckerOptions = {
16
16
  onPermissionTimeout?: (request: PermissionRequest) => void;
17
17
  /** 兜底过期(提示后仍无响应,自动拒绝)时调用(P0 双层超时:前端清理弹窗状态)。 */
18
18
  onPermissionExpired?: (request: PermissionRequest) => void;
19
+ /** 权限请求「报告归属」的会话 id。缺省取 tool context 的会话——子 agent 的 ask
20
+ * 请求会挂在各自子会话上,用户所在的主/工作流会话按 sessionId 重挂 pending
21
+ * 弹窗时找不到。传入发起 run 的用户可见会话 id,使 GET /api/permissions/:id/pending
22
+ * 能从该会话恢复全部子 agent 的挂起请求。 */
23
+ reportSessionId?: string;
19
24
  };
20
25
  /** 阻塞式权限检查器:ask 权限会阻塞等待用户确认。
21
26
  * pending 存在全局 PermissionStore 中,独立于 agent run 生命周期。 */
@@ -29,13 +29,16 @@ function createInteractivePermissionChecker(store, opts = {}) {
29
29
  }
30
30
  // 交互式确认
31
31
  const toolCallId = randomUUID();
32
+ const reportedSessionId = opts.reportSessionId ?? ctx.session?.id;
32
33
  const request = {
33
34
  toolCallId,
34
35
  tool: tool.name,
35
36
  input,
36
37
  // P1:绑定会话 id——挂起期间用户切换页面后,前端经
37
38
  // GET /api/permissions/:sessionId/pending 重挂确认弹窗。
38
- ...(ctx.session?.id ? { sessionId: ctx.session.id } : {}),
39
+ // 子 agent 请求按 reportSessionId 归属到发起 run 的用户可见会话,
40
+ // 而不是各自的子会话(用户不会停留在子会话页面上等确认)。
41
+ ...(reportedSessionId ? { sessionId: reportedSessionId } : {}),
39
42
  };
40
43
  const promise = new Promise((resolve) => {
41
44
  // pending 注册到全局 store,confirm 端点按 toolCallId 直接寻址,
@@ -38,6 +38,8 @@ type PermissionStore = {
38
38
  size(): number;
39
39
  /** 查询某会话当前挂起的权限请求(P1:前端重挂弹窗;无则 null)。 */
40
40
  pendingForSession(sessionId: string): PermissionRequest | null;
41
+ /** P3:统计指定会话集合内的挂起请求数(update 影响面按受影响会话过滤用)。 */
42
+ countForSessions(sessionIds: ReadonlySet<string>): number;
41
43
  /** settle 所有 pending 为 deny 并清空(dev 热重载重建前调用)。 */
42
44
  dispose(): void;
43
45
  };
@@ -71,6 +71,14 @@ function createPermissionStore(opts = {}) {
71
71
  }
72
72
  return null;
73
73
  },
74
+ countForSessions(sessionIds) {
75
+ let n = 0;
76
+ for (const p of pending.values()) {
77
+ if (p.request.sessionId && sessionIds.has(p.request.sessionId))
78
+ n += 1;
79
+ }
80
+ return n;
81
+ },
74
82
  dispose() {
75
83
  // dev 热重载重建前调用:所有 pending settle 为 deny(避免悬空 Promise +
76
84
  // timer 泄漏),clearTimeout 后清空 Map。
@@ -35,13 +35,24 @@ function createAuthRoute(ctx) {
35
35
  if (!body.token) {
36
36
  return apiError(c, 400, 'BAD_REQUEST', 'token is required');
37
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 已失效(已有设备注册)。请在新设备发起配对,由已授权设备审批。');
38
+ const result = await ctx.authManager.registerFirstDevice(body.token, body.deviceName ?? '设备');
39
+ if (!result.ok) {
40
+ // 区分失败原因给正确的恢复指引:链接过期 → 重启 serve 换新链接;
41
+ // 已有设备 → 走配对审批;两者补救动作完全不同,不得混用同一文案。
42
+ if (result.reason === 'ttl_expired') {
43
+ return apiError(c, 403, 'BOOTSTRAP_EXPIRED', '注册链接已过期(超过安全窗口未使用)。请重启 c0de serve——无已注册设备时' +
44
+ '重启会重新生成注册链接,打开启动日志中打印的新 URL 完成首次注册。');
45
+ }
46
+ if (result.reason === 'devices_exist') {
47
+ return apiError(c, 403, 'BOOTSTRAP_CONSUMED', 'bootstrap token 已失效(已有设备注册)。请在新设备发起配对,由已授权设备审批。');
48
+ }
49
+ if (result.reason === 'invalid_bootstrap') {
50
+ return apiError(c, 403, 'BOOTSTRAP_INVALID', 'bootstrap token 无效。');
51
+ }
52
+ return apiError(c, 403, 'STATIC_TOKEN_MODE', '静态 token 模式不支持设备注册。');
42
53
  }
43
54
  // P1-3:回传注册成功的设备名,前端展示一次性确认(用户可核对注册的是否自己)。
44
- return c.json({ deviceToken, deviceName: body.deviceName ?? '设备' });
55
+ return c.json({ deviceToken: result.deviceToken, deviceName: body.deviceName ?? '设备' });
45
56
  });
46
57
  // 新设备发起配对请求(公开)。
47
58
  app.post('/pairing/request', async (c) => {
@@ -53,7 +64,12 @@ function createAuthRoute(ctx) {
53
64
  if (!result) {
54
65
  return apiError(c, 429, 'PAIRING_LIMIT', '待审批的配对请求过多,请稍后再试');
55
66
  }
56
- return c.json(result);
67
+ // L3:零已授权设备时配对审批不可能完成(无人可批准)——前端据此展示
68
+ // 恢复指引(重启 serve 重新生成 bootstrap 注册链接)而非干等审批。
69
+ return c.json({
70
+ ...result,
71
+ hasAuthorizedDevices: ctx.authManager.listDevices().length > 0,
72
+ });
57
73
  });
58
74
  // 新设备轮询配对审批结果(公开)。
59
75
  app.get('/pairing/status', (c) => {
@@ -76,7 +92,8 @@ function createAuthRoute(ctx) {
76
92
  }
77
93
  return c.json({ pairings: ctx.authManager.listPairings() });
78
94
  });
79
- // 审批通过(需认证)。
95
+ // 审批通过(需认证)。P2-9:必须携带审批方输入的 6 位配对码(新设备屏幕显示),
96
+ // 服务端核对匹配才签发——防多请求并存时看错行误批给错设备。
80
97
  app.post('/pairing/approve', async (c) => {
81
98
  if (!ctx.authManager) {
82
99
  return apiError(c, 400, 'AUTH_DISABLED', '认证未启用');
@@ -84,9 +101,18 @@ function createAuthRoute(ctx) {
84
101
  const body = (await c.req.json().catch(() => ({})));
85
102
  if (!body.pairingId)
86
103
  return apiError(c, 400, 'BAD_REQUEST', 'pairingId is required');
87
- const ok = ctx.authManager.approvePairing(body.pairingId);
88
- if (!ok)
104
+ if (!body.code)
105
+ return apiError(c, 400, 'BAD_REQUEST', 'code is required');
106
+ const result = ctx.authManager.approvePairing(body.pairingId, body.code);
107
+ if (result === 'code_mismatch') {
108
+ return apiError(c, 400, 'PAIRING_CODE_MISMATCH', '配对码不匹配:请核对新设备屏幕显示的 6 位配对码后重新输入');
109
+ }
110
+ if (result === 'not_found' || result === 'expired') {
89
111
  return apiError(c, 404, 'PAIRING_NOT_FOUND', '配对请求不存在或已过期');
112
+ }
113
+ if (result === 'already_handled') {
114
+ return apiError(c, 409, 'PAIRING_ALREADY_HANDLED', '该配对请求已被处理');
115
+ }
90
116
  return c.json({ ok: true });
91
117
  });
92
118
  // 拒绝配对(需认证)。
@@ -1,5 +1,7 @@
1
1
  import { Hono } from 'hono';
2
2
  import { type SSEStreamingApi } from 'hono/streaming';
3
+ import type { RegisterChildRun } from '../../core/types.js';
4
+ import type { AgentManager } from '../agent-manager.js';
3
5
  import type { ServerContext } from '../types.js';
4
6
  /** 按 session.projectId / worktreePath 解析 agent 工作目录:
5
7
  * - 有 projectId → 项目 worktree(缺失/目录失效时抛错,由上层转 409)。
@@ -10,16 +12,23 @@ export declare function resolveAgentCwd(ctx: ServerContext, session: {
10
12
  projectId: string | null;
11
13
  worktreePath?: string | null;
12
14
  }): 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
15
  declare const SSE_HEARTBEAT_INTERVAL_MS = 30000;
21
16
  /** 启动心跳定时器;返回停止函数(幂等)。流关闭后写入会被吞掉,安全。 */
22
17
  declare function startHeartbeat(stream: SSEStreamingApi): () => void;
23
18
  export { SSE_HEARTBEAT_INTERVAL_MS, startHeartbeat };
19
+ /**
20
+ * 子 agent run 注册桥接:core 的 registerChildRun → agentManager 槽位。
21
+ * P1:此前子 run(含后台任务)从不注册,暂停/热更新/删除级联/更新影响面
22
+ * 全部绕过它们;core 无 agentManager 引用,桥接由 Web 宿主注入。
23
+ * 返回的注销函数幂等(unregister 对不存在的 key 安全)。
24
+ */
25
+ export declare function childRunBridge(manager: AgentManager): RegisterChildRun;
26
+ /**
27
+ * 会话「忙」判定统一口径:活跃 run / tryAcquire 占位(starting)/ 工作流运行期
28
+ * (发起会话无主 run,busy 映射承载)。变更型操作(fork/compact/clear 等)在斜杠与
29
+ * REST 双入口共用本判定,防止守卫漂移——REST fork 此前只查 get(),漏 starting
30
+ * 占位窗口与工作流运行期两种态(斜杠守卫早已覆盖)。
31
+ */
32
+ export declare function hasBusySession(ctx: ServerContext, sessionId: string): 'run' | 'starting' | 'workflow' | null;
24
33
  declare function createChatRoute(ctx: ServerContext): Hono;
25
34
  export { createChatRoute };