c0de-agent 1.11.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 (177) hide show
  1. package/README.md +63 -16
  2. package/dist/cli/commands/acp.js +26 -8
  3. package/dist/cli/commands/chat.js +151 -1
  4. package/dist/cli/commands/config.d.ts +2 -0
  5. package/dist/cli/commands/config.js +63 -3
  6. package/dist/cli/commands/serve.js +11 -2
  7. package/dist/cli/commands/sessions.js +109 -3
  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 +110 -11
  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 +78 -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/compact.d.ts +2 -0
  24. package/dist/core/compact.js +1 -0
  25. package/dist/core/config.d.ts +53 -2
  26. package/dist/core/config.js +235 -19
  27. package/dist/core/loop/compaction.d.ts +2 -1
  28. package/dist/core/loop/compaction.js +19 -6
  29. package/dist/core/loop/segment.d.ts +3 -1
  30. package/dist/core/loop/segment.js +24 -1
  31. package/dist/core/loop/stream-collect.js +9 -1
  32. package/dist/core/loop/subagent.js +88 -37
  33. package/dist/core/loop.d.ts +7 -0
  34. package/dist/core/loop.js +90 -6
  35. package/dist/core/redact.d.ts +11 -0
  36. package/dist/core/redact.js +49 -0
  37. package/dist/core/slash.d.ts +10 -0
  38. package/dist/core/slash.js +237 -65
  39. package/dist/core/title.js +7 -1
  40. package/dist/core/types.d.ts +22 -1
  41. package/dist/core/workflows/context.js +44 -4
  42. package/dist/core/workflows/discovery.d.ts +2 -2
  43. package/dist/core/workflows/discovery.js +5 -7
  44. package/dist/core/workflows/index.d.ts +2 -1
  45. package/dist/core/workflows/index.js +2 -1
  46. package/dist/core/workflows/registry.d.ts +13 -3
  47. package/dist/core/workflows/registry.js +14 -9
  48. package/dist/core/workflows/resolve.d.ts +22 -0
  49. package/dist/core/workflows/resolve.js +23 -0
  50. package/dist/core/workflows/runtime.d.ts +6 -1
  51. package/dist/core/workflows/runtime.js +19 -2
  52. package/dist/db/schema.d.ts +373 -1
  53. package/dist/db/schema.js +60 -4
  54. package/dist/kanban/index.d.ts +2 -1
  55. package/dist/kanban/index.js +1 -1
  56. package/dist/kanban/store.d.ts +87 -1
  57. package/dist/kanban/store.js +429 -22
  58. package/dist/llm/index.d.ts +1 -1
  59. package/dist/llm/index.js +1 -1
  60. package/dist/llm/provider.js +1 -1
  61. package/dist/llm/registry.d.ts +7 -1
  62. package/dist/llm/registry.js +7 -1
  63. package/dist/llm/retry.d.ts +5 -2
  64. package/dist/llm/retry.js +6 -5
  65. package/dist/llm/routing.d.ts +24 -1
  66. package/dist/llm/routing.js +29 -1
  67. package/dist/plugins/init.d.ts +7 -0
  68. package/dist/plugins/init.js +22 -1
  69. package/dist/plugins/loader.d.ts +7 -1
  70. package/dist/plugins/loader.js +12 -4
  71. package/dist/project/index.d.ts +1 -1
  72. package/dist/project/index.js +1 -1
  73. package/dist/project/project.d.ts +37 -0
  74. package/dist/project/project.js +142 -1
  75. package/dist/project/trust.d.ts +65 -0
  76. package/dist/project/trust.js +328 -0
  77. package/dist/server/agent-manager.d.ts +15 -2
  78. package/dist/server/agent-manager.js +18 -3
  79. package/dist/server/app.js +45 -30
  80. package/dist/server/auth-manager.d.ts +26 -9
  81. package/dist/server/auth-manager.js +78 -42
  82. package/dist/server/context.js +14 -1
  83. package/dist/server/index.d.ts +1 -1
  84. package/dist/server/index.js +1 -1
  85. package/dist/server/middleware/auth.d.ts +11 -1
  86. package/dist/server/middleware/auth.js +25 -3
  87. package/dist/server/permission/interactive.d.ts +11 -3
  88. package/dist/server/permission/interactive.js +23 -4
  89. package/dist/server/permission/store.d.ts +14 -2
  90. package/dist/server/permission/store.js +39 -3
  91. package/dist/server/registry-config.d.ts +14 -0
  92. package/dist/server/registry-config.js +44 -0
  93. package/dist/server/routes/auth.js +66 -10
  94. package/dist/server/routes/chat.d.ts +21 -0
  95. package/dist/server/routes/chat.js +685 -52
  96. package/dist/server/routes/commands.d.ts +4 -2
  97. package/dist/server/routes/commands.js +9 -4
  98. package/dist/server/routes/config.js +180 -23
  99. package/dist/server/routes/files.js +142 -28
  100. package/dist/server/routes/kanban.js +218 -26
  101. package/dist/server/routes/permissions.d.ts +0 -4
  102. package/dist/server/routes/permissions.js +140 -7
  103. package/dist/server/routes/project.js +132 -29
  104. package/dist/server/routes/provider.js +54 -3
  105. package/dist/server/routes/session.js +463 -23
  106. package/dist/server/routes/tool.js +24 -5
  107. package/dist/server/routes/update.d.ts +11 -5
  108. package/dist/server/routes/update.js +145 -29
  109. package/dist/server/routes/usage.d.ts +4 -0
  110. package/dist/server/routes/usage.js +133 -0
  111. package/dist/server/routes/workflows.js +476 -87
  112. package/dist/server/server.d.ts +16 -13
  113. package/dist/server/server.js +364 -107
  114. package/dist/server/terminal/pty-manager.d.ts +12 -0
  115. package/dist/server/terminal/pty-manager.js +63 -7
  116. package/dist/server/types.d.ts +16 -0
  117. package/dist/session/archive.d.ts +3 -1
  118. package/dist/session/archive.js +13 -1
  119. package/dist/session/branch.d.ts +2 -1
  120. package/dist/session/branch.js +92 -9
  121. package/dist/session/context.js +28 -2
  122. package/dist/session/import.d.ts +44 -0
  123. package/dist/session/import.js +83 -0
  124. package/dist/session/index.d.ts +1 -1
  125. package/dist/session/index.js +1 -1
  126. package/dist/session/jobs.d.ts +12 -0
  127. package/dist/session/jobs.js +53 -0
  128. package/dist/session/message.d.ts +7 -1
  129. package/dist/session/message.js +21 -1
  130. package/dist/session/session.d.ts +186 -12
  131. package/dist/session/session.js +577 -33
  132. package/dist/session/snapshot.d.ts +5 -2
  133. package/dist/session/snapshot.js +10 -2
  134. package/dist/session/types.d.ts +13 -1
  135. package/dist/session/usage.d.ts +62 -0
  136. package/dist/session/usage.js +194 -0
  137. package/dist/shared/types/agent.d.ts +29 -2
  138. package/dist/shared/types/config.d.ts +64 -5
  139. package/dist/shared/types/kanban.d.ts +17 -2
  140. package/dist/shared/types/message.d.ts +23 -0
  141. package/dist/tools/builtin/kanban.js +13 -2
  142. package/dist/tools/index.d.ts +14 -3
  143. package/dist/tools/index.js +23 -5
  144. package/dist/tools/websearch/index.d.ts +5 -1
  145. package/dist/tools/websearch/index.js +46 -7
  146. package/dist/tools/websearch/providers/brave.js +2 -2
  147. package/dist/tools/websearch/providers/duckduckgo.js +2 -1
  148. package/dist/tools/websearch/providers/tavily.js +2 -2
  149. package/dist/tools/websearch/types.d.ts +5 -1
  150. package/dist/tools/websearch/types.js +7 -1
  151. package/dist/update/index.d.ts +3 -3
  152. package/dist/update/index.js +2 -2
  153. package/dist/update/ipc.d.ts +13 -2
  154. package/dist/update/ipc.js +36 -2
  155. package/dist/update/scheduler.js +5 -2
  156. package/dist/update/snapshot.d.ts +18 -3
  157. package/dist/update/snapshot.js +5 -2
  158. package/dist/update/updater.d.ts +17 -1
  159. package/dist/update/updater.js +47 -18
  160. package/dist/update/version.d.ts +3 -0
  161. package/dist/update/version.js +9 -3
  162. package/drizzle/0008_tranquil_lily_hollister.sql +1 -0
  163. package/drizzle/0010_usage_events_ledger.sql +17 -0
  164. package/drizzle/0011_slow_slapstick.sql +12 -0
  165. package/drizzle/0012_chief_timeslip.sql +1 -0
  166. package/drizzle/0013_swift_zeigeist.sql +1 -0
  167. package/drizzle/0014_flimsy_legion.sql +1 -0
  168. package/drizzle/meta/0008_snapshot.json +875 -0
  169. package/drizzle/meta/0010_snapshot.json +1010 -0
  170. package/drizzle/meta/0011_snapshot.json +1055 -0
  171. package/drizzle/meta/0012_snapshot.json +1062 -0
  172. package/drizzle/meta/0013_snapshot.json +1068 -0
  173. package/drizzle/meta/0014_snapshot.json +1075 -0
  174. package/drizzle/meta/_journal.json +42 -0
  175. package/package.json +42 -42
  176. package/dist/core/agents/discovery.d.ts +0 -6
  177. package/dist/core/agents/discovery.js +0 -96
