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.
- package/README.md +63 -16
- package/dist/cli/commands/acp.js +26 -8
- package/dist/cli/commands/chat.js +151 -1
- package/dist/cli/commands/config.d.ts +2 -0
- package/dist/cli/commands/config.js +63 -3
- package/dist/cli/commands/serve.js +11 -2
- package/dist/cli/commands/sessions.js +109 -3
- package/dist/cli/commands/trust.d.ts +11 -0
- package/dist/cli/commands/trust.js +31 -0
- package/dist/cli/commands/update.js +4 -0
- package/dist/cli/deps.d.ts +9 -6
- package/dist/cli/deps.js +66 -19
- package/dist/cli/index.d.ts +4 -1
- package/dist/cli/index.js +110 -11
- package/dist/cli/modes/acp.js +39 -8
- package/dist/cli/modes/print.d.ts +3 -0
- package/dist/cli/modes/print.js +78 -8
- package/dist/core/agent.d.ts +1 -1
- package/dist/core/agent.js +9 -4
- package/dist/core/agents/builtin.js +2 -2
- package/dist/core/agents/index.d.ts +0 -1
- package/dist/core/agents/index.js +0 -1
- package/dist/core/compact.d.ts +2 -0
- package/dist/core/compact.js +1 -0
- package/dist/core/config.d.ts +53 -2
- package/dist/core/config.js +235 -19
- package/dist/core/loop/compaction.d.ts +2 -1
- package/dist/core/loop/compaction.js +19 -6
- package/dist/core/loop/segment.d.ts +3 -1
- package/dist/core/loop/segment.js +24 -1
- package/dist/core/loop/stream-collect.js +9 -1
- package/dist/core/loop/subagent.js +88 -37
- package/dist/core/loop.d.ts +7 -0
- package/dist/core/loop.js +90 -6
- package/dist/core/redact.d.ts +11 -0
- package/dist/core/redact.js +49 -0
- package/dist/core/slash.d.ts +10 -0
- package/dist/core/slash.js +237 -65
- package/dist/core/title.js +7 -1
- package/dist/core/types.d.ts +22 -1
- package/dist/core/workflows/context.js +44 -4
- package/dist/core/workflows/discovery.d.ts +2 -2
- package/dist/core/workflows/discovery.js +5 -7
- package/dist/core/workflows/index.d.ts +2 -1
- package/dist/core/workflows/index.js +2 -1
- package/dist/core/workflows/registry.d.ts +13 -3
- package/dist/core/workflows/registry.js +14 -9
- package/dist/core/workflows/resolve.d.ts +22 -0
- package/dist/core/workflows/resolve.js +23 -0
- package/dist/core/workflows/runtime.d.ts +6 -1
- package/dist/core/workflows/runtime.js +19 -2
- package/dist/db/schema.d.ts +373 -1
- package/dist/db/schema.js +60 -4
- package/dist/kanban/index.d.ts +2 -1
- package/dist/kanban/index.js +1 -1
- package/dist/kanban/store.d.ts +87 -1
- package/dist/kanban/store.js +429 -22
- package/dist/llm/index.d.ts +1 -1
- package/dist/llm/index.js +1 -1
- package/dist/llm/provider.js +1 -1
- package/dist/llm/registry.d.ts +7 -1
- package/dist/llm/registry.js +7 -1
- package/dist/llm/retry.d.ts +5 -2
- package/dist/llm/retry.js +6 -5
- package/dist/llm/routing.d.ts +24 -1
- package/dist/llm/routing.js +29 -1
- package/dist/plugins/init.d.ts +7 -0
- package/dist/plugins/init.js +22 -1
- package/dist/plugins/loader.d.ts +7 -1
- package/dist/plugins/loader.js +12 -4
- package/dist/project/index.d.ts +1 -1
- package/dist/project/index.js +1 -1
- package/dist/project/project.d.ts +37 -0
- package/dist/project/project.js +142 -1
- package/dist/project/trust.d.ts +65 -0
- package/dist/project/trust.js +328 -0
- package/dist/server/agent-manager.d.ts +15 -2
- package/dist/server/agent-manager.js +18 -3
- package/dist/server/app.js +45 -30
- package/dist/server/auth-manager.d.ts +26 -9
- package/dist/server/auth-manager.js +78 -42
- package/dist/server/context.js +14 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/middleware/auth.d.ts +11 -1
- package/dist/server/middleware/auth.js +25 -3
- package/dist/server/permission/interactive.d.ts +11 -3
- package/dist/server/permission/interactive.js +23 -4
- package/dist/server/permission/store.d.ts +14 -2
- package/dist/server/permission/store.js +39 -3
- package/dist/server/registry-config.d.ts +14 -0
- package/dist/server/registry-config.js +44 -0
- package/dist/server/routes/auth.js +66 -10
- package/dist/server/routes/chat.d.ts +21 -0
- package/dist/server/routes/chat.js +685 -52
- package/dist/server/routes/commands.d.ts +4 -2
- package/dist/server/routes/commands.js +9 -4
- package/dist/server/routes/config.js +180 -23
- package/dist/server/routes/files.js +142 -28
- package/dist/server/routes/kanban.js +218 -26
- package/dist/server/routes/permissions.d.ts +0 -4
- package/dist/server/routes/permissions.js +140 -7
- package/dist/server/routes/project.js +132 -29
- package/dist/server/routes/provider.js +54 -3
- package/dist/server/routes/session.js +463 -23
- package/dist/server/routes/tool.js +24 -5
- package/dist/server/routes/update.d.ts +11 -5
- package/dist/server/routes/update.js +145 -29
- package/dist/server/routes/usage.d.ts +4 -0
- package/dist/server/routes/usage.js +133 -0
- package/dist/server/routes/workflows.js +476 -87
- package/dist/server/server.d.ts +16 -13
- package/dist/server/server.js +364 -107
- package/dist/server/terminal/pty-manager.d.ts +12 -0
- package/dist/server/terminal/pty-manager.js +63 -7
- package/dist/server/types.d.ts +16 -0
- package/dist/session/archive.d.ts +3 -1
- package/dist/session/archive.js +13 -1
- package/dist/session/branch.d.ts +2 -1
- package/dist/session/branch.js +92 -9
- package/dist/session/context.js +28 -2
- package/dist/session/import.d.ts +44 -0
- package/dist/session/import.js +83 -0
- package/dist/session/index.d.ts +1 -1
- package/dist/session/index.js +1 -1
- package/dist/session/jobs.d.ts +12 -0
- package/dist/session/jobs.js +53 -0
- package/dist/session/message.d.ts +7 -1
- package/dist/session/message.js +21 -1
- package/dist/session/session.d.ts +186 -12
- package/dist/session/session.js +577 -33
- package/dist/session/snapshot.d.ts +5 -2
- package/dist/session/snapshot.js +10 -2
- package/dist/session/types.d.ts +13 -1
- package/dist/session/usage.d.ts +62 -0
- package/dist/session/usage.js +194 -0
- package/dist/shared/types/agent.d.ts +29 -2
- package/dist/shared/types/config.d.ts +64 -5
- package/dist/shared/types/kanban.d.ts +17 -2
- package/dist/shared/types/message.d.ts +23 -0
- package/dist/tools/builtin/kanban.js +13 -2
- package/dist/tools/index.d.ts +14 -3
- package/dist/tools/index.js +23 -5
- package/dist/tools/websearch/index.d.ts +5 -1
- package/dist/tools/websearch/index.js +46 -7
- package/dist/tools/websearch/providers/brave.js +2 -2
- package/dist/tools/websearch/providers/duckduckgo.js +2 -1
- package/dist/tools/websearch/providers/tavily.js +2 -2
- package/dist/tools/websearch/types.d.ts +5 -1
- package/dist/tools/websearch/types.js +7 -1
- package/dist/update/index.d.ts +3 -3
- package/dist/update/index.js +2 -2
- package/dist/update/ipc.d.ts +13 -2
- package/dist/update/ipc.js +36 -2
- package/dist/update/scheduler.js +5 -2
- package/dist/update/snapshot.d.ts +18 -3
- package/dist/update/snapshot.js +5 -2
- package/dist/update/updater.d.ts +17 -1
- package/dist/update/updater.js +47 -18
- package/dist/update/version.d.ts +3 -0
- package/dist/update/version.js +9 -3
- package/drizzle/0008_tranquil_lily_hollister.sql +1 -0
- package/drizzle/0010_usage_events_ledger.sql +17 -0
- package/drizzle/0011_slow_slapstick.sql +12 -0
- package/drizzle/0012_chief_timeslip.sql +1 -0
- package/drizzle/0013_swift_zeigeist.sql +1 -0
- package/drizzle/0014_flimsy_legion.sql +1 -0
- package/drizzle/meta/0008_snapshot.json +875 -0
- package/drizzle/meta/0010_snapshot.json +1010 -0
- package/drizzle/meta/0011_snapshot.json +1055 -0
- package/drizzle/meta/0012_snapshot.json +1062 -0
- package/drizzle/meta/0013_snapshot.json +1068 -0
- package/drizzle/meta/0014_snapshot.json +1075 -0
- package/drizzle/meta/_journal.json +42 -0
- package/package.json +42 -42
- package/dist/core/agents/discovery.d.ts +0 -6
- package/dist/core/agents/discovery.js +0 -96
|
@@ -3,18 +3,23 @@ import { readFile, stat } from 'node:fs/promises';
|
|
|
3
3
|
import { Hono } from 'hono';
|
|
4
4
|
import { streamSSE } from 'hono/streaming';
|
|
5
5
|
import { createAgent, runAgent } from '../../core/agent.js';
|
|
6
|
+
import { loadConfigScopes, mergeConfig } from '../../core/config.js';
|
|
6
7
|
import { compactContext } from '../../core/loop.js';
|
|
7
|
-
import { createSlashRegistry, parseSlashInput } from '../../core/slash.js';
|
|
8
|
+
import { createSlashRegistry, isSlashCommandEnabled, parseSlashInput } from '../../core/slash.js';
|
|
8
9
|
import { injectSteering } from '../../core/steering.js';
|
|
9
10
|
import { buildWorkflowNotice, containsWorkflow } from '../../core/workflow.js';
|
|
11
|
+
import { createWorkflowRegistry, discoverWorkflows, executeWorkflow, resolveWorkflow, workflowSessionTitle, } from '../../core/workflows/index.js';
|
|
10
12
|
import { resolveRoute } from '../../llm/registry.js';
|
|
11
13
|
import { getProject } from '../../project/project.js';
|
|
12
|
-
import {
|
|
14
|
+
import { enrichProjectRiskWithGlobal, projectTrustNeeded } from '../../project/trust.js';
|
|
15
|
+
import { insertEntry } from '../../session/message.js';
|
|
16
|
+
import { consumeBudgetPauseMarker, createSession, getLLMSegments, getSession, markUnfinishedTurn, updateSessionLastRun, upgradeTemporarySession, } from '../../session/session.js';
|
|
13
17
|
import { upsertFileSnapshot } from '../../session/snapshot.js';
|
|
14
|
-
import { resolveEnabledToolNames } from '../../tools/index.js';
|
|
18
|
+
import { PROJECT_BOUND_TOOLS, resolveEnabledToolNames } from '../../tools/index.js';
|
|
15
19
|
import { autoAllowChecker } from '../../tools/permission.js';
|
|
16
20
|
import { apiError } from '../middleware/error.js';
|
|
17
21
|
import { createInteractivePermissionChecker } from '../permission/interactive.js';
|
|
22
|
+
import { buildRegistryFromConfig } from '../registry-config.js';
|
|
18
23
|
import { safeResolve } from '../util/safe-path.js';
|
|
19
24
|
/** 按 session.projectId / worktreePath 解析 agent 工作目录:
|
|
20
25
|
* - 有 projectId → 项目 worktree(缺失/目录失效时抛错,由上层转 409)。
|
|
@@ -39,6 +44,58 @@ export async function resolveAgentCwd(ctx, session) {
|
|
|
39
44
|
}
|
|
40
45
|
return project.worktree;
|
|
41
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* SSE 心跳间隔。前端有 90s 静默看门狗(web/services/chat.ts),而服务端各
|
|
49
|
+
* 等待语义远长于此:bash 默认超时 120s、权限确认 5 分钟、子 agent 执行数分钟。
|
|
50
|
+
* 这些等待期间没有业务事件帧——若无心跳,前端会在 90s 时 abort 连接,
|
|
51
|
+
* 服务端 stream.onAbort 随即中止 agent,长命令/权限弹窗/多 agent 全部误杀。
|
|
52
|
+
* 30s 心跳保证看门狗只对「连接真正死亡」(连续 3 个心跳周期无数据)生效。
|
|
53
|
+
*/
|
|
54
|
+
/** 权限确认超时兜底拒绝后暂停 run 的暂停原因(区别于用户主动暂停)。
|
|
55
|
+
* 附重试指引:被拒工具不会自动重试,用户回来后需要知道可以要求 agent 重做。 */
|
|
56
|
+
const PERMISSION_TIMEOUT_PAUSE_REASON = '权限确认超时:该工具已被自动拒绝,对话已暂停。点击「恢复」后可直接要求 agent 重试该工具';
|
|
57
|
+
const SSE_HEARTBEAT_INTERVAL_MS = 30_000;
|
|
58
|
+
/** 启动心跳定时器;返回停止函数(幂等)。流关闭后写入会被吞掉,安全。 */
|
|
59
|
+
function startHeartbeat(stream) {
|
|
60
|
+
const timer = setInterval(() => {
|
|
61
|
+
stream.writeSSE({ event: 'heartbeat', data: '' }).catch(() => { }); // 流已关闭/客户端断开时写入失败,忽略
|
|
62
|
+
}, SSE_HEARTBEAT_INTERVAL_MS);
|
|
63
|
+
return () => clearInterval(timer);
|
|
64
|
+
}
|
|
65
|
+
export { SSE_HEARTBEAT_INTERVAL_MS, startHeartbeat };
|
|
66
|
+
/**
|
|
67
|
+
* 子 agent run 注册桥接:core 的 registerChildRun → agentManager 槽位。
|
|
68
|
+
* P1:此前子 run(含后台任务)从不注册,暂停/热更新/删除级联/更新影响面
|
|
69
|
+
* 全部绕过它们;core 无 agentManager 引用,桥接由 Web 宿主注入。
|
|
70
|
+
* 返回的注销函数幂等(unregister 对不存在的 key 安全)。
|
|
71
|
+
*/
|
|
72
|
+
export function childRunBridge(manager) {
|
|
73
|
+
return (run) => {
|
|
74
|
+
manager.register({
|
|
75
|
+
sessionId: run.sessionId,
|
|
76
|
+
parentSessionId: run.parentSessionId,
|
|
77
|
+
jobId: run.jobId,
|
|
78
|
+
state: run.state,
|
|
79
|
+
deps: run.deps,
|
|
80
|
+
});
|
|
81
|
+
return () => manager.unregister(run.sessionId);
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* 会话「忙」判定统一口径:活跃 run / tryAcquire 占位(starting)/ 工作流运行期
|
|
86
|
+
* (发起会话无主 run,busy 映射承载)。变更型操作(fork/compact/clear 等)在斜杠与
|
|
87
|
+
* REST 双入口共用本判定,防止守卫漂移——REST fork 此前只查 get(),漏 starting
|
|
88
|
+
* 占位窗口与工作流运行期两种态(斜杠守卫早已覆盖)。
|
|
89
|
+
*/
|
|
90
|
+
export function hasBusySession(ctx, sessionId) {
|
|
91
|
+
if (ctx.agentManager.get(sessionId))
|
|
92
|
+
return 'run';
|
|
93
|
+
if (ctx.agentManager.isStarting(sessionId))
|
|
94
|
+
return 'starting';
|
|
95
|
+
if (ctx.workflowBusyBySession.has(sessionId))
|
|
96
|
+
return 'workflow';
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
42
99
|
function createChatRoute(ctx) {
|
|
43
100
|
const app = new Hono();
|
|
44
101
|
// POST / — SSE 流式聊天
|
|
@@ -46,8 +103,36 @@ function createChatRoute(ctx) {
|
|
|
46
103
|
const body = await c.req.json().catch(() => ({}));
|
|
47
104
|
const sessionId = body.sessionId;
|
|
48
105
|
const message = body.message;
|
|
49
|
-
|
|
50
|
-
|
|
106
|
+
const images = body.images;
|
|
107
|
+
// P1-5:纯图片消息放行(Composer 允许只带图片不带文本),
|
|
108
|
+
// 仅当文本与图片皆无时拒绝,错误信息说明可操作原因。
|
|
109
|
+
if (!sessionId || (!message && !images?.length)) {
|
|
110
|
+
return apiError(c, 400, 'BAD_REQUEST', '消息内容不能为空:请输入文字或附带图片后再发送');
|
|
111
|
+
}
|
|
112
|
+
// P0:图片大小/数量服务端限额(前端已限制,此为兜底——API 直调/旧客户端)。
|
|
113
|
+
if (images) {
|
|
114
|
+
const MAX_IMAGE_COUNT = 6;
|
|
115
|
+
const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
|
|
116
|
+
if (images.length > MAX_IMAGE_COUNT) {
|
|
117
|
+
return apiError(c, 400, 'TOO_MANY_IMAGES', `最多 ${MAX_IMAGE_COUNT} 张图片(收到 ${images.length} 张)`);
|
|
118
|
+
}
|
|
119
|
+
for (const img of images) {
|
|
120
|
+
// P3:按解码字节校验(base64 字符串长度比真实字节约大 33%);
|
|
121
|
+
// 无法解码(非法 base64/含 dataURL 前缀等)时按字符串长度保守拦截。
|
|
122
|
+
if (typeof img.data === 'string') {
|
|
123
|
+
let bytes = img.data.length;
|
|
124
|
+
try {
|
|
125
|
+
bytes = Buffer.from(img.data, 'base64').length;
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// 解码失败保留字符串长度兜底
|
|
129
|
+
}
|
|
130
|
+
if (bytes > MAX_IMAGE_BYTES) {
|
|
131
|
+
const mb = (bytes / (1024 * 1024)).toFixed(1);
|
|
132
|
+
return apiError(c, 400, 'IMAGE_TOO_LARGE', `图片大小 ${mb}MB 超过上限 ${MAX_IMAGE_BYTES / (1024 * 1024)}MB`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
51
136
|
}
|
|
52
137
|
let session;
|
|
53
138
|
try {
|
|
@@ -59,6 +144,32 @@ function createChatRoute(ctx) {
|
|
|
59
144
|
if (!session) {
|
|
60
145
|
return apiError(c, 404, 'NOT_FOUND', 'Session not found');
|
|
61
146
|
}
|
|
147
|
+
// P2 修复:拒绝向回收站会话发消息——此前 getSession 不检查 deletedAt,
|
|
148
|
+
// API 直调可继续向软删除会话写消息,与「删除」心智模型冲突
|
|
149
|
+
// (CLI --continue 已拒绝,Web API 未对齐)。前端正常流程不会触发(会话 404)。
|
|
150
|
+
if (session.deletedAt) {
|
|
151
|
+
return apiError(c, 404, 'NOT_FOUND', '会话不存在或已删除(如需继续对话请先从回收站恢复)');
|
|
152
|
+
}
|
|
153
|
+
// P1-3:继续对话的 workflow 会话升级为持久会话——「续接即持久」此前仅
|
|
154
|
+
// 覆盖 CLI print(upgradeTemporarySession 只被 print --continue 调用),
|
|
155
|
+
// workflow 会话在 Web 继续追问后仍会被 30 天临时清理移入回收站。
|
|
156
|
+
if (session.agentType === 'workflow') {
|
|
157
|
+
await upgradeTemporarySession(ctx.db, sessionId).catch(() => {
|
|
158
|
+
// 升级失败不阻塞对话(最坏仍按临时会话清理)
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
// P2:会话级权限覆盖持久化在 metadata——重启后从 DB 恢复到内存 Map,
|
|
162
|
+
// 使 getMode 的「会话覆盖」优先级跨重启成立。
|
|
163
|
+
if (session.metadata.permissionMode === 'auto' ||
|
|
164
|
+
session.metadata.permissionMode === 'default') {
|
|
165
|
+
ctx.sessionPermissionModes.set(sessionId, session.metadata.permissionMode);
|
|
166
|
+
}
|
|
167
|
+
// P2:会话级「始终允许」白名单同样持久化于 metadata,checker 经 getter 实时读取
|
|
168
|
+
// (run 期间用户可追加;此处仅在本会话 Map 尚未加载时注入,已有值优先)。
|
|
169
|
+
if (!ctx.sessionAlwaysAllow.has(sessionId)) {
|
|
170
|
+
const list = session.metadata.alwaysAllow;
|
|
171
|
+
ctx.sessionAlwaysAllow.set(sessionId, Array.isArray(list) ? list.filter((t) => typeof t === 'string') : []);
|
|
172
|
+
}
|
|
62
173
|
// 提前解析 cwd(slash 拦截与 agent 路径都需使用)
|
|
63
174
|
// P1-9:worktree 失效/项目缺失 → 明确 409,不静默回退服务端 cwd。
|
|
64
175
|
let cwd;
|
|
@@ -75,17 +186,333 @@ function createChatRoute(ctx) {
|
|
|
75
186
|
}
|
|
76
187
|
return apiError(c, 500, 'CWD_RESOLVE_FAILED', msg);
|
|
77
188
|
}
|
|
78
|
-
//
|
|
79
|
-
|
|
189
|
+
// P1 多项目配置:会话级配置按会话工作目录解析(该项目 .c0de/config.json 的原始
|
|
190
|
+
// 作用域合并到服务级配置之上)。此前所有项目共用启动目录配置——在其他项目里
|
|
191
|
+
// 修改「项目配置」实际写的是启动项目。cwd === ctx.cwd 时直接复用服务级配置。
|
|
192
|
+
// 权限默认模式单独取原始 project 作用域:项目文件未显式设置时回退运行时全局
|
|
193
|
+
// 模式(启动默认或 PUT /api/permissions 覆盖),避免启动项目配置泄漏到其他项目。
|
|
194
|
+
const sessionProjectScope = cwd === ctx.cwd ? undefined : loadConfigScopes(cwd).project;
|
|
195
|
+
const sessionConfig = sessionProjectScope
|
|
196
|
+
? mergeConfig(ctx.config, sessionProjectScope)
|
|
197
|
+
: ctx.config;
|
|
198
|
+
const sessionDefaultMode = sessionProjectScope?.permission?.defaultMode;
|
|
199
|
+
// P0-2 项目信任门禁:未信任项目(或信任后配置漂移)+ 项目作用域原始配置含
|
|
200
|
+
// 风险项(auto 权限 / timeoutAction=deny 降级 / 启用项目插件 / MCP)→ 409
|
|
201
|
+
// TRUST_REQUIRED,前端弹窗明示风险,用户显式信任(POST /projects/:id/trust)
|
|
202
|
+
// 后重发。只评估项目作用域原始配置(全局配置是用户本机显式编辑,天然可信);
|
|
203
|
+
// 信任非永久:trustedAt 落盘时附带风险指纹,其后仓库 git pull 新增风险键
|
|
204
|
+
// (指纹漂移)同样重新触发门禁(P1 信任指纹复检)。
|
|
205
|
+
if (session.projectId) {
|
|
206
|
+
const project = await getProject(ctx.db, session.projectId);
|
|
207
|
+
if (project) {
|
|
208
|
+
const scopes = loadConfigScopes(cwd);
|
|
209
|
+
const risks = projectTrustNeeded(scopes.project, scopes.global, project.trustedAt, project.riskFingerprint, project.worktree);
|
|
210
|
+
if (risks.length > 0) {
|
|
211
|
+
// 并入全局配置的权限风险上下文(不改变门禁触发条件):用户做信任决策时
|
|
212
|
+
// 能看到完整生效的权限状态(如全局已 auto),避免「信任了项目却困惑于
|
|
213
|
+
// 权限为何仍自动放行」。
|
|
214
|
+
const items = enrichProjectRiskWithGlobal(risks, scopes.global);
|
|
215
|
+
return apiError(c, 409, 'TRUST_REQUIRED', `项目「${project.name ?? project.worktree}」的项目配置(.c0de/config.json)包含需要你确认的风险项`, { projectId: project.id, projectName: project.name ?? project.worktree, items });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// 权限确认超时(双层超时兜底)后的动作:'pause'(默认)拒绝该工具并暂停 run,
|
|
220
|
+
// 防 default 模式下 agent 在用户缺席时继续自主执行;'deny' 保持旧行为(run 继续)。
|
|
221
|
+
// 仅显式 'deny' 时选 deny,缺省/非法值一律 pause(安全默认)。
|
|
222
|
+
const permissionTimeoutAction = sessionConfig.permission.timeoutAction === 'deny' ? 'deny' : 'pause';
|
|
223
|
+
// 暂停目标:会话自身 run + 其全部子 agent run(/workflow run 路径会话无主 run,
|
|
224
|
+
// 只有子 agent——仅 pause 主 run 会让子 agent 在用户缺席时继续执行)。
|
|
225
|
+
// reason 透传给 pauseAgent,使权限超时暂停与用户主动暂停在状态栏可区分。
|
|
226
|
+
const pauseSessionRun = (reason) => {
|
|
227
|
+
ctx.agentManager.pause(sessionId, reason);
|
|
228
|
+
for (const child of ctx.agentManager.children(sessionId)) {
|
|
229
|
+
ctx.agentManager.pause(child.sessionId, reason);
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
// /workflow run 专用执行通道:与 REST POST /api/workflows/:name/run 同口径——
|
|
233
|
+
// 预算护栏、urlRegistry/hookRunner、agentManager 注册 + 断连 abort、进度 SSE。
|
|
234
|
+
// 此前经通用 commandCtx.deps 执行:子 agent 预算旁路(P0)、内部 URL 解析失败、
|
|
235
|
+
// run 游离于 agentManager(不可中止、热更新不可见)。
|
|
236
|
+
const runWorkflowSlash = async (c, workflowArgs) => {
|
|
237
|
+
// 并发守卫:主 run 活跃时不允许同会话发起工作流(权限弹窗/预算/终端混淆)。
|
|
238
|
+
if (ctx.agentManager.get(sessionId) || ctx.agentManager.isStarting(sessionId)) {
|
|
239
|
+
return apiError(c, 409, 'RUN_ACTIVE', '该会话已有进行中的对话,请等待完成或中止后再运行工作流');
|
|
240
|
+
}
|
|
241
|
+
const parts = workflowArgs.split(/\s+/).filter(Boolean);
|
|
242
|
+
const name = parts[1];
|
|
243
|
+
if (!name) {
|
|
244
|
+
return apiError(c, 400, 'BAD_REQUEST', 'Usage: /workflow run <name> [args]');
|
|
245
|
+
}
|
|
246
|
+
const wfArgs = parts.slice(2).join(' ');
|
|
247
|
+
const registry = ctx.workflowRegistry ?? createWorkflowRegistry();
|
|
248
|
+
// 统一解析:项目级(已信任)> 用户 > 内置。仅在会话已绑定项目时发现
|
|
249
|
+
// 项目级工作流——聊天门禁已保证该项目可信且指纹未漂移;孤儿会话
|
|
250
|
+
// (无 projectId)不再按 cwd 无条件发现:其目录未注册、信任状态未知,
|
|
251
|
+
// 此前会在未信任仓库直接 dynamic import 其工作流代码。
|
|
252
|
+
let entry = ctx.workflowRegistry?.get(name);
|
|
253
|
+
if (session.projectId) {
|
|
254
|
+
entry =
|
|
255
|
+
(await resolveWorkflow({
|
|
256
|
+
name,
|
|
257
|
+
registry,
|
|
258
|
+
projectDir: cwd,
|
|
259
|
+
projectTrusted: true,
|
|
260
|
+
})) ?? entry;
|
|
261
|
+
}
|
|
262
|
+
if (!entry) {
|
|
263
|
+
// 错误提示里列出可用工作流:仅在会话绑定项目(门禁已通过)时发现
|
|
264
|
+
// 项目级——与上方解析同口径,不因「拼错名字」在未信任目录执行仓库代码。
|
|
265
|
+
const discovered = session.projectId ? await discoverWorkflows(cwd) : [];
|
|
266
|
+
const available = [
|
|
267
|
+
...registry.list().map((e) => e.meta.name),
|
|
268
|
+
...discovered.map((e) => e.meta.name),
|
|
269
|
+
].join(', ');
|
|
270
|
+
return streamSSE(c, async (stream) => {
|
|
271
|
+
await stream.writeSSE({
|
|
272
|
+
event: 'text_delta',
|
|
273
|
+
data: JSON.stringify({
|
|
274
|
+
_tag: 'text_delta',
|
|
275
|
+
text: `未知工作流:"${name}"。可用:${available || '(无)'}`,
|
|
276
|
+
}),
|
|
277
|
+
});
|
|
278
|
+
await stream.writeSSE({ event: 'done', data: JSON.stringify({ _tag: 'done' }) });
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
// 并发守卫(同步原子占位):执行作用域 = 项目或目录。此前
|
|
282
|
+
// tryAcquire(workflowSession.id) 作用在刚创建的全新会话 id 上(永真),且主会话
|
|
283
|
+
// 在整段工作流期间不注册任何 run——双发 /workflow run、REST 与斜杠并行都能
|
|
284
|
+
// 并发执行。现在同一作用域仅允许一个工作流运行。
|
|
285
|
+
const scopeKey = session.projectId ? `project:${session.projectId}` : `dir:${cwd}`;
|
|
286
|
+
if (ctx.workflowBusyByScope.has(scopeKey)) {
|
|
287
|
+
return apiError(c, 409, 'RUN_ACTIVE', '该项目/目录已有进行中的工作流执行,请等待完成或中止后重试');
|
|
288
|
+
}
|
|
289
|
+
ctx.workflowBusyByScope.set(scopeKey, '');
|
|
290
|
+
// 工作流运行会话:绑定当前会话项目 + worktree(与 REST 路由同口径),
|
|
291
|
+
// 标题带时间戳区分多次运行。
|
|
292
|
+
let workflowSession;
|
|
293
|
+
try {
|
|
294
|
+
workflowSession = await createSession(ctx.db, workflowSessionTitle(name), session.projectId ?? undefined, 'workflow', undefined, undefined, cwd,
|
|
295
|
+
// 中断恢复指引需要工作流名(重启后会话内无 user 消息可重发)。
|
|
296
|
+
{ workflowName: name });
|
|
297
|
+
}
|
|
298
|
+
catch (e) {
|
|
299
|
+
ctx.workflowBusyByScope.delete(scopeKey);
|
|
300
|
+
throw e;
|
|
301
|
+
}
|
|
302
|
+
ctx.workflowBusyByScope.set(scopeKey, workflowSession.id);
|
|
303
|
+
// 发起会话在工作流运行期间视为占用:普通消息 409;控制端点据此路由到工作流 run。
|
|
304
|
+
ctx.workflowBusyBySession.set(sessionId, workflowSession.id);
|
|
305
|
+
const releaseWorkflowBusy = () => {
|
|
306
|
+
if (ctx.workflowBusyByScope.get(scopeKey) === workflowSession.id) {
|
|
307
|
+
ctx.workflowBusyByScope.delete(scopeKey);
|
|
308
|
+
}
|
|
309
|
+
if (ctx.workflowBusyBySession.get(sessionId) === workflowSession.id) {
|
|
310
|
+
ctx.workflowBusyBySession.delete(sessionId);
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
if (!ctx.agentManager.tryAcquire(workflowSession.id)) {
|
|
314
|
+
releaseWorkflowBusy();
|
|
315
|
+
return apiError(c, 409, 'RUN_ACTIVE', '该工作流会话已有进行中的执行');
|
|
316
|
+
}
|
|
317
|
+
// 持久化 run 状态:运行中 → completed。服务崩溃时停留在 running,
|
|
318
|
+
// 重启后 /:id/status 据此识别为 interrupted(此前工作流 run 不写 lastRun)。
|
|
319
|
+
const startedAtMs = Date.now();
|
|
320
|
+
await updateSessionLastRun(ctx.db, workflowSession.id, {
|
|
321
|
+
status: 'running',
|
|
322
|
+
startedAt: startedAtMs,
|
|
323
|
+
}).catch(() => { });
|
|
324
|
+
let handedOff = false;
|
|
325
|
+
try {
|
|
326
|
+
const response = streamSSE(c, async (stream) => {
|
|
327
|
+
const stopHeartbeat = startHeartbeat(stream);
|
|
328
|
+
try {
|
|
329
|
+
const permissionChecker = createInteractivePermissionChecker(ctx.permissionStore, {
|
|
330
|
+
getMode: () => ctx.sessionPermissionModes.get(sessionId) ??
|
|
331
|
+
sessionDefaultMode ??
|
|
332
|
+
ctx.permissionMode,
|
|
333
|
+
alwaysAllow: () => ctx.sessionAlwaysAllow.get(sessionId) ?? [],
|
|
334
|
+
// 子 agent 的 ask 请求统一归属到用户所在的会话(而非各自子会话),
|
|
335
|
+
// 刷新/切换页面后按当前会话即可重挂挂起弹窗。
|
|
336
|
+
reportSessionId: sessionId,
|
|
337
|
+
onPermissionRequired: async (req) => {
|
|
338
|
+
await stream.writeSSE({
|
|
339
|
+
event: 'permission_required',
|
|
340
|
+
data: JSON.stringify({ _tag: 'permission_required', ...req }),
|
|
341
|
+
});
|
|
342
|
+
},
|
|
343
|
+
onPermissionTimeout: (req) => {
|
|
344
|
+
stream
|
|
345
|
+
.writeSSE({
|
|
346
|
+
event: 'permission_timeout',
|
|
347
|
+
data: JSON.stringify({
|
|
348
|
+
_tag: 'permission_timeout',
|
|
349
|
+
...req,
|
|
350
|
+
timeoutAction: permissionTimeoutAction,
|
|
351
|
+
}),
|
|
352
|
+
})
|
|
353
|
+
.catch(() => { });
|
|
354
|
+
},
|
|
355
|
+
onPermissionExpired: (req) => {
|
|
356
|
+
if (permissionTimeoutAction === 'pause') {
|
|
357
|
+
// P1 级联暂停:与主 chat 通道 pauseSessionRun 同口径——
|
|
358
|
+
// 仅暂停父 run 会让子 agent 在用户缺席时继续执行。
|
|
359
|
+
ctx.agentManager.pause(workflowSession.id, PERMISSION_TIMEOUT_PAUSE_REASON);
|
|
360
|
+
for (const child of ctx.agentManager.children(workflowSession.id)) {
|
|
361
|
+
ctx.agentManager.pause(child.sessionId, PERMISSION_TIMEOUT_PAUSE_REASON);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
stream
|
|
365
|
+
.writeSSE({
|
|
366
|
+
event: 'permission_expired',
|
|
367
|
+
data: JSON.stringify({
|
|
368
|
+
_tag: 'permission_expired',
|
|
369
|
+
...req,
|
|
370
|
+
timeoutAction: permissionTimeoutAction,
|
|
371
|
+
}),
|
|
372
|
+
})
|
|
373
|
+
.catch(() => { });
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
const deps = {
|
|
377
|
+
db: ctx.db,
|
|
378
|
+
llmRegistry: sessionRegistry,
|
|
379
|
+
toolRegistry: ctx.toolRegistry,
|
|
380
|
+
urlRegistry: ctx.urlRegistry,
|
|
381
|
+
hookRunner: ctx.hookRunner,
|
|
382
|
+
permission: permissionChecker,
|
|
383
|
+
config: sessionConfig,
|
|
384
|
+
cwd,
|
|
385
|
+
agentRegistry: ctx.agentRegistry,
|
|
386
|
+
registerChildRun: childRunBridge(ctx.agentManager),
|
|
387
|
+
// 预算护栏:与主 chat 路由同口径(金额/token 任一轴 pause/abort 即启用)。
|
|
388
|
+
...(sessionConfig.usage?.budgetAction === 'pause' ||
|
|
389
|
+
sessionConfig.usage?.budgetAction === 'abort' ||
|
|
390
|
+
sessionConfig.usage?.tokenBudgetAction === 'pause' ||
|
|
391
|
+
sessionConfig.usage?.tokenBudgetAction === 'abort'
|
|
392
|
+
? { budgetPause: true }
|
|
393
|
+
: {}),
|
|
394
|
+
};
|
|
395
|
+
// P2-5:runner 的工具集不再恒 []——传配置解析结果作为子 agent
|
|
396
|
+
// 工具集的交集基准(与主 chat/CLI print 同口径)。config.tools.enabled
|
|
397
|
+
// 的 fail-closed 语义对工作流派发的子 agent 成立(全禁 → 交集为空)。
|
|
398
|
+
// runner 自身不进 agentLoop,工具名单不产生任何 LLM 调用影响。
|
|
399
|
+
const agentConfig = {
|
|
400
|
+
provider: sessionConfig.defaultProvider,
|
|
401
|
+
model: sessionConfig.defaultModel,
|
|
402
|
+
tools: resolveEnabledToolNames(ctx.toolRegistry, sessionConfig).filter((n) => Boolean(session.projectId) || !PROJECT_BOUND_TOOLS.has(n)),
|
|
403
|
+
plugins: sessionConfig.plugins.enabled,
|
|
404
|
+
agentName: 'default',
|
|
405
|
+
};
|
|
406
|
+
const parent = await createAgent(workflowSession, agentConfig, deps);
|
|
407
|
+
ctx.agentManager.register({ sessionId: workflowSession.id, state: parent, deps });
|
|
408
|
+
stream.onAbort(() => {
|
|
409
|
+
ctx.agentManager.abort(workflowSession.id);
|
|
410
|
+
});
|
|
411
|
+
const result = await executeWorkflow({
|
|
412
|
+
registry,
|
|
413
|
+
name,
|
|
414
|
+
entry,
|
|
415
|
+
args: wfArgs,
|
|
416
|
+
deps,
|
|
417
|
+
parent,
|
|
418
|
+
onProgress: async (progressMessage, detail) => {
|
|
419
|
+
await stream.writeSSE({
|
|
420
|
+
event: 'progress',
|
|
421
|
+
data: JSON.stringify({ _tag: 'progress', message: progressMessage, detail }),
|
|
422
|
+
});
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
if (result._tag === 'error') {
|
|
426
|
+
await stream.writeSSE({
|
|
427
|
+
event: 'error',
|
|
428
|
+
data: JSON.stringify({
|
|
429
|
+
_tag: 'error',
|
|
430
|
+
error: { _tag: 'unexpected', message: result.message },
|
|
431
|
+
}),
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
// executeWorkflow 只产出 text/error(success/compact 是斜杠命令泛型变体,不会到达)
|
|
436
|
+
const text = result._tag === 'text'
|
|
437
|
+
? result.text
|
|
438
|
+
: result._tag === 'success'
|
|
439
|
+
? result.message
|
|
440
|
+
: '';
|
|
441
|
+
await stream.writeSSE({
|
|
442
|
+
event: 'text_delta',
|
|
443
|
+
data: JSON.stringify({ _tag: 'text_delta', text }),
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
await stream.writeSSE({ event: 'done', data: JSON.stringify({ _tag: 'done' }) });
|
|
447
|
+
}
|
|
448
|
+
catch (e) {
|
|
449
|
+
await stream
|
|
450
|
+
.writeSSE({
|
|
451
|
+
event: 'error',
|
|
452
|
+
data: JSON.stringify({
|
|
453
|
+
_tag: 'error',
|
|
454
|
+
error: {
|
|
455
|
+
_tag: 'unexpected',
|
|
456
|
+
message: e instanceof Error ? e.message : String(e),
|
|
457
|
+
},
|
|
458
|
+
}),
|
|
459
|
+
})
|
|
460
|
+
.catch(() => { });
|
|
461
|
+
await stream
|
|
462
|
+
.writeSSE({ event: 'done', data: JSON.stringify({ _tag: 'done' }) })
|
|
463
|
+
.catch(() => { });
|
|
464
|
+
}
|
|
465
|
+
finally {
|
|
466
|
+
stopHeartbeat();
|
|
467
|
+
ctx.agentManager.unregister(workflowSession.id);
|
|
468
|
+
void updateSessionLastRun(ctx.db, workflowSession.id, {
|
|
469
|
+
status: 'completed',
|
|
470
|
+
startedAt: startedAtMs,
|
|
471
|
+
}).catch(() => { });
|
|
472
|
+
releaseWorkflowBusy();
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
handedOff = true;
|
|
476
|
+
return response;
|
|
477
|
+
}
|
|
478
|
+
finally {
|
|
479
|
+
if (!handedOff) {
|
|
480
|
+
ctx.agentManager.unregister(workflowSession.id);
|
|
481
|
+
releaseWorkflowBusy();
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
// P1-1:会话项目注册表——项目级配置含 providers 时,按该项目合并配置
|
|
486
|
+
// 构建/复用 LLM 注册表(否则项目配置的 provider 永远 NoRoute)。
|
|
487
|
+
// 服务启动目录项目直接复用服务级注册表。
|
|
488
|
+
let sessionRegistry = ctx.llmRegistry;
|
|
489
|
+
if (sessionProjectScope && session.projectId) {
|
|
490
|
+
const pid = session.projectId;
|
|
491
|
+
const cached = ctx.projectRegistries?.get(pid);
|
|
492
|
+
if (cached) {
|
|
493
|
+
sessionRegistry = cached;
|
|
494
|
+
}
|
|
495
|
+
else if (sessionConfig.providers.length > 0) {
|
|
496
|
+
sessionRegistry = buildRegistryFromConfig(sessionConfig);
|
|
497
|
+
ctx.projectRegistries?.set(pid, sessionRegistry);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
// 斜杠命令拦截(纯图片消息无文本,跳过命令解析)
|
|
501
|
+
const parsed = parseSlashInput(message ?? '');
|
|
80
502
|
if (parsed) {
|
|
81
503
|
const registry = createSlashRegistry();
|
|
82
504
|
const cmd = registry.get(parsed.name);
|
|
83
505
|
if (cmd) {
|
|
506
|
+
// P3:变更型命令(清空/压缩/分支直接改写消息树)与活跃 run 的并发
|
|
507
|
+
// 写入存在竞态——斜杠拦截早于 tryAcquire,须在此显式拒绝。
|
|
508
|
+
// 守卫口径统一为 hasBusySession(get + isStarting + workflowBusy)。
|
|
509
|
+
const MUTATING_SLASH = new Set(['compact', 'clear', 'fork']);
|
|
510
|
+
if (MUTATING_SLASH.has(parsed.name) && hasBusySession(ctx, sessionId)) {
|
|
511
|
+
return apiError(c, 409, 'RUN_ACTIVE', `该会话已有进行中的对话,请等待完成或中止后再执行 /${parsed.name}`);
|
|
512
|
+
}
|
|
84
513
|
// P2-4:执行 config.slashCommands.enabled 过滤(此前该配置无任何消费方)。
|
|
85
|
-
// enabled 为空 =
|
|
86
|
-
|
|
87
|
-
const enabledSet = new Set(enabledList.map((n) => (n.startsWith('/') ? n.slice(1) : n)));
|
|
88
|
-
if (enabledSet.size > 0 && !enabledSet.has(parsed.name)) {
|
|
514
|
+
// enabled 为空 = 全部禁用(fail-closed);含 ['*'] = 全部启用;名称兼容带/不带前缀斜杠。
|
|
515
|
+
if (!isSlashCommandEnabled(sessionConfig.slashCommands?.enabled, parsed.name)) {
|
|
89
516
|
return streamSSE(c, async (stream) => {
|
|
90
517
|
await stream.writeSSE({
|
|
91
518
|
event: 'text_delta',
|
|
@@ -97,40 +524,111 @@ function createChatRoute(ctx) {
|
|
|
97
524
|
await stream.writeSSE({ event: 'done', data: JSON.stringify({ _tag: 'done' }) });
|
|
98
525
|
});
|
|
99
526
|
}
|
|
527
|
+
// /workflow run 走专用通道(预算/URL 注册表/agentManager 生命周期与 REST 同口径);
|
|
528
|
+
// 其余 workflow 子命令(list/show/create/edit)继续走通用 commandCtx。
|
|
529
|
+
if (parsed.name === 'workflow' && /^run(?:\s|$)/.test(parsed.args.trim())) {
|
|
530
|
+
return runWorkflowSlash(c, parsed.args.trim());
|
|
531
|
+
}
|
|
100
532
|
const commandCtx = {
|
|
101
533
|
cwd,
|
|
102
|
-
config:
|
|
534
|
+
config: sessionConfig,
|
|
103
535
|
// 当前会话 id:/clear /fork 等命令默认作用于当前会话(P2-4)。
|
|
104
536
|
sessionId,
|
|
537
|
+
// 消费渠道:/model 等命令按渠道给指引(Web 有底部模型选择器)。
|
|
538
|
+
channel: 'web',
|
|
105
539
|
// 内置斜杠命令(/clear、/fork、/config)仅需 db + config;
|
|
106
540
|
// 但 /workflow run 会走 executeWorkflow → buildWorkflowContext → runSubAgent,
|
|
107
541
|
// 该路径需要 agentRegistry 来派生子 agent,因此必须注入。
|
|
108
|
-
// permission/toolRegistry 用 autoAllow 凑齐类型(子命令不触发交互权限)。
|
|
109
542
|
workflowRegistry: ctx.workflowRegistry,
|
|
110
543
|
deps: {
|
|
111
544
|
db: ctx.db,
|
|
112
|
-
config:
|
|
545
|
+
config: sessionConfig,
|
|
113
546
|
cwd,
|
|
547
|
+
// P1 工作流权限:占位 checker 在 streamSSE 回调内被替换为交互式
|
|
548
|
+
// checker——/workflow run 的子 agent 才能对 ask 工具弹窗确认。
|
|
114
549
|
permission: autoAllowChecker,
|
|
115
550
|
toolRegistry: ctx.toolRegistry,
|
|
116
|
-
llmRegistry:
|
|
551
|
+
llmRegistry: sessionRegistry,
|
|
552
|
+
urlRegistry: ctx.urlRegistry,
|
|
553
|
+
hookRunner: ctx.hookRunner,
|
|
117
554
|
agentRegistry: ctx.agentRegistry,
|
|
555
|
+
registerChildRun: childRunBridge(ctx.agentManager),
|
|
556
|
+
// 预算护栏:/compact 的压缩 LLM 调用与子 agent 同样受护栏约束。
|
|
557
|
+
...(sessionConfig.usage?.budgetAction === 'pause' ||
|
|
558
|
+
sessionConfig.usage?.budgetAction === 'abort' ||
|
|
559
|
+
sessionConfig.usage?.tokenBudgetAction === 'pause' ||
|
|
560
|
+
sessionConfig.usage?.tokenBudgetAction === 'abort'
|
|
561
|
+
? { budgetPause: true }
|
|
562
|
+
: {}),
|
|
118
563
|
},
|
|
119
564
|
};
|
|
120
565
|
return streamSSE(c, async (stream) => {
|
|
566
|
+
const stopHeartbeat = startHeartbeat(stream);
|
|
121
567
|
try {
|
|
122
|
-
|
|
568
|
+
// P1 工作流权限:子命令(尤其 /workflow run)的子 agent 继承此 checker。
|
|
569
|
+
// 此前用 autoAllowChecker:ask 工具返回 permission_required 结果但无
|
|
570
|
+
// 确认通道(confirm 是 no-op),写/执行工具在工作流里静默不可用。
|
|
571
|
+
// 现在与主 agent 一致:permission_required 经 SSE 弹窗,用户确认后
|
|
572
|
+
// 经 /api/tools/confirm 消费全局 store。
|
|
573
|
+
const permissionChecker = createInteractivePermissionChecker(ctx.permissionStore, {
|
|
574
|
+
getMode: () => ctx.sessionPermissionModes.get(sessionId) ??
|
|
575
|
+
sessionDefaultMode ??
|
|
576
|
+
ctx.permissionMode,
|
|
577
|
+
alwaysAllow: () => ctx.sessionAlwaysAllow.get(sessionId) ?? [],
|
|
578
|
+
// 子 agent 的 ask 请求统一归属到用户所在的会话(而非各自子会话),
|
|
579
|
+
// 刷新/切换页面后按当前会话即可重挂挂起弹窗。
|
|
580
|
+
reportSessionId: sessionId,
|
|
581
|
+
onPermissionRequired: async (req) => {
|
|
582
|
+
await stream.writeSSE({
|
|
583
|
+
event: 'permission_required',
|
|
584
|
+
data: JSON.stringify({ _tag: 'permission_required', ...req }),
|
|
585
|
+
});
|
|
586
|
+
},
|
|
587
|
+
onPermissionTimeout: (req) => {
|
|
588
|
+
stream
|
|
589
|
+
.writeSSE({
|
|
590
|
+
event: 'permission_timeout',
|
|
591
|
+
data: JSON.stringify({
|
|
592
|
+
_tag: 'permission_timeout',
|
|
593
|
+
...req,
|
|
594
|
+
timeoutAction: permissionTimeoutAction,
|
|
595
|
+
}),
|
|
596
|
+
})
|
|
597
|
+
.catch(() => { });
|
|
598
|
+
},
|
|
599
|
+
onPermissionExpired: (req) => {
|
|
600
|
+
// P1-1:timeoutAction='pause' 时拒绝并暂停(主 run + 子 agent),
|
|
601
|
+
// 不让 agent 在用户缺席时继续自主推进;'deny' 时仅拒绝、run 继续。
|
|
602
|
+
if (permissionTimeoutAction === 'pause')
|
|
603
|
+
pauseSessionRun(PERMISSION_TIMEOUT_PAUSE_REASON);
|
|
604
|
+
stream
|
|
605
|
+
.writeSSE({
|
|
606
|
+
event: 'permission_expired',
|
|
607
|
+
data: JSON.stringify({
|
|
608
|
+
_tag: 'permission_expired',
|
|
609
|
+
...req,
|
|
610
|
+
timeoutAction: permissionTimeoutAction,
|
|
611
|
+
}),
|
|
612
|
+
})
|
|
613
|
+
.catch(() => { });
|
|
614
|
+
},
|
|
615
|
+
});
|
|
616
|
+
const streamCommandCtx = {
|
|
617
|
+
...commandCtx,
|
|
618
|
+
deps: { ...commandCtx.deps, permission: permissionChecker },
|
|
619
|
+
};
|
|
620
|
+
const result = await cmd.execute(parsed.args, streamCommandCtx);
|
|
123
621
|
if (result._tag === 'compact') {
|
|
124
622
|
// /compact:手动触发上下文压缩。复用 loop.compactContext(createSummarizer +
|
|
125
623
|
// runCompaction),不创建主 agent、不进入 LLM turn 循环(不把 /compact 当作
|
|
126
624
|
// user 消息发给模型)。
|
|
127
|
-
const provider = body.provider ??
|
|
128
|
-
const model = body.model ??
|
|
625
|
+
const provider = body.provider ?? sessionConfig.defaultProvider;
|
|
626
|
+
const model = body.model ?? sessionConfig.defaultModel;
|
|
129
627
|
const agentConfig = {
|
|
130
628
|
provider,
|
|
131
629
|
model,
|
|
132
630
|
tools: [],
|
|
133
|
-
plugins:
|
|
631
|
+
plugins: sessionConfig.plugins.enabled,
|
|
134
632
|
agentName: 'default',
|
|
135
633
|
};
|
|
136
634
|
const compactState = await createAgent(session, agentConfig, commandCtx.deps);
|
|
@@ -183,13 +681,16 @@ function createChatRoute(ctx) {
|
|
|
183
681
|
});
|
|
184
682
|
await stream.writeSSE({ event: 'done', data: JSON.stringify({ _tag: 'done' }) });
|
|
185
683
|
}
|
|
684
|
+
finally {
|
|
685
|
+
stopHeartbeat();
|
|
686
|
+
}
|
|
186
687
|
});
|
|
187
688
|
}
|
|
188
689
|
// 未知斜杠命令:回退为正常消息发给 agent
|
|
189
690
|
}
|
|
190
|
-
// 构建多模态 user content
|
|
191
|
-
|
|
192
|
-
const
|
|
691
|
+
// 构建多模态 user content:文本在前(可为空串/缺省,纯图片消息),
|
|
692
|
+
// images(dataURL base64)在后。文本为空时跳过 text part,避免把空文本发给模型。
|
|
693
|
+
const userContent = message ? [{ _tag: 'text', text: message }] : [];
|
|
193
694
|
if (images?.length) {
|
|
194
695
|
for (const img of images) {
|
|
195
696
|
userContent.push({ _tag: 'image', mediaType: img.mediaType, data: img.data });
|
|
@@ -201,12 +702,21 @@ function createChatRoute(ctx) {
|
|
|
201
702
|
const valid = mentionedAgents
|
|
202
703
|
.map((n) => ctx.agentRegistry.get(n))
|
|
203
704
|
.filter((d) => Boolean(d && d.mode !== 'primary'));
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
}
|
|
705
|
+
// P3-10 修复:部分无效也显式 400——此前仅全无效报错,拼错 1 个时该提及
|
|
706
|
+
// 被静默丢弃、用户以为已派发。列出全部无效名,供用户修正后重发。
|
|
707
|
+
const validNames = new Set(valid.map((d) => d.name));
|
|
708
|
+
const invalid = mentionedAgents.filter((n) => !validNames.has(n));
|
|
709
|
+
if (invalid.length > 0) {
|
|
710
|
+
return apiError(c, 400, 'INVALID_AGENT_MENTION', `未知或非 subagent 类型的 agent 提及:${invalid.join(', ')}`);
|
|
711
|
+
}
|
|
712
|
+
const names = valid.map((d) => d.name).join(', ');
|
|
713
|
+
const first = userContent[0];
|
|
714
|
+
if (first && first._tag === 'text') {
|
|
715
|
+
first.text = `[User requested subagent(s): ${names}]\n\n${first.text}`;
|
|
716
|
+
}
|
|
717
|
+
else {
|
|
718
|
+
// 纯图片消息:前置文本 part 承载 subagent 指令,避免指令静默丢失
|
|
719
|
+
userContent.unshift({ _tag: 'text', text: `[User requested subagent(s): ${names}]` });
|
|
210
720
|
}
|
|
211
721
|
}
|
|
212
722
|
// @文件上下文:读取文件内容写入快照,后续 getSessionContext→injectSnapshots 自动注入。
|
|
@@ -232,26 +742,46 @@ function createChatRoute(ctx) {
|
|
|
232
742
|
}
|
|
233
743
|
}
|
|
234
744
|
}
|
|
235
|
-
|
|
236
|
-
|
|
745
|
+
// P1-1:默认 provider/model 按会话项目配置解析(此前用服务启动目录配置,
|
|
746
|
+
// 其他项目的 defaultProvider 设置被静默忽略)。
|
|
747
|
+
const provider = body.provider ?? sessionConfig.defaultProvider;
|
|
748
|
+
const model = body.model ?? sessionConfig.defaultModel;
|
|
237
749
|
// P0-1 首次运行引导:provider 未配置/未注册时立即返回可操作错误,
|
|
238
750
|
// 不让错误延迟到 SSE 流内以英文 NoRoute 冒出。前端据此引导用户去设置页。
|
|
239
751
|
// 注:测试注入 ctx.chatStream 时跳过——测试 registry 空壳不代表生产未配置。
|
|
752
|
+
// P2-4:区分两种失败——完全没有配置 provider vs 配置了但名字不匹配。
|
|
240
753
|
if (!ctx.chatStream) {
|
|
241
754
|
try {
|
|
242
|
-
resolveRoute(
|
|
755
|
+
resolveRoute(sessionRegistry, provider, model);
|
|
243
756
|
}
|
|
244
757
|
catch {
|
|
245
|
-
const configured =
|
|
758
|
+
const configured = sessionConfig.providers
|
|
246
759
|
.map((p) => p.name)
|
|
247
760
|
.filter(Boolean)
|
|
248
761
|
.join(', ');
|
|
249
|
-
|
|
762
|
+
const hasAny = sessionConfig.providers.length > 0;
|
|
763
|
+
return apiError(c, 400, hasAny ? 'PROVIDER_NOT_FOUND' : 'NO_PROVIDER_CONFIGURED', hasAny
|
|
764
|
+
? `未找到名为「${provider}」的 AI 服务。已配置:${configured}。` +
|
|
765
|
+
'请检查拼写,或前往「设置 → Provider」添加该服务。'
|
|
766
|
+
: `未配置可用的 AI 服务(provider: ${provider})。请前往「设置 → Provider」添加 API 服务并测试连接`, {
|
|
250
767
|
provider,
|
|
251
|
-
configuredProviders:
|
|
768
|
+
configuredProviders: sessionConfig.providers.map((p) => p.name).filter(Boolean),
|
|
252
769
|
...(configured ? { hint: `已配置:${configured}` } : {}),
|
|
253
770
|
});
|
|
254
771
|
}
|
|
772
|
+
// P2-4:provider 显式声明了模型清单但请求模型不在其中 → 大概率拼写错误。
|
|
773
|
+
// 未声明清单的 provider(自建网关等)接受任意模型名,不做校验。
|
|
774
|
+
const providerDef = sessionConfig.providers.find((p) => (p.name || p._tag) === provider);
|
|
775
|
+
const modelNames = providerDef?.models ? Object.keys(providerDef.models) : undefined;
|
|
776
|
+
if (modelNames && modelNames.length > 0 && !modelNames.includes(model)) {
|
|
777
|
+
return apiError(c, 400, 'MODEL_NOT_FOUND', `provider「${provider}」没有名为「${model}」的模型。已配置模型:${modelNames.join(', ')}`, { provider, model, knownModels: modelNames });
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
// 对称并发守卫:该会话的工作流运行期间不允许发起普通对话(与 /workflow run
|
|
781
|
+
// 侧「主 run 活跃时拒绝工作流」互斥);工作流进度在其运行会话中可见。
|
|
782
|
+
// 控制端点(abort/pause/resume)经 workflowBusyBySession 路由到工作流 run。
|
|
783
|
+
if (ctx.workflowBusyBySession.has(sessionId)) {
|
|
784
|
+
return apiError(c, 409, 'RUN_ACTIVE', '该会话有进行中的工作流执行(进度见工作流运行会话),请等待完成或中止后重试');
|
|
255
785
|
}
|
|
256
786
|
// 并发守卫(P0-4):同步原子占位 tryAcquire(Map has+set 一气呵成、无 await)。
|
|
257
787
|
// 旧实现先 get 检查、真正 register 在 SSE 回调内,两者之间隔多个 await
|
|
@@ -267,17 +797,23 @@ function createChatRoute(ctx) {
|
|
|
267
797
|
// (其 finally 负责幂等释放),移交前的全部路径在 finally 统一兜底释放。
|
|
268
798
|
let handedOff = false;
|
|
269
799
|
try {
|
|
270
|
-
// 工具解析(P1-1):config.tools.enabled
|
|
271
|
-
// disabled 已在 registry
|
|
272
|
-
|
|
800
|
+
// 工具解析(P1-1):config.tools.enabled 含 '*' = 全部注册工具;空 = 无工具(fail-closed);
|
|
801
|
+
// 非空名单 = 默认集。disabled 已在 registry 层过滤,此处兜底。
|
|
802
|
+
// P1 多项目:按会话项目配置解析(此前用启动目录配置)。
|
|
803
|
+
// 无项目会话(projectId null/undefined)剔除项目绑定工具(kanban 等)
|
|
804
|
+
const sessionHasProject = Boolean(session.projectId);
|
|
805
|
+
const tools = resolveEnabledToolNames(ctx.toolRegistry, sessionConfig, body.tools).filter((n) => sessionHasProject || !PROJECT_BOUND_TOOLS.has(n));
|
|
273
806
|
// primary agent 解析(spec: agent-frontend-switching §4.3)
|
|
274
807
|
const agentName = body.agent ?? 'default';
|
|
275
808
|
const agentDef = ctx.agentRegistry.get(agentName);
|
|
276
809
|
if (!agentDef || agentDef.mode === 'subagent') {
|
|
277
810
|
return apiError(c, 400, 'INVALID_AGENT', `Unknown or non-primary agent: ${agentName}`);
|
|
278
811
|
}
|
|
279
|
-
// agent def 覆盖:tools(plan 限只读)、model
|
|
280
|
-
|
|
812
|
+
// agent def 覆盖:tools(plan 限只读)、model(可选)。
|
|
813
|
+
// P1 修复:def.tools 与配置解析结果取交集——此前完全覆盖会绕过
|
|
814
|
+
// config.tools.enabled 的 fail-closed 语义(禁用 bash 后切到 plan agent
|
|
815
|
+
// 又静默恢复)。def 未声明 tools 时原样沿用配置解析结果。
|
|
816
|
+
const resolvedTools = agentDef.tools ? agentDef.tools.filter((t) => tools.includes(t)) : tools;
|
|
281
817
|
const resolvedModel = agentDef.model ?? model;
|
|
282
818
|
// 分段预检:切换 provider/model/tools 将开新段(前缀失效→缓存 miss),
|
|
283
819
|
// 需用户显式确认(confirmSegmentBreak)。首轮无活跃段时跳过。
|
|
@@ -294,7 +830,7 @@ function createChatRoute(ctx) {
|
|
|
294
830
|
const agentDiffer = (active.agentName ?? 'default') !== agentName;
|
|
295
831
|
const confirmed = body.confirmSegmentBreak === true;
|
|
296
832
|
if ((modelDiffer || toolsDiffer || agentDiffer) && !confirmed) {
|
|
297
|
-
return apiError(c, 409, 'SEGMENT_BREAK_REQUIRED', '
|
|
833
|
+
return apiError(c, 409, 'SEGMENT_BREAK_REQUIRED', '切换模型/工具将开始新的上下文段:此前的对话内容需要重新读取,需用户确认', {
|
|
298
834
|
activeSegment: {
|
|
299
835
|
provider: active.provider,
|
|
300
836
|
model: active.model,
|
|
@@ -307,25 +843,57 @@ function createChatRoute(ctx) {
|
|
|
307
843
|
// P0-4:回调从首行起全部纳入 try/finally——createAgent、updateSessionLastRun
|
|
308
844
|
// 等启动阶段失败同样必须释放占位并补发 error/done(hono 的 stream 包装器
|
|
309
845
|
// 只 console.error 不善后),不得泄漏占位或让前端卡在 streaming 态。
|
|
846
|
+
// P0 心跳:长工具执行/权限确认等待期间无业务帧,心跳防前端 90s 看门狗误杀。
|
|
847
|
+
const stopHeartbeat = startHeartbeat(stream);
|
|
310
848
|
let doneSent = false;
|
|
311
849
|
let runStartedAt;
|
|
312
850
|
try {
|
|
851
|
+
// M3:上轮 run 未正常结束(崩溃/重启/热更新交接,lastRun 仍为
|
|
852
|
+
// running/paused)→ 把最后一条 user 消息之后的半截条目标记为
|
|
853
|
+
// 「未完成轮次」:构建上下文时剔除、时间线置灰。必须先于
|
|
854
|
+
// runAgent(其内部 append 新 user 消息)执行,保证边界判定正确。
|
|
855
|
+
await markUnfinishedTurn(ctx.db, sessionId).catch(() => { });
|
|
313
856
|
// 权限检查器:ask 权限通过 SSE 通知前端,阻塞等待确认。
|
|
314
|
-
//
|
|
857
|
+
// 模式优先级:会话级覆盖(PUT /:sessionId)> 会话项目配置 defaultMode
|
|
858
|
+
// > 运行时全局模式(启动默认或 PUT / 覆盖)。
|
|
315
859
|
const permissionChecker = createInteractivePermissionChecker(ctx.permissionStore, {
|
|
316
|
-
getMode: () => ctx.sessionPermissionModes.get(sessionId) ?? ctx.permissionMode,
|
|
860
|
+
getMode: () => ctx.sessionPermissionModes.get(sessionId) ?? sessionDefaultMode ?? ctx.permissionMode,
|
|
861
|
+
alwaysAllow: () => ctx.sessionAlwaysAllow.get(sessionId) ?? [],
|
|
862
|
+
// 子 agent 的 ask 请求统一归属到用户所在的会话(而非各自子会话),
|
|
863
|
+
// 刷新/切换页面后按当前会话即可重挂挂起弹窗。
|
|
864
|
+
reportSessionId: sessionId,
|
|
317
865
|
onPermissionRequired: async (req) => {
|
|
318
866
|
await stream.writeSSE({
|
|
319
867
|
event: 'permission_required',
|
|
320
868
|
data: JSON.stringify({ _tag: 'permission_required', ...req }),
|
|
321
869
|
});
|
|
322
870
|
},
|
|
323
|
-
//
|
|
871
|
+
// P2-9:确认超时(5 分钟)仅通知前端(pending 保持,重开弹窗不重发消息)
|
|
324
872
|
onPermissionTimeout: (req) => {
|
|
325
873
|
stream
|
|
326
874
|
.writeSSE({
|
|
327
875
|
event: 'permission_timeout',
|
|
328
|
-
data: JSON.stringify({
|
|
876
|
+
data: JSON.stringify({
|
|
877
|
+
_tag: 'permission_timeout',
|
|
878
|
+
...req,
|
|
879
|
+
timeoutAction: permissionTimeoutAction,
|
|
880
|
+
}),
|
|
881
|
+
})
|
|
882
|
+
.catch(() => { });
|
|
883
|
+
},
|
|
884
|
+
// P0 双层超时:提示后仍无响应 → 兜底拒绝,SSE 通知前端清理弹窗状态
|
|
885
|
+
//(run 已继续,重开弹窗/拒绝按钮均失效,必须显式清理)。
|
|
886
|
+
onPermissionExpired: (req) => {
|
|
887
|
+
if (permissionTimeoutAction === 'pause')
|
|
888
|
+
pauseSessionRun(PERMISSION_TIMEOUT_PAUSE_REASON);
|
|
889
|
+
stream
|
|
890
|
+
.writeSSE({
|
|
891
|
+
event: 'permission_expired',
|
|
892
|
+
data: JSON.stringify({
|
|
893
|
+
_tag: 'permission_expired',
|
|
894
|
+
...req,
|
|
895
|
+
timeoutAction: permissionTimeoutAction,
|
|
896
|
+
}),
|
|
329
897
|
})
|
|
330
898
|
.catch(() => { });
|
|
331
899
|
},
|
|
@@ -334,25 +902,42 @@ function createChatRoute(ctx) {
|
|
|
334
902
|
// 构建 agent 依赖(注入测试用 chatStream)
|
|
335
903
|
const deps = {
|
|
336
904
|
db: ctx.db,
|
|
337
|
-
llmRegistry:
|
|
905
|
+
llmRegistry: sessionRegistry,
|
|
338
906
|
toolRegistry: ctx.toolRegistry,
|
|
339
907
|
urlRegistry: ctx.urlRegistry,
|
|
340
908
|
hookRunner: ctx.hookRunner,
|
|
341
909
|
permission: permissionChecker,
|
|
342
|
-
config:
|
|
910
|
+
config: sessionConfig,
|
|
343
911
|
agentRegistry: ctx.agentRegistry,
|
|
912
|
+
registerChildRun: childRunBridge(ctx.agentManager),
|
|
344
913
|
cwd,
|
|
914
|
+
// P3 成本护栏:会话项目配置任一轴动作为 'pause'/'abort' 时启用预算检查。
|
|
915
|
+
// 具体 pause/abort 由 budgetOverageParts 按超支轴配置动作判定(abort >
|
|
916
|
+
// pause),此处仅注入「启用」门禁——修 P1 双轴动作坍缩(此前 pause 分支
|
|
917
|
+
// 优先,金额=abort+token=pause 等混合配置被静默降级为暂停)。
|
|
918
|
+
...(sessionConfig.usage?.budgetAction === 'pause' ||
|
|
919
|
+
sessionConfig.usage?.budgetAction === 'abort' ||
|
|
920
|
+
sessionConfig.usage?.tokenBudgetAction === 'pause' ||
|
|
921
|
+
sessionConfig.usage?.tokenBudgetAction === 'abort'
|
|
922
|
+
? { budgetPause: true }
|
|
923
|
+
: {}),
|
|
345
924
|
...(ctx.chatStream ? { chatStream: ctx.chatStream } : {}),
|
|
346
925
|
};
|
|
347
926
|
const agentConfig = {
|
|
348
927
|
provider,
|
|
349
928
|
model: resolvedModel,
|
|
350
929
|
tools: resolvedTools,
|
|
351
|
-
plugins:
|
|
930
|
+
plugins: sessionConfig.plugins.enabled,
|
|
352
931
|
agentName,
|
|
353
932
|
...(agentDef.systemPrompt ? { agentRolePrompt: agentDef.systemPrompt } : {}),
|
|
354
933
|
};
|
|
355
934
|
const state = await createAgent(session, agentConfig, deps);
|
|
935
|
+
// P2-3:热更新/重启后 run 重建,内存 budgetPauseTriggered 丢失——从会话
|
|
936
|
+
// metadata 消费上次预算暂停标记还原(单次语义),避免用户已确认继续后
|
|
937
|
+
// 又被同一超支原因二次暂停。
|
|
938
|
+
const priorBudgetPause = await consumeBudgetPauseMarker(ctx.db, sessionId).catch(() => null);
|
|
939
|
+
if (priorBudgetPause)
|
|
940
|
+
state.budgetPauseTriggered = true;
|
|
356
941
|
// 持久化 run 状态:status='running' 写入 DB。服务重启后此字段仍为 'running'
|
|
357
942
|
// 而进程无活跃 run → 检测为 interrupted。finally 中标记 'completed'。
|
|
358
943
|
runStartedAt = Date.now();
|
|
@@ -368,7 +953,7 @@ function createChatRoute(ctx) {
|
|
|
368
953
|
ctx.agentManager.register({ sessionId, state, deps });
|
|
369
954
|
// workflowz 关键词检测:用户消息包含独立关键词时注入工作流通知(steering),
|
|
370
955
|
// 引导模型用 task 工具批量 fan-out 做确定性多子 agent 分解。
|
|
371
|
-
if (containsWorkflow(message)) {
|
|
956
|
+
if (message && containsWorkflow(message)) {
|
|
372
957
|
const wfList = ctx.workflowRegistry
|
|
373
958
|
? ctx.workflowRegistry.list().map((w) => ({
|
|
374
959
|
name: w.meta.name,
|
|
@@ -429,6 +1014,7 @@ function createChatRoute(ctx) {
|
|
|
429
1014
|
.catch(() => { });
|
|
430
1015
|
}
|
|
431
1016
|
finally {
|
|
1017
|
+
stopHeartbeat();
|
|
432
1018
|
// 先从 agentManager 释放(占位与活跃 run 均幂等):必须最先执行,确保
|
|
433
1019
|
// 客户端断开使 writeSSE reject 时 agent 状态(state/deps/AbortController)
|
|
434
1020
|
// 不会泄漏到 Map(unregister 仅做 Map.delete、不依赖 state,放最前安全)。
|
|
@@ -474,20 +1060,67 @@ function createChatRoute(ctx) {
|
|
|
474
1060
|
: undefined;
|
|
475
1061
|
app.post('/abort', async (c) => {
|
|
476
1062
|
const { sessionId } = await c.req.json();
|
|
1063
|
+
// 工作流运行期间发起会话无主 run:中止请求路由到实际的工作流 run。
|
|
1064
|
+
const busyWorkflowId = ctx.workflowBusyBySession.get(sessionId);
|
|
1065
|
+
if (busyWorkflowId) {
|
|
1066
|
+
return c.json({ aborted: ctx.agentManager.abort(busyWorkflowId) });
|
|
1067
|
+
}
|
|
477
1068
|
return runStarting(c, sessionId) ?? c.json({ aborted: ctx.agentManager.abort(sessionId) });
|
|
478
1069
|
});
|
|
479
1070
|
app.post('/pause', async (c) => {
|
|
480
1071
|
const { sessionId } = await c.req.json();
|
|
481
|
-
|
|
1072
|
+
// 级联子 run(P1:子 agent 现已注册,暂停主 run 必须一并暂停——
|
|
1073
|
+
// 否则后台子 agent 在用户认为「已暂停」时继续执行)。
|
|
1074
|
+
const busyWorkflowId = ctx.workflowBusyBySession.get(sessionId);
|
|
1075
|
+
const targetId = busyWorkflowId ?? sessionId;
|
|
1076
|
+
if (!busyWorkflowId && runStarting(c, sessionId))
|
|
1077
|
+
return runStarting(c, sessionId);
|
|
1078
|
+
const paused = ctx.agentManager.pause(targetId);
|
|
1079
|
+
for (const child of ctx.agentManager.children(targetId)) {
|
|
1080
|
+
ctx.agentManager.pause(child.sessionId);
|
|
1081
|
+
}
|
|
1082
|
+
return c.json({ paused });
|
|
482
1083
|
});
|
|
483
1084
|
app.post('/resume', async (c) => {
|
|
484
1085
|
const { sessionId } = await c.req.json();
|
|
485
|
-
|
|
1086
|
+
// 权限超时暂停工作流后,发起会话展示「恢复」按钮——路由到工作流 run 真正恢复。
|
|
1087
|
+
// 级联恢复子 run:暂停时子 run 一并暂停,仅恢复主 run 会让父的工具批次
|
|
1088
|
+
// 永远等待已暂停的子 agent(死锁)。
|
|
1089
|
+
const busyWorkflowId = ctx.workflowBusyBySession.get(sessionId);
|
|
1090
|
+
const targetId = busyWorkflowId ?? sessionId;
|
|
1091
|
+
if (!busyWorkflowId && runStarting(c, sessionId))
|
|
1092
|
+
return runStarting(c, sessionId);
|
|
1093
|
+
const resumed = ctx.agentManager.resume(targetId);
|
|
1094
|
+
for (const child of ctx.agentManager.children(targetId)) {
|
|
1095
|
+
ctx.agentManager.resume(child.sessionId);
|
|
1096
|
+
}
|
|
1097
|
+
return c.json({ resumed });
|
|
486
1098
|
});
|
|
487
1099
|
app.post('/steer', async (c) => {
|
|
488
1100
|
const body = await c.req.json();
|
|
489
|
-
|
|
490
|
-
|
|
1101
|
+
const starting = runStarting(c, body.sessionId);
|
|
1102
|
+
if (starting)
|
|
1103
|
+
return starting;
|
|
1104
|
+
const message = typeof body.message === 'string' ? body.message.trim() : '';
|
|
1105
|
+
if (!message)
|
|
1106
|
+
return c.json({ steered: false });
|
|
1107
|
+
// P1 工作流路由:工作流运行期间 steer 目标为实际的 workflow run
|
|
1108
|
+
//(此前 steer 恒 false、指令静默丢失,用户以为已生效)。
|
|
1109
|
+
const busyWorkflowId = ctx.workflowBusyBySession.get(body.sessionId);
|
|
1110
|
+
const steered = ctx.agentManager.steer(busyWorkflowId ?? body.sessionId, message);
|
|
1111
|
+
// P0 闭环:steering 持久化为会话条目——用户运行中输入追加指令曾是内存瞬态,
|
|
1112
|
+
// 刷新后彻底丢失;现在与消息同样入史(context 重建时作为 system 消息注入,
|
|
1113
|
+
// 时间线经 /messages 渲染为 user steering 块)。
|
|
1114
|
+
if (steered) {
|
|
1115
|
+
await insertEntry(ctx.db, {
|
|
1116
|
+
sessionId: body.sessionId,
|
|
1117
|
+
tag: 'steering',
|
|
1118
|
+
content: { text: message },
|
|
1119
|
+
}).catch(() => {
|
|
1120
|
+
// 持久化失败不阻塞本轮生效(指令已进入 run 队列)
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
return c.json({ steered });
|
|
491
1124
|
});
|
|
492
1125
|
return app;
|
|
493
1126
|
}
|