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
package/dist/cli/deps.js CHANGED
@@ -1,6 +1,9 @@
1
+ import { loadConfigScopes } from '../core/config.js';
1
2
  import { decryptSecret } from '../core/secret.js';
2
3
  import { createRegistry, overrideToCapabilities, registerProvider } from '../llm/index.js';
3
4
  import { initPlugins } from '../plugins/index.js';
5
+ import { getByDirectory } from '../project/index.js';
6
+ import { projectTrustCurrent } from '../project/trust.js';
4
7
  import { createDefaultRegistry, createDefaultURLRegistry } from '../tools/index.js';
5
8
  import { autoAllowChecker } from '../tools/permission.js';
6
9
  /** 真正非交互模式(print/acp):所有工具无条件放行(命令由用户显式触发)。 */
@@ -14,20 +17,29 @@ const fullyAutoApproveChecker = {
14
17
  * 非交互模式(print)安全策略(P1-6):ask 工具**直接拒绝**并给出可操作提示
15
18
  * (-y 或 serve),而非返回 permission_required 让 LLM 在无确认通道下无限重试。
16
19
  * 拒绝原因作为 tool result 交给模型,模型会向用户转述。
20
+ * `allowTools`(--allow 白名单)内的 ask 工具改为放行,提供「全拒 / 全放行」之间的
21
+ * 逐工具粒度;未列出的 ask 工具仍拒绝。
17
22
  */
18
- const nonInteractiveSafeChecker = {
19
- check: async (tool, _input, _ctx) => {
20
- const result = await autoAllowChecker.check(tool, _input, _ctx);
21
- if (result._tag === 'ask') {
22
- return {
23
- _tag: 'deny',
24
- reason: `非交互模式:工具 "${tool.name}" 需要确认。请加 -y 放行写操作,或使用 c0de serve 交互确认`,
25
- };
26
- }
27
- return result;
28
- },
29
- confirm: autoAllowChecker.confirm,
30
- };
23
+ function createNonInteractiveChecker(allowTools) {
24
+ const allowSet = new Set(allowTools ?? []);
25
+ return {
26
+ check: async (tool, _input, _ctx) => {
27
+ const result = await autoAllowChecker.check(tool, _input, _ctx);
28
+ if (result._tag === 'ask') {
29
+ if (allowSet.has(tool.name))
30
+ return { _tag: 'allow' };
31
+ return {
32
+ _tag: 'deny',
33
+ reason: `非交互模式:工具 "${tool.name}" 需要确认。请加 -y 全量放行、--allow <工具名> 定向放行,或使用 c0de serve 交互确认`,
34
+ };
35
+ }
36
+ return result;
37
+ },
38
+ confirm: autoAllowChecker.confirm,
39
+ };
40
+ }
41
+ /** 无白名单的非交互安全策略(保留导出兼容既有调用方)。 */
42
+ const nonInteractiveSafeChecker = createNonInteractiveChecker();
31
43
  /** 把 config.providers 注册到新建的 LLM registry。 */
32
44
  function buildLLMRegistry(config) {
33
45
  const registry = createRegistry();
@@ -53,25 +65,52 @@ function registerProviderFromConfig(registry, p) {
53
65
  ...(p.models ? { models: overrideToCapabilities(p.models) } : {}),
54
66
  });
55
67
  }