@@ -1,18 +1,67 @@
1
+ import { existsSync } from 'node:fs';
1
2
  import { unlink } from 'node:fs/promises';
3
+ import { join } from 'node:path';
2
4
  import { Hono } from 'hono';
3
5
  import { streamSSE } from 'hono/streaming';
4
6
  import { createAgent } from '../../core/agent.js';
5
- import { discoverWorkflows, saveWorkflow } from '../../core/workflows/discovery.js';
7
+ import { loadConfigScopes, mergeConfig, resolveGlobalConfigDir } from '../../core/config.js';
8
+ import { discoverWorkflows, executeWorkflow, resolveWorkflow, saveWorkflow, workflowSessionTitle, } from '../../core/workflows/index.js';
6
9
  import { reloadRegistry } from '../../core/workflows/registry.js';
7
- import { executeWorkflow } from '../../core/workflows/runtime.js';
8
- import { getProject } from '../../project/project.js';
9
- import { createSession } from '../../session/session.js';
10
- import { autoAllowChecker } from '../../tools/permission.js';
10
+ import { getByDirectory, getProject, trustProject } from '../../project/project.js';
11
+ import { enrichProjectRiskWithGlobal, projectTrustCurrent, projectTrustNeeded, } from '../../project/trust.js';
12
+ import { createSession, updateSessionLastRun } from '../../session/session.js';
13
+ import { PROJECT_BOUND_TOOLS, resolveEnabledToolNames } from '../../tools/index.js';
11
14
  import { apiError } from '../middleware/error.js';