56
- /** 解析权限 checker:显式策略优先,否则回退到 config.permission.defaultMode。 */
57
- function resolvePermissionChecker(config, strategy) {
68
+ /** 解析权限 checker:显式策略优先,否则回退到 config.permission.defaultMode。
69
+ * 无 --allow 白名单时返回单例 nonInteractiveSafeChecker(保持对象同一性);有白名单
70
+ * 时按白名单新建 checker(--allow 只影响 ask 工具的定向放行)。 */
71
+ function resolvePermissionChecker(config, strategy, allowTools) {
58
72
  if (strategy === 'full-auto')
59
73
  return fullyAutoApproveChecker;
74
+ const listed = allowTools !== undefined && allowTools.length > 0;
60
75
  if (strategy === 'safe')
61
- return nonInteractiveSafeChecker;
76
+ return listed ? createNonInteractiveChecker(allowTools) : nonInteractiveSafeChecker;
62
77
  return config.permission.defaultMode === 'auto'
63
78
  ? fullyAutoApproveChecker
64
- : nonInteractiveSafeChecker;
79
+ : listed
80
+ ? createNonInteractiveChecker(allowTools)
81
+ : nonInteractiveSafeChecker;
82
+ }
83
+ /**
84
+ * cwd 是否当前可信项目(trustedAt 非空且指纹未漂移)。与聊天门禁/插件加载同口径;
85
+ * CLI 工作流注册表(内置+用户级+项目级)的构建也用它决定是否 import 项目级文件。
86
+ */
87
+ async function resolveCwdProjectTrusted(db, cwd) {
88
+ try {
89
+ const p = await getByDirectory(db, cwd);
90
+ if (p?.trustedAt != null) {
91
+ return projectTrustCurrent(loadConfigScopes(cwd).project, p.trustedAt, p.riskFingerprint, cwd);
92
+ }
93
+ }
94
+ catch {
95
+ // 查询失败 → 未信任
96
+ }
97
+ return false;
65
98
  }
66
99
  /** 组装完整 LoopDeps(默认 safe 放行 + 默认工具注册表)。 */
67
100
  async function buildAgentDeps(config, opts) {
68
101
  const llmRegistry = buildLLMRegistry(config);
69
102
  const toolRegistry = createDefaultRegistry(config);
103
+ // P0-2:项目插件仅在项目被显式信任后加载(c0de trust <dir>);内存库
104
+ // (--temp/锁冲突降级)不含项目记录 → 未信任,项目插件不加载。
105
+ // P0(代码面):已信任但指纹漂移(插件代码/MCP 参数/风险键变更)→ 不加载,
106
+ // 与聊天门禁同口径。
107
+ const projectTrusted = await resolveCwdProjectTrusted(opts.db, opts.cwd);
70
108
  const { hookRunner } = await initPlugins({
71
109
  cwd: opts.cwd,
72
110
  config,
73
111
  toolRegistry,
74
112
  llmRegistry,
113
+ projectTrusted,
75
114
  });
76
115
  const deps = {
77
116
  db: opts.db,
@@ -79,11 +118,19 @@ async function buildAgentDeps(config, opts) {
79
118
  toolRegistry,
80
119
  urlRegistry: createDefaultURLRegistry(),
81
120
  hookRunner,
82
- permission: resolvePermissionChecker(config, opts.permissionStrategy),
121
+ permission: resolvePermissionChecker(config, opts.permissionStrategy, opts.allowTools),
83
122
  config,
84
123
  cwd: opts.cwd,
85
124
  ...(opts.chatStream ? { chatStream: opts.chatStream } : {}),
125
+ // P:预算护栏 CLI 变体——print/acp 无恢复 UI,超支中止 run(产出 error)而非
126
+ // 暂停永久挂起。金额与 token 任一动作='pause'/'abort' 即启用(每轮 LLM 请求前检查)。
127
+ ...(config.usage?.budgetAction === 'pause' ||
128
+ config.usage?.budgetAction === 'abort' ||
129
+ config.usage?.tokenBudgetAction === 'pause' ||
130
+ config.usage?.tokenBudgetAction === 'abort'
131
+ ? { budgetAbort: true }
132
+ : {}),
86
133
  };
87
134
  return deps;
88
135
  }
89
- export { buildAgentDeps, buildLLMRegistry, fullyAutoApproveChecker, nonInteractiveSafeChecker };
136
+ export { buildAgentDeps, buildLLMRegistry, fullyAutoApproveChecker, nonInteractiveSafeChecker, resolveCwdProjectTrusted, };
@@ -4,5 +4,8 @@ declare const COMMANDS: CommandSpec[];
4
4
  type DispatchOverrides = {
5
5
  runServe?: () => Promise<void>;
6
6
  };
7
+ /** P1-2:解析 --budget-action 覆盖值。CLI 无「恢复」UI,config 的 pause 在此等效 abort;
8
+ * 接受 warn|abort(pause 兼容映射到 abort)。非法值抛错并提示可取值。 */
9
+ declare function parseBudgetActionOption(value: string | undefined): 'warn' | 'abort' | undefined;
7
10
  declare function dispatch(argv: string[], overrides?: DispatchOverrides): Promise<void>;
8
- export { COMMANDS, dispatch };
11
+ export { COMMANDS, dispatch, parseBudgetActionOption };
package/dist/cli/index.js CHANGED
@@ -4,7 +4,8 @@ import { mkdirSync } from 'node:fs';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { loadConfig } from '../core/config.js';
6
6
  import { createDB, migrateDB } from '../db/index.js';
7
- import { acquireDevDbLock, releaseDevDbLock, resolveDbDir } from '../server/server.js';
7
+ import { enforceProjectTrust } from '../project/index.js';
8
+ import { acquireDevDbLock, readServeInfo, releaseDevDbLock, resolveDbDir, } from '../server/server.js';
8
9
  import { runAcpCommand } from './commands/acp.js';
9
10
  import { runAuthCommand } from './commands/auth.js';
10
11
  import { runChatCommand } from './commands/chat.js';
@@ -13,6 +14,7 @@ import { runInitCommand } from './commands/init.js';
13
14
  import { runPluginCommand } from './commands/plugin.js';
14
15
  import { runServeCommand } from './commands/serve.js';
15
16
  import { runSessionsCommand } from './commands/sessions.js';
17
+ import { runTrustCommand } from './commands/trust.js';
16
18
  import { runUpdateCommand } from './commands/update.js';
17
19
  import { buildAgentDeps } from './deps.js';
18
20
  import { parseCommand } from './parser.js';
@@ -24,10 +26,13 @@ const COMMANDS = [
24
26
  { name: 'model', type: 'string' },
25
27
  { name: 'format', type: 'string' },
26
28
  { name: 'yes', type: 'boolean', short: 'y' },
29
+ { name: 'allow', type: 'string' },
27
30
  { name: 'continue', type: 'string' },
28
31
  // P2 修复:serve 运行时持久库被占用,此前静默退化为内存库(会话不保存,
29
32
  // 仅 stderr 一行提示极易错过)。现须显式 --temp 才允许临时模式。
30
33
  { name: 'temp', type: 'boolean' },
34
+ // P1-2:CLI 无「恢复」交互,预算 pause 在此等效「超支中止」;允许本 run 覆盖。
35
+ { name: 'budget-action', type: 'string' },
31
36
  ],
32
37
  },
33
38
  {
@@ -35,6 +40,7 @@ const COMMANDS = [
35
40
  description: 'Start the HTTP server (default when no command given).',
36
41
  options: [
37
42
  { name: 'port', type: 'string' },
43
+ { name: 'host', type: 'string' },
38
44
  { name: 'open', type: 'boolean' },
39
45
  { name: 'restore', type: 'string' },
40
46
  { name: 'handoff-port', type: 'string' },
@@ -62,8 +68,17 @@ const COMMANDS = [
62
68
  },
63
69
  {
64
70
  name: 'sessions',
65
- description: 'Manage CLI/web sessions (list / delete / restore / deleted).',
66
- options: [{ name: 'project', type: 'string' }],
71
+ description: 'Manage CLI/web sessions (list / delete / restore / deleted / purge).',
72
+ options: [
73
+ { name: 'project', type: 'string' },
74
+ { name: 'yes', type: 'boolean', short: 'y' },
75
+ { name: 'all', type: 'boolean' },
76
+ ],
77
+ },
78
+ {
79
+ name: 'trust',
80
+ description: 'Trust a project directory: enable its project-scope config and plugins.',
81
+ options: [{ name: 'yes', type: 'boolean', short: 'y' }],
67
82
  },
68
83
  {
69
84
  name: 'acp',
@@ -71,6 +86,8 @@ const COMMANDS = [
71
86
  options: [
72
87
  // serve 占用持久库时的显式临时模式开关(与 chat --temp 同语义)。
73
88
  { name: 'temp', type: 'boolean' },
89
+ // P1-2:ACP 同样无恢复 UI(超支中止),允许本 run 覆盖 warn/abort。
90
+ { name: 'budget-action', type: 'string' },
74
91
  ],
75
92
  },
76
93
  {
@@ -90,6 +107,17 @@ function isDbLockConflict(err) {
90
107
  return true;
91
108
  return err.name === 'RuntimeError' && /abort/i.test(err.message);
92
109
  }
110
+ /** P1-2:解析 --budget-action 覆盖值。CLI 无「恢复」UI,config 的 pause 在此等效 abort;
111
+ * 接受 warn|abort(pause 兼容映射到 abort)。非法值抛错并提示可取值。 */
112
+ function parseBudgetActionOption(value) {
113
+ if (value === undefined)
114
+ return undefined;
115
+ if (value === 'warn')
116
+ return 'warn';
117
+ if (value === 'abort' || value === 'pause')
118
+ return 'abort';
119
+ throw new Error(`--budget-action 仅支持 warn|abort(CLI 无恢复 UI,config 的 pause 在此等效 abort)`);
120
+ }
93
121
  /** 封装 agent 依赖生命周期:加载配置 → 建库迁移 → 组装 deps → 使用后关库。
94
122
  * chat / acp 两个命令复用同一套逻辑(此前在 dispatch 中各写一份)。
95
123
  * 数据库优先用全局 dataDir 持久库(会话可恢复,--continue 可用);
@@ -98,6 +126,31 @@ function isDbLockConflict(err) {
98
126
  * 带 --continue 时内存库不含历史会话,直接报错退出而非误导性的 session not found。 */
99
127
  async function withAgentDeps(cwd, opts, fn) {
100
128
  const config = await loadConfig(cwd);
129
+ // P1-2:--budget-action 覆盖(warn=不中止;abort=超支中止)。
130
+ // 直接映射为 config 的 abort 动作(不再借 'pause' 中转):
131
+ // 预算预检、buildAgentDeps 的 budgetAbort 注入、超支判定文案三处口径一致。
132
+ // CLI 无「恢复」UI,config 的 pause 在此等效 abort。
133
+ if (opts.budgetAction) {
134
+ const action = opts.budgetAction === 'abort' ? 'abort' : 'warn';
135
+ config.usage = { ...config.usage, budgetAction: action, tokenBudgetAction: action };
136
+ }
137
+ const budgetWillAbort = config.usage?.budgetAction === 'pause' ||
138
+ config.usage?.budgetAction === 'abort' ||
139
+ config.usage?.tokenBudgetAction === 'pause' ||
140
+ config.usage?.tokenBudgetAction === 'abort';
141
+ const budgetConfigured = (config.usage?.monthlyBudgetUsd ?? 0) > 0 ||
142
+ (config.usage?.globalMonthlyBudgetUsd ?? 0) > 0 ||
143
+ (config.usage?.monthlyTokenBudget ?? 0) > 0 ||
144
+ (config.usage?.globalMonthlyTokenBudget ?? 0) > 0;
145
+ if (opts.budgetAction === undefined && budgetWillAbort && budgetConfigured) {
146
+ // 文案按配置实际动作区分:pause 与 abort 在 CLI 下都等价于中止本次 run,
147
+ // 但用户应知道自己配置的是什么。
148
+ const effective = config.usage?.budgetAction === 'abort' || config.usage?.tokenBudgetAction === 'abort'
149
+ ? 'abort'
150
+ : 'pause';
151
+ process.stderr.write(`[c0de] ⚠ 预算动作为 ${effective}:CLI 无「恢复」交互,超预算将中止本次 run(无断点续跑)。` +
152
+ '如需仅告警不中止,加 `--budget-action warn`。\n');
153
+ }
101
154
  const dataDir = resolveDbDir();
102
155
  let db;
103
156
  let holdLock = false;
@@ -114,21 +167,26 @@ async function withAgentDeps(cwd, opts, fn) {
114
167
  holdLock = false;
115
168
  if (!isDbLockConflict(err))
116
169
  throw err;
170
+ // serve 运行时引导:读取运行中实例的端口(serve-info.json),直接给出可点击的
171
+ // Web 地址,而不是让用户面对「单写者限制」无从下手。
172
+ const info = readServeInfo(dataDir);
173
+ const webUrl = info && info.port > 0 ? `http://localhost:${info.port}/` : '启动日志中打印的地址';
117
174
  if (opts.requirePersistent) {
118
- throw new Error('c0de serve 正在运行并占用会话库,CLI 会话管理暂不可用(单写者限制)。\n' +
119
- ' 1) 在 Web 界面管理会话;\n' +
120
- ' 2) 或停止 serve(Ctrl+C)后重试本命令。');
175
+ throw new Error(`c0de serve 正在运行并占用会话库,CLI 会话管理暂不可用(单写者限制)。\n` +
176
+ ` 1) 在浏览器打开 ${webUrl} 管理会话;\n` +
177
+ ` 2) 或停止 serve(Ctrl+C)后重试本命令。`);
121
178
  }
122
179
  if (opts.continueSessionId) {
123
180
  throw new Error(`无法续聊会话 ${opts.continueSessionId}:持久库被 c0de serve 占用,内存模式无法续聊。\n` +
124
- `请停止 serve 后重试,或去掉 --continue 开启新会话。`);
181
+ `请在浏览器打开 ${webUrl} 找到该会话继续对话;或停止 serve 后重试。`);
125
182
  }
126
183
  if (!opts.allowTemp) {
127
184
  // P2 修复:锁冲突不再静默降级为不保存会话的内存模式(用户极易错过 stderr
128
185
  // 提示导致对话丢失)。必须显式 --temp 确认放弃持久化。
129
- throw new Error('c0de serve 正在运行并占用会话库,本次对话将无法保存。\n' +
130
- ' 1) 停止 serve 后重试(对话可持久化,推荐);\n' +
131
- ' 2) 或加 --temp 显式接受临时模式(消息与工具调用不会保存)。');
186
+ throw new Error(`c0de serve 正在运行并占用会话库,本次对话将无法保存。\n` +
187
+ ` 1) 在浏览器打开 ${webUrl} 继续对话(推荐,会话自动保存);\n` +
188
+ ` 2) 或停止 serve 后重试(对话可持久化);\n` +
189
+ ` 3) 或加 --temp 显式接受临时模式(消息与工具调用不会保存)。`);
132
190
  }
133
191
  process.stderr.write('[c0de] ⚠ 临时模式:消息与工具调用不会保存。停止 serve 后重试可持久化,或直接使用浏览器界面。\n');
134
192
  db = await createDB({ driver: 'pglite' });
@@ -139,7 +197,13 @@ async function withAgentDeps(cwd, opts, fn) {
139
197
  db,
140
198
  cwd,
141
199
  ...(opts.strategy ? { permissionStrategy: opts.strategy } : {}),
200
+ ...(opts.allowTools ? { allowTools: opts.allowTools } : {}),
142
201
  });
202
+ // P0:agent 执行路径(chat/acp)在无 Web 确认弹窗下硬拦未信任项目的风险配置,
203
+ // 引导 c0de trust;trust/sessions 等非 agent 命令经 skipTrustGate 跳过。
204
+ if (opts.skipTrustGate !== true) {
205
+ await enforceProjectTrust(db, cwd);
206
+ }
143
207
  await fn(config, deps);
144
208
  }
145
209
  finally {
@@ -168,12 +232,21 @@ async function dispatch(argv, overrides = {}) {
168
232
  case 'chat': {
169
233
  // --yes / -y 显式放行写操作;否则按 config.permission.defaultMode 决定(默认 safe)。
170
234
  const strategy = args.options.yes ? 'full-auto' : undefined;
235
+ const allowTools = args.options.allow
236
+ ? String(args.options.allow)
237
+ .split(',')
238
+ .map((s) => s.trim())
239
+ .filter((s) => s.length > 0)
240
+ : undefined;
171
241
  const continueId = args.options.continue;
172
242
  const allowTemp = args.options.temp === true;
243
+ const budgetAction = parseBudgetActionOption(args.options['budget-action']);
173
244
  await withAgentDeps(cwd, {
174
245
  ...(strategy ? { strategy } : {}),
246
+ ...(allowTools && allowTools.length > 0 ? { allowTools } : {}),
175
247
  ...(continueId ? { continueSessionId: continueId } : {}),
176
248
  ...(allowTemp ? { allowTemp } : {}),
249
+ ...(budgetAction ? { budgetAction } : {}),
177
250
  }, (config, deps) => runChatCommand({ args, config, deps }));
178
251
  return;
179
252
  }
@@ -195,14 +268,21 @@ async function dispatch(argv, overrides = {}) {
195
268
  }
196
269
  case 'sessions': {
197
270
  // 需要持久库列出/清理会话;serve 运行时内存库不含数据,直接失败而不是列空表。
198
- await withAgentDeps(cwd, { requirePersistent: true }, (_config, deps) => runSessionsCommand({ args, db: deps.db }));
271
+ await withAgentDeps(cwd, { requirePersistent: true, skipTrustGate: true }, (_config, deps) => runSessionsCommand({ args, db: deps.db }));
272
+ return;
273
+ }
274
+ case 'trust': {
275
+ // 与 sessions 同约束:持久库落盘 trustedAt;serve 运行时引导走 Web 信任弹窗。
276
+ await withAgentDeps(cwd, { requirePersistent: true, skipTrustGate: true }, (_config, deps) => runTrustCommand({ args, db: deps.db, cwd }));
199
277
  return;
200
278
  }
201
279
  case 'acp': {
202
280
  // ACP 非交互:所有工具放行(编辑器侧自行控制执行授权)。
281
+ const budgetAction = parseBudgetActionOption(args.options['budget-action']);
203
282
  await withAgentDeps(cwd, {
204
283
  strategy: 'full-auto',
205
284
  ...(args.options.temp === true ? { allowTemp: true } : {}),
285
+ ...(budgetAction ? { budgetAction } : {}),
206
286
  }, (config, deps) => runAcpCommand({ config, deps }));
207
287
  return;
208
288
  }
@@ -225,7 +305,7 @@ async function main() {
225
305
  process.exit(1);
226
306
  }
227
307
  }
228
- export { COMMANDS, dispatch };
308
+ export { COMMANDS, dispatch, parseBudgetActionOption };
229
309
  // bin 入口:仅在直接执行时运行 main(非被 import)。
230
310
  const isMain = process.argv[1] === fileURLToPath(import.meta.url);
231
311
  if (isMain) {
@@ -27,31 +27,62 @@ function formatACPEvent(method, params) {
27
27
  return JSON.stringify({ jsonrpc: '2.0', method, params });
28
28
  }
29
29
  async function runAcpLoop(opts) {
30
+ // P2-4:chat 在后台单飞执行——loop 继续读取后续请求(abort 等)而不被阻塞。
31
+ // 此前串行 await 使 abort 请求永远排在 chat 完成之后,中止形同虚设。
32
+ let chatInFlight = null;
33
+ const writer = opts.writer;
30
34
  for await (const line of opts.reader) {
31
35
  const trimmed = line.trim();
32
36
  if (!trimmed)
33
37
  continue;
34
38
  const req = parseACPRequest(trimmed);
35
39
  if (!req) {
36
- opts.writer(formatACPError(null, -32700, 'Parse error'));
40
+ writer(formatACPError(null, -32700, 'Parse error'));
37
41
  continue;
38
42
  }
39
43
  const handler = opts.handlers[req.method];
40
44
  if (!handler) {
41
45
  if (req.id !== null)
42
- opts.writer(formatACPError(req.id, -32601, `Method not found: ${req.method}`));
46
+ writer(formatACPError(req.id, -32601, `Method not found: ${req.method}`));
43
47
  continue;
44
48
  }
45
- try {
46
- const result = await handler(req.params);
49
+ const respond = (result) => {
47
50
  if (req.id !== null)
48
- opts.writer(formatACPResponse(req.id, result));
49
- }
50
- catch (err) {
51
+ writer(formatACPResponse(req.id, result));
52
+ };
53
+ const fail = (err) => {
51
54
  if (req.id !== null) {
52
- opts.writer(formatACPError(req.id, -32603, err instanceof Error ? err.message : String(err)));
55
+ writer(formatACPError(req.id, -32603, err instanceof Error ? err.message : String(err)));
53
56
  }
57
+ };
58
+ if (req.method === 'chat') {
59
+ if (chatInFlight) {
60
+ fail(new Error('chat: another chat is already in progress (abort it first)'));
61
+ continue;
62
+ }
63
+ chatInFlight = (async () => {
64
+ try {
65
+ respond(await handler(req.params));
66
+ }
67
+ catch (err) {
68
+ fail(err);
69
+ }
70
+ finally {
71
+ chatInFlight = null;
72
+ }
73
+ })();
74
+ void chatInFlight;
75
+ continue;
76
+ }
77
+ try {
78
+ respond(await handler(req.params));
79
+ }
80
+ catch (err) {
81
+ fail(err);
54
82
  }
55
83
  }
84
+ // 流结束前等最后一个在途 chat 收尾,保证响应写全。
85
+ if (chatInFlight)
86
+ await chatInFlight;
56
87
  }
57
88
  export { formatACPError, formatACPEvent, formatACPResponse, parseACPRequest, runAcpLoop };
@@ -9,6 +9,9 @@ type PrintOptions = {
9
9
  sessionId?: string;
10
10
  /** 事件观察回调(CLI 用于把 tool/thinking 写到 stderr)。 */
11
11
  onEvent?: (event: AgentEvent) => void;
12
+ /** P2-4:外部中止信号(ACP abort)——触发即中止 run 并抛错,
13
+ * 让调用方如实返回失败而非谎报 ok。 */
14
+ abortSignal?: AbortSignal;
12
15
  };
13
16
  /** 从事件流累积 assistant 文本。纯函数。 */
14
17
  declare function collectAssistantText(events: AgentEvent[]): string;
@@ -1,7 +1,9 @@
1
- import { createAgent, runAgent } from '../../core/agent.js';
1
+ import { resolve } from 'node:path';
2
+ import { abortAgent, createAgent, runAgent } from '../../core/agent.js';
2
3
  import { resolveRoute } from '../../llm/registry.js';
4
+ import { getByDirectory } from '../../project/index.js';
3
5
  import { createSession, getSession, upgradeTemporarySession } from '../../session/session.js';
4
- import { resolveEnabledToolNames } from '../../tools/index.js';
6
+ import { PROJECT_BOUND_TOOLS, resolveEnabledToolNames } from '../../tools/index.js';
5
7
  /** 从事件流累积 assistant 文本。纯函数。 */
6
8
  function collectAssistantText(events) {
7
9
  let text = '';
@@ -24,9 +26,25 @@ async function runPrintMode(config, message, deps, opts = {}) {
24
26
  const existing = await getSession(deps.db, opts.sessionId);
25
27
  if (!existing)
26
28
  throw new Error(`session not found: ${opts.sessionId}`);
29
+ // M2:会话在回收站(Web 已删除)时拒绝续接——否则消息会写入一个 Web 树
30
+ // 不可见的会话,与「删除」心智模型冲突。给出可操作的恢复途径。
31
+ if (existing.deletedAt) {
32
+ throw new Error(`该会话在回收站中(已删除)。请先在 Web 界面回收站中恢复,` +
33
+ `或停止 serve 后运行 \`c0de sessions restore ${opts.sessionId}\` 恢复,再续接。`);
34
+ }
35
+ // P2-6 修复:cwd 与会话 worktree 不一致时拒绝——文件工具在 cwd 执行,
36
+ // 而 kanban/预算等绑定 session.projectId,同一会话上下文会分裂到两个目录,
37
+ // 且毫无提示。给出切换目录的可操作指引,避免用户在错误目录误改文件。
38
+ const worktree = existing.worktreePath;
39
+ if (worktree && resolve(worktree) !== resolve(deps.cwd)) {
40
+ throw new Error(`该会话的工作目录(${worktree})与当前目录(${deps.cwd})不一致。` +
41
+ `请先切换到会话目录再续接:\n cd "${worktree}"\n` +
42
+ `(会话绑定项目时,工具必须在原目录执行,否则看板与文件操作会分裂到两个目录)`);
43
+ }
27
44
  // 续接即升级为持久会话:30 天临时清理不再触及(P1:此前 --continue 的
28
45
  // 会话同样会在 30 天不活动后被物理删除,用户显式续接的历史静默丢失)。
29
- if (existing.agentType === 'print') {
46
+ // P1-3:workflow 会话同口径——Web 继续追问的 workflow 会话此前无升级路径。
47
+ if (existing.agentType === 'print' || existing.agentType === 'workflow') {
30
48
  await upgradeTemporarySession(deps.db, opts.sessionId);
31
49
  }
32
50
  session = existing;
@@ -34,31 +52,76 @@ async function runPrintMode(config, message, deps, opts = {}) {
34
52
  else {
35
53
  // agentType='print':一次性 print 会话标记。purgeTemporarySessions 仅清理
36
54
  // print/workflow 会话;普通 CLI 会话(ACP 等)与 --continue 续接的历史永不误删。
37
- session = await createSession(deps.db, 'cli-print', undefined, 'print', 'cli');
55
+ // P1-2 CLI/Web 同树:cwd 已注册为项目时直接绑定(续接后 Web 树按项目可见);
56
+ // worktreePath 落盘——Web 打开时工具在原目录执行,而非回退 serve cwd。
57
+ const existing = await getByDirectory(deps.db, deps.cwd);
58
+ session = await createSession(deps.db, 'cli-print', existing?.id, 'print', 'cli', undefined, deps.cwd);
38
59
  }
39
60
  const agentConfig = {
40
61
  provider: config.defaultProvider,
41
62
  model: opts.model ?? config.defaultModel,
42
- // 工具集:enabled 非空 → enabled ∩ registered;空 → 全部 registered(disabled 已在 registry 层过滤)。
43
- tools: resolveEnabledToolNames(deps.toolRegistry, config),
63
+ // 工具集:enabled 含 '*' → 全部 registered;空 → 无工具(fail-closed);否则 enabled ∩ registered
64
+ // (disabled 已在 registry 层过滤)。无项目绑定(cwd 未注册为项目)时剔除
65
+ // 项目绑定工具(kanban 等)——其 store 不会被注入,调用必失败。
66
+ tools: resolveEnabledToolNames(deps.toolRegistry, config).filter((n) => Boolean(session.projectId) || !PROJECT_BOUND_TOOLS.has(n)),
44
67
  plugins: config.plugins.enabled,
45
68
  ...(opts.maxTokens !== undefined ? { maxTokens: opts.maxTokens } : {}),
46
69
  };
47
70
  // P3:首跑友好报错——未配置 provider 时不再让底层 NoRoute 异常裸抛,
48
71
  // 给出与 Web 端一致的引导(c0de serve → 设置 → Provider)。
72
+ // P1-3:区分三种失败(此前一律报「未配置」,配置了 provider 的用户被误导):
73
+ // - 完全没有配置 provider → 引导添加;
74
+ // - provider 名不匹配(如 defaultProvider 仍是默认 'openai',只配了 anthropic)
75
+ // → 列出已配置清单 + 修正指引(与 Web PROVIDER_NOT_FOUND 对齐);
76
+ // - provider 声明了模型清单但请求模型不在其中 → 列出可用模型(与 Web
77
+ // MODEL_NOT_FOUND 对齐)。
49
78
  try {
50
79
  resolveRoute(deps.llmRegistry, agentConfig.provider, agentConfig.model);
51
80
  }
52
81
  catch {
53
- throw new Error('未配置可用的 AI 服务。请运行 `c0de serve` 并在「设置 → Provider」中添加 API 服务并测试连接,' +
54
- '或使用 `c0de config set` 配置 providers。');
82
+ if (config.providers.length === 0) {
83
+ throw new Error('未配置可用的 AI 服务。请运行 `c0de serve` 并在「设置 → Provider」中添加 API 服务并测试连接,' +
84
+ '或使用 `c0de config set` 配置 providers。');
85
+ }
86
+ const configured = config.providers
87
+ .map((p) => p.name)
88
+ .filter(Boolean)
89
+ .join(', ');
90
+ throw new Error(`未找到名为「${agentConfig.provider}」的 AI 服务。已配置:${configured}。` +
91
+ `请检查拼写,或用 \`c0de config set defaultProvider <name>\` 修正默认服务。`);
92
+ }
93
+ const providerDef = config.providers.find((p) => p.name === agentConfig.provider);
94
+ const modelNames = providerDef?.models ? Object.keys(providerDef.models) : undefined;
95
+ if (modelNames && modelNames.length > 0 && !modelNames.includes(agentConfig.model)) {
96
+ throw new Error(`provider「${agentConfig.provider}」没有名为「${agentConfig.model}」的模型。` +
97
+ `已配置模型:${modelNames.join(', ')}(可用 \`c0de chat --model <model>\` 指定)。`);
55
98
  }
56
99
  const state = await createAgent(session, agentConfig, deps);
100
+ // P2-4:ACP abort 真中止——把外部信号桥接到该 run 的 abortController。
101
+ // abortAgent 会使 loop 在 turn/流边界 unwind,runAgent 随即结束。
102
+ if (opts.abortSignal) {
103
+ if (opts.abortSignal.aborted) {
104
+ throw new Error('已中止');
105
+ }
106
+ opts.abortSignal.addEventListener('abort', () => {
107
+ abortAgent(state);
108
+ }, { once: true });
109
+ }
57
110
  const events = [];
58
111
  for await (const event of runAgent(state, [{ _tag: 'text', text: message }], deps)) {
59
112
  events.push(event);
60
113
  opts.onEvent?.(event);
61
114
  }
115
+ // P2-4:中止后如实报错(ACP 客户端收到 error 响应,而非谎报 ok 的完成结果)。
116
+ if (opts.abortSignal?.aborted) {
117
+ throw new Error('已中止');
118
+ }
119
+ // 终态错误(unexpected,含预算中止)必须反馈给用户而非静默返回半截文本——
120
+ // 抛出后 dispatch 打印 message 并以非零码退出(预算超支中止等硬性护栏由此可见)。
121
+ const terminal = events.find((e) => e._tag === 'error' && e.error._tag === 'unexpected');
122
+ if (terminal && terminal._tag === 'error' && terminal.error._tag === 'unexpected') {
123
+ throw new Error(terminal.error.message);
124
+ }
62
125
  return collectAssistantText(events);
63
126
  }
64
127
  export { collectAssistantText, runPrintMode };
@@ -3,7 +3,7 @@ import type { MessageContent, Session } from '../shared/types/message.js';
3
3
  import type { AgentDependencies } from './types.js';
4
4
  declare function createAgent(session: Session, config: AgentConfig, deps: AgentDependencies): Promise<AgentState>;
5
5
  declare function runAgent(state: AgentState, userInput: MessageContent[], deps: AgentDependencies): AsyncGenerator<AgentEvent>;
6
- declare function pauseAgent(state: AgentState): void;
6
+ declare function pauseAgent(state: AgentState, reason?: string): void;
7
7
  declare function resumeAgent(state: AgentState): void;
8
8
  declare function abortAgent(state: AgentState): void;
9
9
  declare function getAgentStatus(state: AgentState): AgentStatus;
@@ -51,13 +51,18 @@ async function createAgent(session, config, deps) {
51
51
  }
52
52
  async function* runAgent(state, userInput, deps) {
53
53
  // 幂等重发保护:若 DB 末尾已是相同 user message(服务重启后续传场景),
54
- // 跳过 append 避免重复。比较 text parts 内容即可区分不同消息。
54
+ // 跳过 append 避免重复。比较 text/image parts 全量内容——纯文本比较会在
55
+ // 「含图片消息重发」时误判为不同消息而追加重复条目(P1-2 修复)。
55
56
  const last = state.messages[state.messages.length - 1];
56
57
  const isDupUser = last?.role === 'user' &&
57
58
  last.content.length === userInput.length &&
58
59
  last.content.every((p, i) => {
59
60
  const q = userInput[i];
60
- return p._tag === 'text' && q?._tag === 'text' ? p.text === q.text : false;
61
+ if (p._tag === 'text' && q?._tag === 'text')
62
+ return p.text === q.text;
63
+ if (p._tag === 'image' && q?._tag === 'image')
64
+ return p.mediaType === q.mediaType && p.data === q.data;
65
+ return false;
61
66
  });
62
67
  if (!isDupUser) {
63
68
  await appendMessage(deps.db, state.session.id, {
@@ -84,10 +89,10 @@ async function* runAgent(state, userInput, deps) {
84
89
  state.status = { _tag: 'running', turnCount: 0 };
85
90
  yield* agentLoop(state, deps);
86
91
  }
87
- function pauseAgent(state) {
92
+ function pauseAgent(state, reason) {
88
93
  if (state.status._tag !== 'running')
89
94
  return;
90
- state.status = { _tag: 'paused', pauseReason: 'User requested pause' };
95
+ state.status = { _tag: 'paused', pauseReason: reason ?? 'User requested pause' };
91
96
  }
92
97
  function resumeAgent(state) {
93
98
  if (state.status._tag !== 'paused')
@@ -19,7 +19,7 @@ const PLAN_ROLE = `You are c0de-agent in **Plan Mode**. Your job is to investiga
19
19
  - Use read-only tools (grep/glob/read) to understand the structure and relevant code.
20
20
  - Ask clarifying questions when requirements are ambiguous.
21
21
  - When ready, present a concrete implementation plan (files to touch, approach, risks).
22
- - Do NOT use edit/write tools to modify code. You may run bash for investigation only.`;
22
+ - You have NO shell access and NO write tools in this mode. If investigation requires running a command or modifying files, note it as a step for the user to execute in default mode.`;
23
23
  /** 6 个内置 agent(2 primary + 4 subagent)。 */
24
24
  const BUILTIN_AGENTS = [
25
25
  {
@@ -32,7 +32,7 @@ const BUILTIN_AGENTS = [
32
32
  {
33
33
  name: 'plan',
34
34
  description: '计划模式(只读)。专注调研与方案设计,不直接改代码。',
35
- tools: ['read', 'grep', 'glob', 'bash'],
35
+ tools: ['read', 'grep', 'glob'],
36
36
  systemPrompt: PLAN_ROLE,
37
37
  mode: 'primary',
38
38
  source: 'builtin',
@@ -1,5 +1,4 @@
1
1
  export { BUILTIN_AGENTS, WORKER_BASE } from './builtin.js';
2
- export { loadAgentFile, loadAgents } from './discovery.js';
3
2
  export type { ParallelResult } from './parallel.js';
4
3
  export { mapWithConcurrencyLimit } from './parallel.js';
5
4
  export { createAgentRegistry } from './registry.js';
@@ -1,4 +1,3 @@
1
1
  export { BUILTIN_AGENTS, WORKER_BASE } from './builtin.js';
2
- export { loadAgentFile, loadAgents } from './discovery.js';
3
2
  export { mapWithConcurrencyLimit } from './parallel.js';
4
3
  export { createAgentRegistry } from './registry.js';