15
+ import { createInteractivePermissionChecker } from '../permission/interactive.js';
16
+ import { buildRegistryFromConfig } from '../registry-config.js';
17
+ import { childRunBridge, startHeartbeat } from './chat.js';
18
+ /**
19
+ * 按名字查找运行中的工作流 run:遍历 busy 作用域占位(workflowBusyByScope 的
20
+ * 值为工作流会话 id),从活跃 run 的会话 metadata.workflowName 匹配(斜杠与
21
+ * REST 两条执行通道创建会话时均落盘该键)。返回工作流会话 id;无匹配返回 null。
22
+ */
23
+ function findBusyWorkflowByName(ctx, name) {
24
+ for (const wfSessionId of ctx.workflowBusyByScope.values()) {
25
+ if (!wfSessionId)
26
+ continue; // 同步占位空串(run 会话尚未创建)
27
+ const run = ctx.agentManager.get(wfSessionId);
28
+ if (!run)
29
+ continue;
30
+ const meta = run.state.session.metadata;
31
+ if (meta?.workflowName === name)
32
+ return wfSessionId;
33
+ }
34
+ return null;
35
+ }
36
+ /** 权限确认超时兜底拒绝后暂停 run 的暂停原因(与 chat 路由同口径)。 */
37
+ const PERMISSION_TIMEOUT_PAUSE_REASON = '权限确认超时:该工具已被自动拒绝,工作流已暂停。点击「恢复」后可直接要求 agent 重试该工具';
38
+ /** 项目级工作流是仓库自带的任意代码执行面(dynamic import 即执行),发现前
39
+ * 必须校验信任状态——与 .c0de/plugins 的加载门禁同口径(trustedAt + 指纹)。 */
40
+ async function projectTrustState(ctx, projectId) {
41
+ const project = await getProject(ctx.db, projectId);
42
+ if (!project)
43
+ return { project: null, trusted: false };
44
+ const trusted = project.trustedAt != null &&
45
+ projectTrustCurrent(loadConfigScopes(project.worktree).project, project.trustedAt, project.riskFingerprint, project.worktree);
46
+ return { project, trusted };
47
+ }
48
+ /** serve 启动目录项目的信任状态(POST create 后热重载注册表时用)。 */
49
+ async function serveCwdTrusted(ctx) {
50
+ try {
51
+ const p = await getByDirectory(ctx.db, ctx.cwd);
52
+ if (p?.trustedAt == null)
53
+ return false;
54
+ return projectTrustCurrent(loadConfigScopes(ctx.cwd).project, p.trustedAt, p.riskFingerprint, ctx.cwd);
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ }
12
60
  /** 创建工作流 REST API 路由。 */
13
61
  function createWorkflowsRoute(ctx) {
14
62
  const app = new Hono();
15
63
  // GET / — 列出所有工作流。可选 ?projectId=xxx 合并项目级 .c0de/workflows/*.js。
64
+ // P0 信任边界:未信任(或信任后漂移)的项目绝不 dynamic import 其工作流文件。
16
65
  app.get('/', async (c) => {
17
66
  const registry = ctx.workflowRegistry;
18
67
  if (!registry) {
@@ -24,12 +73,21 @@ function createWorkflowsRoute(ctx) {
24
73
  byName.set(entry.meta.name, entry);
25
74
  }
26
75
  // 项目级工作流:从 project.worktree/.c0de/workflows/ 动态发现,同名覆盖。
76
+ // 记录被覆盖的来源层级(project > user > builtin),前端据此展示「覆盖内置」徽标。
77
+ const overrides = new Map();
27
78
  const projectId = c.req.query('projectId');
79
+ let trustRequired = false;
28
80
  if (projectId) {
29
- const project = await getProject(ctx.db, projectId);
30
- if (project) {
81
+ const { project, trusted } = await projectTrustState(ctx, projectId);
82
+ if (project && !trusted) {
83
+ trustRequired = true;
84
+ }
85
+ else if (project && trusted) {
31
86
  const projectWorkflows = await discoverWorkflows(project.worktree);
32
87
  for (const wf of projectWorkflows) {
88
+ const prev = byName.get(wf.meta.name);
89
+ if (prev && prev.source !== 'project')
90
+ overrides.set(wf.meta.name, prev.source);
33
91
  byName.set(wf.meta.name, wf);
34
92
  }
35
93
  }
@@ -40,8 +98,9 @@ function createWorkflowsRoute(ctx) {
40
98
  argsHint: entry.meta.argsHint,
41
99
  phases: entry.meta.phases,
42
100
  source: entry.source,
101
+ overrides: overrides.get(entry.meta.name) ?? null,
43
102
  }));
44
- return c.json({ workflows });
103
+ return c.json({ workflows, ...(trustRequired ? { trustRequired } : {}) });
45
104
  });
46
105
  // POST / — 创建/保存工作流(写入 .c0de/workflows/<name>.js,验证后热重载注册表)
47
106
  app.post('/', async (c) => {
@@ -50,20 +109,68 @@ function createWorkflowsRoute(ctx) {
50
109
  return apiError(c, 500, 'NOT_INITIALIZED', 'Workflow registry not initialized');
51
110
  }
52
111
  const body = await c.req.json().catch(() => ({}));
53
- const { name, source, target } = body;
112
+ const { name, source, target, projectId, overwrite } = body;
54
113
  if (!name || typeof name !== 'string') {
55
114
  return apiError(c, 400, 'BAD_REQUEST', 'Missing required field: name');
56
115
  }
57
116
  if (!source || typeof source !== 'string') {
58
117
  return apiError(c, 400, 'BAD_REQUEST', 'Missing required field: source');
59
118
  }
119
+ // 目标层级必填:此前缺省 target ?? 'project' 落到 serve cwd,与 GET/run 的
120
+ // ?projectId 口径分裂——serve cwd ≠ 目标项目时静默写错目录。
121
+ if (target !== 'project' && target !== 'user') {
122
+ return apiError(c, 400, 'BAD_REQUEST', 'Missing or invalid field: target (project|user)');
123
+ }
124
+ // 落盘目录:target=project 缺省 serve cwd;显式 projectId 时解析项目 worktree。
125
+ // 项目级工作流是任意代码执行面,写入前必须已信任(与发现/执行同一道闸)——
126
+ // 带 projectId 与缺省 serve cwd 两条路径同口径(P3:此前不带 projectId 时
127
+ // 写入不查信任,未信任项目仍可被写入任意 JS 文件)。
128
+ let saveDir = ctx.cwd;
129
+ if (target === 'project') {
130
+ if (projectId) {
131
+ const st = await projectTrustState(ctx, projectId);
132
+ if (!st.project) {
133
+ return apiError(c, 404, 'NOT_FOUND', `Project "${projectId}" not found`);
134
+ }
135
+ if (!st.trusted) {
136
+ return apiError(c, 409, 'TRUST_REQUIRED', `项目「${st.project.name ?? st.project.worktree}」未信任(或信任后配置漂移),请先信任项目再创建工作流`);
137
+ }
138
+ saveDir = st.project.worktree;
139
+ }
140
+ else if (!(await serveCwdTrusted(ctx))) {
141
+ return apiError(c, 409, 'TRUST_REQUIRED', 'serve 启动目录的项目未信任(或信任后配置漂移)——项目级工作流是仓库自带的任意代码执行面,请先信任项目再创建工作流');
142
+ }
143
+ }
144
+ // 同名覆盖保护:目标层级已有同名工作流且未显式 overwrite → 409。
145
+ // 此前 POST 无条件写文件截断——新建误输已有名称会静默替换其内容
146
+ // (斜杠 /workflow create 有 --yes 确认,这里没有)。编辑模式由前端
147
+ // 显式传 overwrite: true。
148
+ const targetFilePath = join(target === 'project' ? join(saveDir, '.c0de') : resolveGlobalConfigDir(), 'workflows', `${name}.js`);
149
+ if (existsSync(targetFilePath) && overwrite !== true) {
150
+ return apiError(c, 409, 'ALREADY_EXISTS', `工作流 "${name}" 已存在(${target === 'project' ? '项目' : '用户'}级)——覆盖会替换其内容。` +
151
+ '确认覆盖请使用「编辑」该工作流,或改用 /workflow create --yes');
152
+ }
60
153
  // 保存到磁盘 + dynamic import 验证
61
- const result = await saveWorkflow(name, source, target ?? 'project', ctx.cwd);
154
+ const result = await saveWorkflow(name, source, target, saveDir);
62
155
  if (!result.ok) {
63
156
  return apiError(c, 400, 'SAVE_FAILED', result.error);
64
157
  }
65
- // 热重载注册表(清空 → 三级重新发现)
66
- await reloadRegistry(registry, ctx.cwd);
158
+ // 用户刚经本入口创建的 workflow 是用户自己写的代码,先纳入信任指纹——
159
+ // 否则「已信任项目新建工作流」会因指纹漂移(新增文件 hash)把自己重新
160
+ // 锁出门禁(下次聊天/列出即 TRUST_REQUIRED),信任 → 创建 → 再信任的死循环。
161
+ // 必须先于 reloadRegistry:reload 的项目级发现按 projectTrustCurrent 判定,
162
+ // 新文件未入指纹时会误判漂移而跳过(registry 里看不到刚创建的工作流)。
163
+ try {
164
+ const p = await getByDirectory(ctx.db, saveDir);
165
+ if (p?.trustedAt != null)
166
+ await trustProject(ctx.db, p.id);
167
+ }
168
+ catch {
169
+ // 指纹刷新失败不阻塞创建结果(最坏回到「需重新信任」的 fail-closed 路径)
170
+ }
171
+ // 热重载注册表(清空 → 三级重新发现);项目级发现按信任门禁(fail-closed),
172
+ // 未信任 serve 目录项目时绝不 import 仓库中既有的工作流文件。
173
+ await reloadRegistry(registry, ctx.cwd, { projectTrusted: await serveCwdTrusted(ctx) });
67
174
  const entry = registry.get(name);
68
175
  return c.json({
69
176
  ok: true,
@@ -81,18 +188,23 @@ function createWorkflowsRoute(ctx) {
81
188
  if (!registry) {
82
189
  return apiError(c, 500, 'NOT_INITIALIZED', 'Workflow registry not initialized');
83
190
  }
84
- let entry = registry.get(name);
85
- // 项目级 fallback
86
- if (!entry) {
87
- const projectId = c.req.query('projectId');
88
- if (projectId) {
89
- const project = await getProject(ctx.db, projectId);
90
- if (project) {
91
- const discovered = await discoverWorkflows(project.worktree);
92
- entry = discovered.find((w) => w.meta.name === name);
93
- }
191
+ // 统一解析:项目级(已信任)> 用户 > 内置,与 GET / 列表口径一致。
192
+ // 此前注册表优先——项目级同名工作流被用户级/内置遮蔽,列表显示项目版、
193
+ // 查看/编辑打开的却是另一份。
194
+ const projectId = c.req.query('projectId');
195
+ let entry;
196
+ if (projectId) {
197
+ const { project, trusted } = await projectTrustState(ctx, projectId);
198
+ if (project && trusted) {
199
+ entry = await resolveWorkflow({
200
+ name,
201
+ registry,
202
+ projectDir: project.worktree,
203
+ projectTrusted: true,
204
+ });
94
205
  }
95
206
  }
207
+ entry ??= registry.get(name);
96
208
  if (!entry) {
97
209
  return apiError(c, 404, 'NOT_FOUND', `Workflow "${name}" not found`);
98
210
  }
@@ -106,6 +218,12 @@ function createWorkflowsRoute(ctx) {
106
218
  });
107
219
  });
108
220
  // POST /:name/run — 执行工作流(SSE 推送进度)。可选 ?projectId=xxx 执行项目级工作流。
221
+ // P1 对齐 chat 路由斜杠路径:
222
+ // - 信任门禁:未信任/漂移项目 409 TRUST_REQUIRED(与聊天入口同口径);
223
+ // - 交互式权限:ask 工具经 SSE permission_required + 全局 store 确认(此前
224
+ // autoAllowChecker 无确认通道,写/执行工具静默不可用);
225
+ // - 预算护栏:按项目合并配置注入 budgetPause(此前完全旁路);
226
+ // - 会话绑定 projectId/worktreePath(此前丢失,继续对话会在错误目录执行)。
109
227
  app.post('/:name/run', async (c) => {
110
228
  const name = c.req.param('name');
111
229
  const registry = ctx.workflowRegistry;
@@ -113,15 +231,33 @@ function createWorkflowsRoute(ctx) {
113
231
  return apiError(c, 500, 'NOT_INITIALIZED', 'Workflow registry not initialized');
114
232
  }
115
233
  let entry = registry.get(name);
116
- // 项目级 fallback + 解析项目 worktree 作为 agent cwd
234
+ // 项目级解析 + 项目 worktree 作为 agent cwd
117
235
  let agentCwd = ctx.cwd;
236
+ let project = null;
118
237
  const projectId = c.req.query('projectId');
119
- if (!entry && projectId) {
120
- const project = await getProject(ctx.db, projectId);
238
+ if (projectId) {
239
+ const st = await projectTrustState(ctx, projectId);
240
+ project = st.project;
121
241
  if (project) {
122
242
  agentCwd = project.worktree;
123
- const discovered = await discoverWorkflows(project.worktree);
124
- entry = discovered.find((w) => w.meta.name === name);
243
+ // 信任门禁先行:未信任(或信任后漂移)项目在发现/执行前拦截——发现本身
244
+ // 就会执行工作流模块顶层代码,必须与聊天入口同一道闸。
245
+ const scopes = loadConfigScopes(agentCwd);
246
+ const risks = projectTrustNeeded(scopes.project, scopes.global, project.trustedAt, project.riskFingerprint, project.worktree);
247
+ if (risks.length > 0) {
248
+ const items = enrichProjectRiskWithGlobal(risks, scopes.global);
249
+ return apiError(c, 409, 'TRUST_REQUIRED', `项目「${project.name ?? project.worktree}」的项目配置(.c0de/config.json 或 .c0de/workflows)包含需要你确认的风险项`, { projectId: project.id, projectName: project.name ?? project.worktree, items });
250
+ }
251
+ // 门禁通过后按「项目 > 用户 > 内置」解析——此前注册表优先,项目级
252
+ // 同名工作流被用户级条目遮蔽,运行的不是列表展示的那份。
253
+ if (st.trusted) {
254
+ entry = await resolveWorkflow({
255
+ name,
256
+ registry,
257
+ projectDir: project.worktree,
258
+ projectTrusted: true,
259
+ });
260
+ }
125
261
  }
126
262
  }
127
263
  if (!entry) {
@@ -129,65 +265,204 @@ function createWorkflowsRoute(ctx) {
129
265
  }
130
266
  const body = await c.req.json().catch(() => ({}));
131
267
  const args = body.args ?? '';
268
+ // 会话级配置:按 agent cwd 解析项目作用域合并(与 chat 路由同口径);
269
+ // 项目配置含 providers 时构建/复用项目级 LLM 注册表。
270
+ const sessionProjectScope = agentCwd === ctx.cwd ? undefined : loadConfigScopes(agentCwd).project;
271
+ const sessionConfig = sessionProjectScope
272
+ ? mergeConfig(ctx.config, sessionProjectScope)
273
+ : ctx.config;
274
+ const sessionDefaultMode = sessionProjectScope?.permission?.defaultMode;
275
+ let sessionRegistry = ctx.llmRegistry;
276
+ if (sessionProjectScope && project) {
277
+ const cached = ctx.projectRegistries?.get(project.id);
278
+ if (cached) {
279
+ sessionRegistry = cached;
280
+ }
281
+ else if (sessionConfig.providers.length > 0) {
282
+ sessionRegistry = buildRegistryFromConfig(sessionConfig);
283
+ ctx.projectRegistries?.set(project.id, sessionRegistry);
284
+ }
285
+ }
286
+ // 并发守卫(同步原子占位):同一执行作用域(项目/目录)仅允许一个工作流运行。
287
+ // 此前 tryAcquire(session.id) 作用在刚创建的全新会话 id 上(永真)——双 POST、
288
+ // REST 与斜杠并行都能并发执行同一项目的工作流。
289
+ const scopeKey = projectId ? `project:${projectId}` : `dir:${ctx.cwd}`;
290
+ if (ctx.workflowBusyByScope.has(scopeKey)) {
291
+ return apiError(c, 409, 'RUN_ACTIVE', '该项目/目录已有进行中的工作流执行,请等待完成或中止后重试');
292
+ }
293
+ ctx.workflowBusyByScope.set(scopeKey, '');
294
+ // 会话绑定 projectId + worktreePath:后续继续该会话时 agent 在正确的
295
+ // 项目目录执行(resolveAgentCwd 不再回退 serve cwd)。
296
+ let session;
297
+ try {
298
+ session = await createSession(ctx.db, workflowSessionTitle(name), project?.id ?? undefined, 'workflow', undefined, undefined, project?.worktree ?? agentCwd,
299
+ // 中断恢复指引需要工作流名(重启后会话内无 user 消息可重发)。
300
+ { workflowName: name });
301
+ }
302
+ catch (e) {
303
+ ctx.workflowBusyByScope.delete(scopeKey);
304
+ throw e;
305
+ }
306
+ ctx.workflowBusyByScope.set(scopeKey, session.id);
307
+ const releaseScopeBusy = () => {
308
+ if (ctx.workflowBusyByScope.get(scopeKey) === session.id) {
309
+ ctx.workflowBusyByScope.delete(scopeKey);
310
+ }
311
+ };
312
+ const permissionTimeoutAction = sessionConfig.permission.timeoutAction === 'deny' ? 'deny' : 'pause';
313
+ // P2-5:runner 工具集传配置解析结果(与 chat 斜杠通道/CLI print 同口径),
314
+ // 作为子 agent 工具集交集基准——config.tools.enabled 的 fail-closed 语义
315
+ // 对工作流派发的子 agent 成立。runner 自身不进 agentLoop,无 LLM 影响。
132
316
  const agentConfig = {
133
- provider: ctx.config.defaultProvider,
134
- model: ctx.config.defaultModel,
135
- tools: [],
136
- plugins: ctx.config.plugins.enabled,
317
+ provider: sessionConfig.defaultProvider,
318
+ model: sessionConfig.defaultModel,
319
+ tools: resolveEnabledToolNames(ctx.toolRegistry, sessionConfig).filter((n) => Boolean(project?.id) || !PROJECT_BOUND_TOOLS.has(n)),
320
+ plugins: sessionConfig.plugins.enabled,
137
321
  agentName: 'default',
138
322
  };
139
- const session = await createSession(ctx.db, `workflow:${name}`, undefined, 'workflow');
140
- const parent = await createAgent(session, agentConfig, {
141
- db: ctx.db,
142
- llmRegistry: ctx.llmRegistry,
143
- toolRegistry: ctx.toolRegistry,
144
- permission: autoAllowChecker,
145
- config: ctx.config,
146
- cwd: agentCwd,
147
- agentRegistry: ctx.agentRegistry,
148
- });
149
- return streamSSE(c, async (stream) => {
150
- const deps = {
151
- db: ctx.db,
152
- llmRegistry: ctx.llmRegistry,
153
- toolRegistry: ctx.toolRegistry,
154
- permission: autoAllowChecker,
155
- config: ctx.config,
156
- cwd: agentCwd,
157
- agentRegistry: ctx.agentRegistry,
158
- };
159
- try {
160
- const result = await executeWorkflow({
161
- registry,
162
- name,
163
- entry,
164
- args,
165
- deps,
166
- parent,
167
- onProgress: async (message, detail) => {
168
- await stream.writeSSE({
169
- event: 'progress',
170
- data: JSON.stringify({ message, detail }),
171
- });
172
- },
173
- });
174
- await stream.writeSSE({
175
- event: 'result',
176
- data: JSON.stringify(result),
177
- });
178
- }
179
- catch (e) {
180
- await stream.writeSSE({
181
- event: 'error',
182
- data: JSON.stringify({
183
- _tag: 'error',
184
- message: e instanceof Error ? e.message : String(e),
185
- }),
186
- });
323
+ // 并发占位:同步原子占位(与 chat 路由同语义),SSE 回调内 register 填充。
324
+ if (!ctx.agentManager.tryAcquire(session.id)) {
325
+ releaseScopeBusy();
326
+ return apiError(c, 409, 'RUN_ACTIVE', '该会话已有进行中的工作流执行');
327
+ }
328
+ // 持久化 run 状态:运行中 → completed。服务崩溃时停留在 running,
329
+ // 重启后 /:id/status 据此识别为 interrupted(与斜杠通道同口径)。
330
+ const startedAtMs = Date.now();
331
+ await updateSessionLastRun(ctx.db, session.id, {
332
+ status: 'running',
333
+ startedAt: startedAtMs,
334
+ }).catch(() => { });
335
+ let handedOff = false;
336
+ try {
337
+ const response = streamSSE(c, async (stream) => {
338
+ const stopHeartbeat = startHeartbeat(stream);
339
+ try {
340
+ // 交互式权限:与 chat 路由斜杠路径一致(permission_required → 全局
341
+ // store → /api/tools/confirm),写/执行工具不再静默不可用。
342
+ const permissionChecker = createInteractivePermissionChecker(ctx.permissionStore, {
343
+ getMode: () => ctx.sessionPermissionModes.get(session.id) ??
344
+ sessionDefaultMode ??
345
+ ctx.permissionMode,
346
+ alwaysAllow: () => ctx.sessionAlwaysAllow.get(session.id) ?? [],
347
+ // 子 agent 的 ask 请求统一归属到工作流会话(树中可见节点),
348
+ // 打开该会话即可重挂全部挂起弹窗。
349
+ reportSessionId: session.id,
350
+ onPermissionRequired: async (req) => {
351
+ await stream.writeSSE({
352
+ event: 'permission_required',
353
+ data: JSON.stringify({ _tag: 'permission_required', ...req }),
354
+ });
355
+ },
356
+ onPermissionTimeout: (req) => {
357
+ stream
358
+ .writeSSE({
359
+ event: 'permission_timeout',
360
+ data: JSON.stringify({
361
+ _tag: 'permission_timeout',
362
+ ...req,
363
+ timeoutAction: permissionTimeoutAction,
364
+ }),
365
+ })
366
+ .catch(() => { });
367
+ },
368
+ onPermissionExpired: (req) => {
369
+ if (permissionTimeoutAction === 'pause') {
370
+ // P1 级联暂停:与主 chat 通道同口径,子 agent 一并暂停。
371
+ ctx.agentManager.pause(session.id, PERMISSION_TIMEOUT_PAUSE_REASON);
372
+ for (const child of ctx.agentManager.children(session.id)) {
373
+ ctx.agentManager.pause(child.sessionId, PERMISSION_TIMEOUT_PAUSE_REASON);
374
+ }
375
+ }
376
+ stream
377
+ .writeSSE({
378
+ event: 'permission_expired',
379
+ data: JSON.stringify({
380
+ _tag: 'permission_expired',
381
+ ...req,
382
+ timeoutAction: permissionTimeoutAction,
383
+ }),
384
+ })
385
+ .catch(() => { });
386
+ },
387
+ });
388
+ const deps = {
389
+ db: ctx.db,
390
+ llmRegistry: sessionRegistry,
391
+ toolRegistry: ctx.toolRegistry,
392
+ urlRegistry: ctx.urlRegistry,
393
+ hookRunner: ctx.hookRunner,
394
+ permission: permissionChecker,
395
+ config: sessionConfig,
396
+ cwd: agentCwd,
397
+ agentRegistry: ctx.agentRegistry,
398
+ registerChildRun: childRunBridge(ctx.agentManager),
399
+ // 预算护栏:与 chat 路由同口径(金额/token 任一轴 pause/abort 即启用)。
400
+ ...(sessionConfig.usage?.budgetAction === 'pause' ||
401
+ sessionConfig.usage?.budgetAction === 'abort' ||
402
+ sessionConfig.usage?.tokenBudgetAction === 'pause' ||
403
+ sessionConfig.usage?.tokenBudgetAction === 'abort'
404
+ ? { budgetPause: true }
405
+ : {}),
406
+ };
407
+ const parent = await createAgent(session, agentConfig, deps);
408
+ ctx.agentManager.register({ sessionId: session.id, state: parent, deps });
409
+ stream.onAbort(() => {
410
+ ctx.agentManager.abort(session.id);
411
+ });
412
+ const result = await executeWorkflow({
413
+ registry,
414
+ name,
415
+ entry,
416
+ args,
417
+ deps,
418
+ parent,
419
+ onProgress: async (message, detail) => {
420
+ await stream.writeSSE({
421
+ event: 'progress',
422
+ data: JSON.stringify({ message, detail }),
423
+ });
424
+ },
425
+ });
426
+ await stream.writeSSE({
427
+ event: 'result',
428
+ data: JSON.stringify(result),
429
+ });
430
+ }
431
+ catch (e) {
432
+ await stream
433
+ .writeSSE({
434
+ event: 'error',
435
+ data: JSON.stringify({
436
+ _tag: 'error',
437
+ message: e instanceof Error ? e.message : String(e),
438
+ }),
439
+ })
440
+ .catch(() => { });
441
+ }
442
+ finally {
443
+ stopHeartbeat();
444
+ ctx.agentManager.unregister(session.id);
445
+ void updateSessionLastRun(ctx.db, session.id, {
446
+ status: 'completed',
447
+ startedAt: startedAtMs,
448
+ }).catch(() => { });
449
+ releaseScopeBusy();
450
+ }
451
+ });
452
+ handedOff = true;
453
+ return response;
454
+ }
455
+ finally {
456
+ if (!handedOff) {
457
+ ctx.agentManager.unregister(session.id);
458
+ releaseScopeBusy();
187
459
  }
188
- });
460
+ }
189
461
  });
190
- // DELETE /:name — 删除(仅非 builtin)
462
+ // DELETE /:name — 删除(仅非 builtin)。可选 ?projectId=xxx&target=project|user 删除指定层级。
463
+ // 不 dynamic import 仓库代码(删除无需信任门禁,未信任项目也可清理自己的文件),仅按路径操作。
464
+ // target 显式指定层级:此前仅凭「项目文件是否存在」猜测——?projectId 下项目文件缺失时
465
+ // 会静默回退删除用户级同名文件(竞态/作用域歧义),现按目标层级 404,不回退。
191
466
  app.delete('/:name', async (c) => {
192
467
  const name = c.req.param('name');
193
468
  // 名称格式校验(涉及文件系统操作前阻断路径穿越)
@@ -198,23 +473,137 @@ function createWorkflowsRoute(ctx) {
198
473
  if (!registry) {
199
474
  return apiError(c, 500, 'NOT_INITIALIZED', 'Workflow registry not initialized');
200
475
  }
201
- const entry = registry.get(name);
202
- if (!entry) {
476
+ const registryEntry = registry.get(name);
477
+ const projectId = c.req.query('projectId');
478
+ const target = c.req.query('target');
479
+ // P2 运行中守卫:删除文件后运行中的 run 仍以内存模块继续执行(用户以为已停),
480
+ // 且 lastRun 记录随注册表条目消失——崩溃后 interrupted 识别丢失。
481
+ // 按名字匹配所有 busy 作用域(run 会话 metadata.workflowName,两通道均落盘);
482
+ // 同名不同层级的运行会构成假阳性阻断,但安全侧(稍后重删)。
483
+ const busyRun = findBusyWorkflowByName(ctx, name);
484
+ if (busyRun) {
485
+ return apiError(c, 409, 'RUN_ACTIVE', `工作流 "${name}" 正在运行(会话 ${busyRun}),请先中止该运行再删除`);
486
+ }
487
+ // 项目级删除:?projectId 指定项目 worktree;缺省 serve cwd(全局设置视图下
488
+ // 的启动目录项目工作流)。文件缺失 → 404,绝不回退删除其他层级。
489
+ if (target === 'project') {
490
+ let baseDir = ctx.cwd;
491
+ if (projectId) {
492
+ const project = await getProject(ctx.db, projectId);
493
+ if (!project) {
494
+ return apiError(c, 404, 'NOT_FOUND', `Project "${projectId}" not found`);
495
+ }
496
+ baseDir = project.worktree;
497
+ }
498
+ const filePath = join(baseDir, '.c0de', 'workflows', `${name}.js`);
499
+ if (!existsSync(filePath)) {
500
+ return apiError(c, 404, 'NOT_FOUND', `项目级工作流 "${name}" 不存在(${join(baseDir, '.c0de', 'workflows')})`);
501
+ }
502
+ try {
503
+ await unlink(filePath);
504
+ }
505
+ catch {
506
+ return apiError(c, 500, 'DELETE_FAILED', `Failed to delete workflow file for "${name}"`);
507
+ }
508
+ // P1 修复:删除文件同样改变信任指纹(workflowDirHashes 随文件集变化)——
509
+ // 不刷新会让已信任项目在下一次聊天时被误判「配置漂移」而重新门禁,
510
+ // 与 create 路径的 trustProject 刷新对称(防「信任→删除→自锁复检」)。
511
+ try {
512
+ const p = await getByDirectory(ctx.db, baseDir);
513
+ if (p?.trustedAt != null)
514
+ await trustProject(ctx.db, p.id);
515
+ }
516
+ catch {
517
+ // 指纹刷新失败不阻塞删除结果(最坏回到 fail-closed 复检路径)
518
+ }
519
+ // 热重载:项目级条目消失后,被其遮蔽的同名 user 级条目应重新可见——
520
+ // registry 单名单位(后注册覆盖),仅 delete 无法恢复被覆盖掉的 user 条目。
521
+ try {
522
+ await reloadRegistry(registry, ctx.cwd, {
523
+ projectTrusted: await serveCwdTrusted(ctx),
524
+ });
525
+ }
526
+ catch {
527
+ // 重载失败不阻塞删除结果(其他文件损坏等);下次启动/创建时自然恢复
528
+ }
529
+ return c.json({ ok: true });
530
+ }
531
+ // 用户级删除:仅删用户级条目(~/.c0de/workflows)。同名项目级文件存在时
532
+ // 注册表条目是项目级——此时列表也不展示用户行,不存在此删除请求。
533
+ if (target === 'user') {
534
+ const userEntry = registryEntry?.source === 'user' ? registryEntry : undefined;
535
+ if (!userEntry?.filePath) {
536
+ return apiError(c, 404, 'NOT_FOUND', `用户级工作流 "${name}" 不存在`);
537
+ }
538
+ try {
539
+ await unlink(userEntry.filePath);
540
+ }
541
+ catch {
542
+ return apiError(c, 500, 'DELETE_FAILED', `Failed to delete workflow file for "${name}"`);
543
+ }
544
+ // 热重载:user 级条目可能遮蔽同名 builtin,仅 delete 会让被覆盖的 builtin
545
+ // 永久消失(registry 单名单位),重建恢复完整层级。
546
+ try {
547
+ await reloadRegistry(registry, ctx.cwd, {
548
+ projectTrusted: await serveCwdTrusted(ctx),
549
+ });
550
+ }
551
+ catch {
552
+ // 重载失败不阻塞删除结果
553
+ }
554
+ return c.json({ ok: true });
555
+ }
556
+ // 兼容旧行为(无 target):按注册表条目删除(旧客户端调用)。
557
+ // 项目级删除目标:按 worktree 拼路径,不做 discovery(避免执行仓库代码)。
558
+ let projectFilePath = null;
559
+ if (projectId) {
560
+ const project = await getProject(ctx.db, projectId);
561
+ if (!project) {
562
+ return apiError(c, 404, 'NOT_FOUND', `Project "${projectId}" not found`);
563
+ }
564
+ const candidate = join(project.worktree, '.c0de', 'workflows', `${name}.js`);
565
+ if (existsSync(candidate))
566
+ projectFilePath = candidate;
567
+ }
568
+ // 无项目级文件且注册表无同名条目 → 404
569
+ if (!projectFilePath && !registryEntry) {
203
570
  return apiError(c, 404, 'NOT_FOUND', `Workflow "${name}" not found`);
204
571
  }
205
- if (entry.source === 'builtin') {
572
+ // 仅注册表条目且为内置 → 拒绝;项目级文件是用户自建代码,可删
573
+ if (!projectFilePath && registryEntry?.source === 'builtin') {
206
574
  return apiError(c, 400, 'BAD_REQUEST', 'Cannot delete builtin workflow');
207
575
  }
576
+ const fileToUnlink = projectFilePath ?? registryEntry?.filePath;
208
577
  // 先从磁盘删除文件(若存在),失败则告知用户且不清理 registry 以保持状态一致
209
- if (entry.filePath) {
578
+ if (fileToUnlink) {
210
579
  try {
211
- await unlink(entry.filePath);
580
+ await unlink(fileToUnlink);
212
581
  }
213
582
  catch {
214
583
  return apiError(c, 500, 'DELETE_FAILED', `Failed to delete workflow file for "${name}"`);
215
584
  }
216
585
  }
217
- registry.delete(name);
586
+ // P1 修复:删除项目级文件同样刷新信任指纹(与 target=project 分支同口径),
587
+ // 防「删除→指纹漂移→自锁复检」。
588
+ if (projectFilePath) {
589
+ try {
590
+ const project = await getProject(ctx.db, projectId ?? '');
591
+ if (project?.trustedAt != null)
592
+ await trustProject(ctx.db, project.id);
593
+ }
594
+ catch {
595
+ // 指纹刷新失败不阻塞删除结果
596
+ }
597
+ }
598
+ // 热重载:删除可能解除 user/builtin 同名条目的遮蔽,registry 单名单位必须重建。
599
+ try {
600
+ await reloadRegistry(registry, ctx.cwd, {
601
+ projectTrusted: await serveCwdTrusted(ctx),
602
+ });
603
+ }
604
+ catch {
605
+ // 重载失败不阻塞删除结果
606
+ }
218
607
  return c.json({ ok: true });
219
608
  });
220
609
  return app;