c0de-agent 1.8.0 → 1.10.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 +21 -7
- package/dist/cli/commands/acp.js +2 -0
- package/dist/cli/commands/chat.js +2 -0
- package/dist/cli/commands/config.js +8 -4
- package/dist/cli/commands/serve.d.ts +1 -0
- package/dist/cli/commands/serve.js +4 -1
- package/dist/cli/deps.d.ts +7 -5
- package/dist/cli/deps.js +23 -7
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +68 -37
- package/dist/cli/modes/print.d.ts +3 -1
- package/dist/cli/modes/print.js +21 -4
- package/dist/core/agent.js +3 -4
- package/dist/core/config.d.ts +18 -1
- package/dist/core/config.js +55 -4
- package/dist/core/loop/compaction.d.ts +31 -0
- package/dist/core/loop/compaction.js +137 -0
- package/dist/core/loop/persist.d.ts +11 -0
- package/dist/core/loop/persist.js +107 -0
- package/dist/core/loop/segment.d.ts +8 -0
- package/dist/core/loop/segment.js +72 -0
- package/dist/core/loop/stream-collect.d.ts +47 -0
- package/dist/core/loop/stream-collect.js +183 -0
- package/dist/core/loop/subagent.d.ts +12 -0
- package/dist/core/loop/subagent.js +171 -0
- package/dist/core/loop/todo.d.ts +5 -0
- package/dist/core/loop/todo.js +26 -0
- package/dist/core/loop.d.ts +2 -20
- package/dist/core/loop.js +13 -659
- package/dist/core/prompt-registry.d.ts +1 -1
- package/dist/core/prompt-registry.js +10 -0
- package/dist/core/slash.js +1 -1
- package/dist/core/todo-tags.d.ts +46 -0
- package/dist/core/todo-tags.js +192 -0
- package/dist/core/workflows/builtins.js +4 -4
- package/dist/core/workflows/discovery.d.ts +1 -1
- package/dist/core/workflows/discovery.js +4 -1
- package/dist/core/workflows/index.d.ts +1 -1
- package/dist/db/schema.d.ts +17 -0
- package/dist/db/schema.js +3 -1
- package/dist/llm/registry.d.ts +30 -3
- package/dist/llm/registry.js +25 -7
- package/dist/llm/retry.d.ts +4 -2
- package/dist/llm/retry.js +7 -6
- package/dist/llm/schema/errors.d.ts +13 -3
- package/dist/llm/schema/errors.js +35 -3
- package/dist/llm/transport.js +5 -1
- package/dist/project/resolve.js +2 -2
- package/dist/server/agent-manager.d.ts +15 -0
- package/dist/server/agent-manager.js +35 -11
- package/dist/server/app.js +2 -1
- package/dist/server/context.js +2 -0
- package/dist/server/middleware/cors.d.ts +11 -2
- package/dist/server/middleware/cors.js +15 -12
- package/dist/server/routes/chat.js +212 -145
- package/dist/server/routes/config.js +7 -3
- package/dist/server/routes/session.js +6 -2
- package/dist/server/routes/terminal.js +12 -0
- package/dist/server/routes/todo.js +5 -2
- package/dist/server/routes/update.d.ts +1 -0
- package/dist/server/routes/update.js +11 -4
- package/dist/server/routes/workflows.js +2 -2
- package/dist/server/server.d.ts +28 -3
- package/dist/server/server.js +127 -38
- package/dist/server/types.d.ts +9 -1
- package/dist/session/branch.d.ts +10 -2
- package/dist/session/branch.js +98 -29
- package/dist/session/compaction.js +1 -0
- package/dist/session/context.d.ts +3 -2
- package/dist/session/context.js +36 -3
- package/dist/session/snapshot.d.ts +9 -3
- package/dist/session/snapshot.js +45 -4
- package/dist/session/squash.js +32 -24
- package/dist/session/types.d.ts +2 -0
- package/dist/shared/types/agent.d.ts +11 -0
- package/dist/shared/types/config.d.ts +7 -6
- package/dist/shared/utils/path.d.ts +2 -3
- package/dist/shared/utils/path.js +20 -4
- package/dist/tools/builtin/todo.d.ts +7 -0
- package/dist/tools/builtin/todo.js +28 -20
- package/dist/tools/index.d.ts +14 -3
- package/dist/tools/index.js +36 -14
- package/dist/update/index.d.ts +1 -1
- package/dist/update/index.js +1 -1
- package/dist/update/ipc.d.ts +32 -5
- package/dist/update/ipc.js +53 -7
- package/dist/update/updater.d.ts +7 -3
- package/dist/update/updater.js +14 -6
- package/dist/update/version.d.ts +6 -1
- package/dist/update/version.js +18 -3
- package/drizzle/0005_snapshot_mtime.sql +1 -0
- package/drizzle/meta/0005_snapshot.json +855 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +1 -7
- package/dist/mcp/index.d.ts +0 -1
- package/dist/mcp/index.js +0 -3
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises';
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
2
|
import { Hono } from 'hono';
|
|
3
3
|
import { streamSSE } from 'hono/streaming';
|
|
4
4
|
import { createAgent, runAgent } from '../../core/agent.js';
|
|
@@ -9,8 +9,8 @@ import { buildWorkflowNotice, containsWorkflow } from '../../core/workflow.js';
|
|
|
9
9
|
import { getProject } from '../../project/project.js';
|
|
10
10
|
import { getLLMSegments, getSession, updateSessionLastRun } from '../../session/session.js';
|
|
11
11
|
import { upsertFileSnapshot } from '../../session/snapshot.js';
|
|
12
|
+
import { resolveEnabledToolNames } from '../../tools/index.js';
|
|
12
13
|
import { autoAllowChecker } from '../../tools/permission.js';
|
|
13
|
-
import { listTools } from '../../tools/registry.js';
|
|
14
14
|
import { apiError } from '../middleware/error.js';
|
|
15
15
|
import { createInteractivePermissionChecker } from '../permission/interactive.js';
|
|
16
16
|
import { safeResolve } from '../util/safe-path.js';
|
|
@@ -160,6 +160,7 @@ function createChatRoute(ctx) {
|
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
162
|
// @文件上下文:读取文件内容写入快照,后续 getSessionContext→injectSnapshots 自动注入。
|
|
163
|
+
// 快照记录文件 mtime——注入前比对磁盘,过期自动刷新(P1-5)。
|
|
163
164
|
// 路径越界或读取失败静默跳过,不阻塞主对话流。
|
|
164
165
|
const files = body.files;
|
|
165
166
|
if (files?.length) {
|
|
@@ -168,8 +169,13 @@ function createChatRoute(ctx) {
|
|
|
168
169
|
if (!resolved)
|
|
169
170
|
continue;
|
|
170
171
|
try {
|
|
172
|
+
// 先 stat 再 readFile(与 session/context.ts 刷新路径一致):若先读后
|
|
173
|
+
// stat,文件恰在两次调用间被写会存「旧内容 + 新 mtime」,注入前的
|
|
174
|
+
// mtime 相等判定使旧内容被永久钉住;先 stat 后读最坏存「新内容 +
|
|
175
|
+
// 旧 mtime」,下次注入仍会触发刷新,可自愈。
|
|
176
|
+
const st = await stat(resolved);
|
|
171
177
|
const content = await readFile(resolved, 'utf-8');
|
|
172
|
-
await upsertFileSnapshot(ctx.db, sessionId, p, content);
|
|
178
|
+
await upsertFileSnapshot(ctx.db, sessionId, p, content, st.mtimeMs);
|
|
173
179
|
}
|
|
174
180
|
catch {
|
|
175
181
|
// 文件读取失败静默跳过
|
|
@@ -178,169 +184,230 @@ function createChatRoute(ctx) {
|
|
|
178
184
|
}
|
|
179
185
|
const provider = body.provider ?? ctx.config.defaultProvider;
|
|
180
186
|
const model = body.model ?? ctx.config.defaultModel;
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
if (!agentDef || agentDef.mode === 'subagent') {
|
|
190
|
-
return apiError(c, 400, 'INVALID_AGENT', `Unknown or non-primary agent: ${agentName}`);
|
|
187
|
+
// 并发守卫(P0-4):同步原子占位 tryAcquire(Map has+set 一气呵成、无 await)。
|
|
188
|
+
// 旧实现先 get 检查、真正 register 在 SSE 回调内,两者之间隔多个 await
|
|
189
|
+
// (getLLMSegments、createAgent 等):双发 POST 在窗口内均通过守卫,后注册
|
|
190
|
+
// 覆盖前者(agentManager runs.set),且 A 结束时 unregister 把仍在跑的 B
|
|
191
|
+
// 删掉,abort/steer/pause 对 B 全部失效。占位失败说明该会话已被占用
|
|
192
|
+
// (活跃 run 或另一请求的占位),立即 409。
|
|
193
|
+
if (!ctx.agentManager.tryAcquire(sessionId)) {
|
|
194
|
+
return apiError(c, 409, 'RUN_ACTIVE', '该会话已有进行中的对话');
|
|
191
195
|
}
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
// 旧段 agentName undefined 视为 'default'
|
|
206
|
-
const agentDiffer = (active.agentName ?? 'default') !== agentName;
|
|
207
|
-
const confirmed = body.confirmSegmentBreak === true;
|
|
208
|
-
if ((modelDiffer || toolsDiffer || agentDiffer) && !confirmed) {
|
|
209
|
-
return apiError(c, 409, 'SEGMENT_BREAK_REQUIRED', '切换模型/工具将开始新的上下文段(缓存失效),需用户确认', {
|
|
210
|
-
activeSegment: {
|
|
211
|
-
provider: active.provider,
|
|
212
|
-
model: active.model,
|
|
213
|
-
tools: active.tools.map((t) => t.name),
|
|
214
|
-
},
|
|
215
|
-
});
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
return streamSSE(c, async (stream) => {
|
|
219
|
-
// 权限检查器:ask 权限通过 SSE 通知前端,阻塞等待确认
|
|
220
|
-
const permissionChecker = createInteractivePermissionChecker(ctx.permissionStore, {
|
|
221
|
-
getMode: () => ctx.permissionMode,
|
|
222
|
-
onPermissionRequired: async (req) => {
|
|
223
|
-
await stream.writeSSE({
|
|
224
|
-
event: 'permission_required',
|
|
225
|
-
data: JSON.stringify({ _tag: 'permission_required', ...req }),
|
|
226
|
-
});
|
|
227
|
-
},
|
|
228
|
-
});
|
|
229
|
-
// cwd 已在斜杠拦截前解析,此处直接复用
|
|
230
|
-
// 构建 agent 依赖(注入测试用 chatStream)
|
|
231
|
-
const deps = {
|
|
232
|
-
db: ctx.db,
|
|
233
|
-
llmRegistry: ctx.llmRegistry,
|
|
234
|
-
toolRegistry: ctx.toolRegistry,
|
|
235
|
-
urlRegistry: ctx.urlRegistry,
|
|
236
|
-
hookRunner: ctx.hookRunner,
|
|
237
|
-
permission: permissionChecker,
|
|
238
|
-
config: ctx.config,
|
|
239
|
-
agentRegistry: ctx.agentRegistry,
|
|
240
|
-
cwd,
|
|
241
|
-
...(ctx.chatStream ? { chatStream: ctx.chatStream } : {}),
|
|
242
|
-
};
|
|
243
|
-
const agentConfig = {
|
|
244
|
-
provider,
|
|
245
|
-
model: resolvedModel,
|
|
246
|
-
tools: resolvedTools,
|
|
247
|
-
plugins: ctx.config.plugins.enabled,
|
|
248
|
-
agentName,
|
|
249
|
-
...(agentDef.systemPrompt ? { agentRolePrompt: agentDef.systemPrompt } : {}),
|
|
250
|
-
};
|
|
251
|
-
const state = await createAgent(session, agentConfig, deps);
|
|
252
|
-
// 持久化 run 状态:status='running' 写入 DB。服务重启后此字段仍为 'running'
|
|
253
|
-
// 而进程无活跃 run → 检测为 interrupted。finally 中标记 'completed'。
|
|
254
|
-
const runStartedAt = Date.now();
|
|
255
|
-
await updateSessionLastRun(ctx.db, sessionId, {
|
|
256
|
-
status: 'running',
|
|
257
|
-
...(agentName !== 'default' ? { agentName } : {}),
|
|
258
|
-
provider,
|
|
259
|
-
model: resolvedModel,
|
|
260
|
-
startedAt: runStartedAt,
|
|
261
|
-
});
|
|
262
|
-
ctx.agentManager.register({ sessionId, state, deps });
|
|
263
|
-
// workflowz 关键词检测:用户消息包含独立关键词时注入工作流通知(steering),
|
|
264
|
-
// 引导模型用 task 工具批量 fan-out 做确定性多子 agent 分解。
|
|
265
|
-
if (containsWorkflow(message)) {
|
|
266
|
-
const wfList = ctx.workflowRegistry
|
|
267
|
-
? ctx.workflowRegistry.list().map((w) => ({
|
|
268
|
-
name: w.meta.name,
|
|
269
|
-
description: w.meta.description,
|
|
270
|
-
}))
|
|
271
|
-
: [];
|
|
272
|
-
injectSteering(state, buildWorkflowNotice(wfList));
|
|
196
|
+
// 占位持有期(tryAcquire → SSE 回调 register)内的任何提前返回或抛错都必须
|
|
197
|
+
// 释放占位,否则该会话被永久 409 锁死。handedOff 标记所有权已移交 SSE 回调
|
|
198
|
+
// (其 finally 负责幂等释放),移交前的全部路径在 finally 统一兜底释放。
|
|
199
|
+
let handedOff = false;
|
|
200
|
+
try {
|
|
201
|
+
// 工具解析(P1-1):config.tools.enabled 非空时作为默认集;空 = 全部注册工具。
|
|
202
|
+
// disabled 已在 registry 层过滤(createDefaultRegistry),此处兜底。
|
|
203
|
+
const tools = resolveEnabledToolNames(ctx.toolRegistry, ctx.config, body.tools);
|
|
204
|
+
// primary agent 解析(spec: agent-frontend-switching §4.3)
|
|
205
|
+
const agentName = body.agent ?? 'default';
|
|
206
|
+
const agentDef = ctx.agentRegistry.get(agentName);
|
|
207
|
+
if (!agentDef || agentDef.mode === 'subagent') {
|
|
208
|
+
return apiError(c, 400, 'INVALID_AGENT', `Unknown or non-primary agent: ${agentName}`);
|
|
273
209
|
}
|
|
274
|
-
//
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
210
|
+
// agent def 覆盖:tools(plan 限只读)、model(可选)
|
|
211
|
+
const resolvedTools = agentDef.tools ?? tools;
|
|
212
|
+
const resolvedModel = agentDef.model ?? model;
|
|
213
|
+
// 分段预检:切换 provider/model/tools 将开新段(前缀失效→缓存 miss),
|
|
214
|
+
// 需用户显式确认(confirmSegmentBreak)。首轮无活跃段时跳过。
|
|
215
|
+
// 守卫 409 短路后 agentManager.get 恒为 undefined,原 existingRun 三元
|
|
216
|
+
// 恒走 else 分支(死代码,已删除)。
|
|
217
|
+
const segs = await getLLMSegments(ctx.db, sessionId);
|
|
218
|
+
const active = segs[segs.length - 1];
|
|
219
|
+
if (active) {
|
|
220
|
+
const reqTools = new Set(resolvedTools);
|
|
221
|
+
const segTools = new Set(active.tools.map((t) => t.name));
|
|
222
|
+
const toolsDiffer = reqTools.size !== segTools.size || [...reqTools].some((t) => !segTools.has(t));
|
|
223
|
+
const modelDiffer = active.provider !== provider || active.model !== resolvedModel;
|
|
224
|
+
// 旧段 agentName undefined 视为 'default'
|
|
225
|
+
const agentDiffer = (active.agentName ?? 'default') !== agentName;
|
|
226
|
+
const confirmed = body.confirmSegmentBreak === true;
|
|
227
|
+
if ((modelDiffer || toolsDiffer || agentDiffer) && !confirmed) {
|
|
228
|
+
return apiError(c, 409, 'SEGMENT_BREAK_REQUIRED', '切换模型/工具将开始新的上下文段(缓存失效),需用户确认', {
|
|
229
|
+
activeSegment: {
|
|
230
|
+
provider: active.provider,
|
|
231
|
+
model: active.model,
|
|
232
|
+
tools: active.tools.map((t) => t.name),
|
|
233
|
+
},
|
|
289
234
|
});
|
|
290
235
|
}
|
|
291
236
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
237
|
+
const response = streamSSE(c, async (stream) => {
|
|
238
|
+
// P0-4:回调从首行起全部纳入 try/finally——createAgent、updateSessionLastRun
|
|
239
|
+
// 等启动阶段失败同样必须释放占位并补发 error/done(hono 的 stream 包装器
|
|
240
|
+
// 只 console.error 不善后),不得泄漏占位或让前端卡在 streaming 态。
|
|
241
|
+
let doneSent = false;
|
|
242
|
+
let runStartedAt;
|
|
243
|
+
try {
|
|
244
|
+
// 权限检查器:ask 权限通过 SSE 通知前端,阻塞等待确认
|
|
245
|
+
const permissionChecker = createInteractivePermissionChecker(ctx.permissionStore, {
|
|
246
|
+
getMode: () => ctx.permissionMode,
|
|
247
|
+
onPermissionRequired: async (req) => {
|
|
248
|
+
await stream.writeSSE({
|
|
249
|
+
event: 'permission_required',
|
|
250
|
+
data: JSON.stringify({ _tag: 'permission_required', ...req }),
|
|
251
|
+
});
|
|
305
252
|
},
|
|
306
|
-
})
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
253
|
+
});
|
|
254
|
+
// cwd 已在斜杠拦截前解析,此处直接复用
|
|
255
|
+
// 构建 agent 依赖(注入测试用 chatStream)
|
|
256
|
+
const deps = {
|
|
257
|
+
db: ctx.db,
|
|
258
|
+
llmRegistry: ctx.llmRegistry,
|
|
259
|
+
toolRegistry: ctx.toolRegistry,
|
|
260
|
+
urlRegistry: ctx.urlRegistry,
|
|
261
|
+
hookRunner: ctx.hookRunner,
|
|
262
|
+
permission: permissionChecker,
|
|
263
|
+
config: ctx.config,
|
|
264
|
+
agentRegistry: ctx.agentRegistry,
|
|
265
|
+
cwd,
|
|
266
|
+
...(ctx.chatStream ? { chatStream: ctx.chatStream } : {}),
|
|
267
|
+
};
|
|
268
|
+
const agentConfig = {
|
|
269
|
+
provider,
|
|
270
|
+
model: resolvedModel,
|
|
271
|
+
tools: resolvedTools,
|
|
272
|
+
plugins: ctx.config.plugins.enabled,
|
|
273
|
+
agentName,
|
|
274
|
+
...(agentDef.systemPrompt ? { agentRolePrompt: agentDef.systemPrompt } : {}),
|
|
275
|
+
};
|
|
276
|
+
const state = await createAgent(session, agentConfig, deps);
|
|
277
|
+
// 持久化 run 状态:status='running' 写入 DB。服务重启后此字段仍为 'running'
|
|
278
|
+
// 而进程无活跃 run → 检测为 interrupted。finally 中标记 'completed'。
|
|
279
|
+
runStartedAt = Date.now();
|
|
280
|
+
await updateSessionLastRun(ctx.db, sessionId, {
|
|
281
|
+
status: 'running',
|
|
282
|
+
...(agentName !== 'default' ? { agentName } : {}),
|
|
283
|
+
provider,
|
|
284
|
+
model: resolvedModel,
|
|
285
|
+
startedAt: runStartedAt,
|
|
286
|
+
});
|
|
287
|
+
// 填充 tryAcquire 留下的占位(原 register 语义:按 sessionId 覆盖写入)
|
|
288
|
+
ctx.agentManager.register({ sessionId, state, deps });
|
|
289
|
+
// workflowz 关键词检测:用户消息包含独立关键词时注入工作流通知(steering),
|
|
290
|
+
// 引导模型用 task 工具批量 fan-out 做确定性多子 agent 分解。
|
|
291
|
+
if (containsWorkflow(message)) {
|
|
292
|
+
const wfList = ctx.workflowRegistry
|
|
293
|
+
? ctx.workflowRegistry.list().map((w) => ({
|
|
294
|
+
name: w.meta.name,
|
|
295
|
+
description: w.meta.description,
|
|
296
|
+
}))
|
|
297
|
+
: [];
|
|
298
|
+
injectSteering(state, buildWorkflowNotice(wfList));
|
|
299
|
+
}
|
|
300
|
+
// 客户端断开时中止 agent
|
|
301
|
+
stream.onAbort(() => {
|
|
302
|
+
ctx.agentManager.abort(sessionId);
|
|
303
|
+
});
|
|
304
|
+
// 跟踪 done 事件是否已发送:agentLoop 正常完成时 yield done;
|
|
305
|
+
// 但 error/abort/max_turns 等路径不 yield done,需在 finally 补发。
|
|
306
|
+
// 否则前端 isStreaming 永远不会变 false,按钮卡在「终止」态。
|
|
307
|
+
try {
|
|
308
|
+
for await (const event of runAgent(state, userContent, deps)) {
|
|
309
|
+
if (event._tag === 'done')
|
|
310
|
+
doneSent = true;
|
|
311
|
+
await stream.writeSSE({
|
|
312
|
+
event: event._tag,
|
|
313
|
+
data: JSON.stringify(event),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
const message = err instanceof Error
|
|
319
|
+
? err.message
|
|
320
|
+
: typeof err === 'object' && err !== null && 'message' in err
|
|
321
|
+
? String(err.message)
|
|
322
|
+
: String(err);
|
|
323
|
+
await stream.writeSSE({
|
|
324
|
+
event: 'error',
|
|
325
|
+
data: JSON.stringify({
|
|
326
|
+
_tag: 'error',
|
|
327
|
+
error: {
|
|
328
|
+
_tag: 'unexpected',
|
|
329
|
+
message,
|
|
330
|
+
},
|
|
331
|
+
}),
|
|
332
|
+
});
|
|
333
|
+
}
|
|
314
334
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
335
|
+
catch (err) {
|
|
336
|
+
// 启动阶段(createAgent/updateSessionLastRun 等)失败:补发 error 事件。
|
|
337
|
+
// 客户端已断开时 writeSSE 会 reject,吞掉以保证 finally 善后执行。
|
|
338
|
+
await stream
|
|
339
|
+
.writeSSE({
|
|
340
|
+
event: 'error',
|
|
341
|
+
data: JSON.stringify({
|
|
342
|
+
_tag: 'error',
|
|
343
|
+
error: {
|
|
344
|
+
_tag: 'unexpected',
|
|
345
|
+
message: err instanceof Error ? err.message : String(err),
|
|
346
|
+
},
|
|
347
|
+
}),
|
|
348
|
+
})
|
|
349
|
+
.catch(() => { });
|
|
350
|
+
}
|
|
351
|
+
finally {
|
|
352
|
+
// 先从 agentManager 释放(占位与活跃 run 均幂等):必须最先执行,确保
|
|
353
|
+
// 客户端断开使 writeSSE reject 时 agent 状态(state/deps/AbortController)
|
|
354
|
+
// 不会泄漏到 Map(unregister 仅做 Map.delete、不依赖 state,放最前安全)。
|
|
355
|
+
ctx.agentManager.unregister(sessionId);
|
|
356
|
+
// agentLoop 的 error/abort/max_turns 路径不 yield done,在此补发。
|
|
357
|
+
// 正常完成路径已在循环中 yield done,doneSent=true 时跳过避免重复。
|
|
358
|
+
// 客户端已断开时 writeSSE 会 reject,吞掉以保证后续 updateSessionLastRun 执行。
|
|
359
|
+
if (!doneSent) {
|
|
360
|
+
await stream
|
|
361
|
+
.writeSSE({ event: 'done', data: JSON.stringify({ _tag: 'done' }) })
|
|
362
|
+
.catch(() => { });
|
|
363
|
+
}
|
|
364
|
+
// 无论正常完成、错误还是 abort,只要服务还活着就标记 completed。
|
|
365
|
+
// 只有服务崩溃/重启才会留下 status='running' → 下次加载检测为 interrupted。
|
|
366
|
+
// runStartedAt 未定义说明 run 从未真正启动(createAgent 即失败),DB 中
|
|
367
|
+
// 无 'running' 标记需要清理,跳过写入。
|
|
368
|
+
if (runStartedAt !== undefined) {
|
|
369
|
+
await updateSessionLastRun(ctx.db, sessionId, {
|
|
370
|
+
status: 'completed',
|
|
371
|
+
...(agentName !== 'default' ? { agentName } : {}),
|
|
372
|
+
provider,
|
|
373
|
+
model: resolvedModel,
|
|
374
|
+
startedAt: runStartedAt,
|
|
375
|
+
}).catch(() => { });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
// streamSSE 已同步启动回调(回调的 register/finally 从此接管占位填充与
|
|
380
|
+
// 释放),所有权移交:本层 finally 不再兜底释放。
|
|
381
|
+
handedOff = true;
|
|
382
|
+
return response;
|
|
383
|
+
}
|
|
384
|
+
finally {
|
|
385
|
+
if (!handedOff)
|
|
324
386
|
ctx.agentManager.unregister(sessionId);
|
|
325
|
-
|
|
326
|
-
});
|
|
387
|
+
}
|
|
327
388
|
});
|
|
328
389
|
// 控制端点
|
|
390
|
+
// P0-4:占位中的会话(tryAcquire 后、register 前)run 尚无 state 可供
|
|
391
|
+
// abort/pause/resume/steer 操作——返回明确 409 而非静默 false 或崩溃。
|
|
392
|
+
const runStarting = (c, sessionId) => ctx.agentManager.isStarting(sessionId)
|
|
393
|
+
? apiError(c, 409, 'RUN_STARTING', '该会话的对话正在启动,请稍后重试')
|
|
394
|
+
: undefined;
|
|
329
395
|
app.post('/abort', async (c) => {
|
|
330
396
|
const { sessionId } = await c.req.json();
|
|
331
|
-
return c.json({ aborted: ctx.agentManager.abort(sessionId) });
|
|
397
|
+
return runStarting(c, sessionId) ?? c.json({ aborted: ctx.agentManager.abort(sessionId) });
|
|
332
398
|
});
|
|
333
399
|
app.post('/pause', async (c) => {
|
|
334
400
|
const { sessionId } = await c.req.json();
|
|
335
|
-
return c.json({ paused: ctx.agentManager.pause(sessionId) });
|
|
401
|
+
return runStarting(c, sessionId) ?? c.json({ paused: ctx.agentManager.pause(sessionId) });
|
|
336
402
|
});
|
|
337
403
|
app.post('/resume', async (c) => {
|
|
338
404
|
const { sessionId } = await c.req.json();
|
|
339
|
-
return c.json({ resumed: ctx.agentManager.resume(sessionId) });
|
|
405
|
+
return runStarting(c, sessionId) ?? c.json({ resumed: ctx.agentManager.resume(sessionId) });
|
|
340
406
|
});
|
|
341
407
|
app.post('/steer', async (c) => {
|
|
342
408
|
const body = await c.req.json();
|
|
343
|
-
return
|
|
409
|
+
return (runStarting(c, body.sessionId) ??
|
|
410
|
+
c.json({ steered: ctx.agentManager.steer(body.sessionId, body.message) }));
|
|
344
411
|
});
|
|
345
412
|
return app;
|
|
346
413
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
|
-
import { mergeConfig,
|
|
2
|
+
import { loadConfigScopes, mergeConfig, mergeRaw, saveConfigScoped } from '../../core/config.js';
|
|
3
3
|
import { encryptSecret, isEncryptedSecret } from '../../core/secret.js';
|
|
4
4
|
import { syncRegistryFromConfig } from '../server.js';
|
|
5
5
|
function createConfigRoute(ctx) {
|
|
@@ -14,10 +14,14 @@ function createConfigRoute(ctx) {
|
|
|
14
14
|
if (Array.isArray(patch.providers)) {
|
|
15
15
|
patch.providers = patch.providers.map((p) => p.apiKey && !isEncryptedSecret(p.apiKey) ? { ...p, apiKey: encryptSecret(p.apiKey) } : p);
|
|
16
16
|
}
|
|
17
|
-
|
|
17
|
+
// 按作用域最小落盘:patch 只合并进项目作用域原始文件,
|
|
18
|
+
// 不把合并结果(含默认值/global 配置)整体序列化进项目文件。
|
|
19
|
+
const scopes = loadConfigScopes(ctx.cwd);
|
|
20
|
+
const nextProject = mergeRaw(scopes.project ?? {}, patch);
|
|
21
|
+
ctx.config = mergeConfig(scopes.global, nextProject);
|
|
18
22
|
// providers 变更后原地同步 registry,使运行中的连接立即生效
|
|
19
23
|
syncRegistryFromConfig(ctx.llmRegistry, ctx.config);
|
|
20
|
-
await
|
|
24
|
+
await saveConfigScoped('project', ctx.cwd, nextProject).catch(() => {
|
|
21
25
|
// 保存失败不影响内存配置
|
|
22
26
|
});
|
|
23
27
|
return c.json(ctx.config);
|
|
@@ -2,7 +2,7 @@ import { Hono } from 'hono';
|
|
|
2
2
|
import { createSummarizer, runCompaction } from '../../core/compact.js';
|
|
3
3
|
import { fromDirectory } from '../../project/index.js';
|
|
4
4
|
import { archiveOriginalEntries } from '../../session/archive.js';
|
|
5
|
-
import { forkSession, getBranches, getTree } from '../../session/branch.js';
|
|
5
|
+
import { BranchPointOutOfRangeError, forkSession, getBranches, getTree, } from '../../session/branch.js';
|
|
6
6
|
import { deleteEntriesByIds, getMessages, insertEntry } from '../../session/message.js';
|
|
7
7
|
import { createSession, deleteSession, getLLMSegments, getSession, listSessions, listSessionsByProject, touchLastOpened, } from '../../session/session.js';
|
|
8
8
|
import { applyShakeRegions, collectShakeRegions, DEFAULT_SHAKE_CONFIG, toRegionViews, } from '../../session/shake.js';
|
|
@@ -63,7 +63,11 @@ function createSessionRoute(ctx) {
|
|
|
63
63
|
const forked = await forkSession(ctx.db, id, messageIndex);
|
|
64
64
|
return c.json(forked, 201);
|
|
65
65
|
}
|
|
66
|
-
catch {
|
|
66
|
+
catch (error) {
|
|
67
|
+
// 分支点越界是客户端索引/分页 bug → 400 并透出明确语义;归 404 会误导排查
|
|
68
|
+
if (error instanceof BranchPointOutOfRangeError) {
|
|
69
|
+
return apiError(c, 400, 'BRANCH_POINT_OUT_OF_RANGE', error.message);
|
|
70
|
+
}
|
|
67
71
|
return apiError(c, 404, 'NOT_FOUND', 'Session not found');
|
|
68
72
|
}
|
|
69
73
|
});
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// src/server/routes/terminal.ts
|
|
2
|
+
import { isAbsolute } from 'node:path';
|
|
2
3
|
import { Hono } from 'hono';
|
|
3
4
|
import { apiError } from '../middleware/error.js';
|
|
5
|
+
/** 允许的 shell 白名单(P2-6:拒绝路径分隔符与任意二进制)。 */
|
|
6
|
+
const ALLOWED_SHELLS = new Set(['bash', 'zsh', 'fish', 'sh']);
|
|
4
7
|
/** 创建终端路由(REST 部分;WebSocket 升级在 server.ts 中处理)。 */
|
|
5
8
|
function createTerminalRoute(ctx) {
|
|
6
9
|
const app = new Hono();
|
|
@@ -18,6 +21,15 @@ function createTerminalRoute(ctx) {
|
|
|
18
21
|
const title = typeof body.title === 'string' ? body.title : undefined;
|
|
19
22
|
const shell = typeof body.shell === 'string' && body.shell.length > 0 ? body.shell : undefined;
|
|
20
23
|
const projectId = typeof body.projectId === 'string' && body.projectId.length > 0 ? body.projectId : undefined;
|
|
24
|
+
// 入参校验(P2-6):shell 白名单;cwd 必须为绝对路径。
|
|
25
|
+
if (shell !== undefined) {
|
|
26
|
+
if (shell.includes('/') || shell.includes('\\') || !ALLOWED_SHELLS.has(shell)) {
|
|
27
|
+
return apiError(c, 400, 'INVALID_SHELL', `shell 必须是 ${[...ALLOWED_SHELLS].join('/')} 之一`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (typeof cwd === 'string' && cwd.length > 0 && !isAbsolute(cwd)) {
|
|
31
|
+
return apiError(c, 400, 'INVALID_CWD', 'cwd 必须是绝对路径');
|
|
32
|
+
}
|
|
21
33
|
try {
|
|
22
34
|
const info = mgr.create({ cwd, cols, rows, title, shell, projectId });
|
|
23
35
|
return c.json(info, 201);
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// src/server/routes/todo.ts
|
|
2
2
|
import { Hono } from 'hono';
|
|
3
|
-
import {
|
|
3
|
+
import { appendMessage, getMessages } from '../../session/message.js';
|
|
4
4
|
import { getSession } from '../../session/session.js';
|
|
5
5
|
import { generateId } from '../../shared/index.js';
|
|
6
|
-
import {
|
|
6
|
+
import { formatSummary, getLatestTodoPhasesFromMessages, todoTool, } from '../../tools/builtin/todo.js';
|
|
7
7
|
import { apiError } from '../middleware/error.js';
|
|
8
8
|
/** 构造仅供 todo tool execute 使用的最小 ToolContext。 */
|
|
9
9
|
function makeTodoCtx(phases, abort) {
|
|
@@ -89,6 +89,9 @@ function createTodoRoute(ctx) {
|
|
|
89
89
|
// 更新活跃 agent 的内存状态
|
|
90
90
|
if (run) {
|
|
91
91
|
run.state.todoPhases = updatedPhases;
|
|
92
|
+
// 前端手动修改了 todo 状态 → 注入 steering 通知 LLM(通道 A)
|
|
93
|
+
const summary = formatSummary(updatedPhases, [], true);
|
|
94
|
+
run.state.steeringQueue.push(`<todo-state-external>\nA todo change was made externally (via UI).\n${summary}\n</todo-state-external>`);
|
|
92
95
|
}
|
|
93
96
|
}
|
|
94
97
|
return c.json({
|
|
@@ -9,6 +9,7 @@ import type { ServerContext } from '../types.js';
|
|
|
9
9
|
* 序列化当前会话 → performHotUpdate(npm install -g + spawn 新实例 + handoffPort)。
|
|
10
10
|
* 旧实例通过 handoff 端点优雅退出,新实例 restore 快照并接管端口。
|
|
11
11
|
* 响应立即返回(不阻塞 spawn),实际接管由 handoff IPC 异步完成。
|
|
12
|
+
* dev(vite)模式无 handoff server,返回 409——热更新仅在独立 serve 进程可用。
|
|
12
13
|
*/
|
|
13
14
|
declare function createUpdateRoute(ctx: ServerContext): Hono;
|
|
14
15
|
export { createUpdateRoute };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
|
-
import { performHotUpdate, serializeSessions } from '../../update/index.js';
|
|
2
|
+
import { getCurrentVersion, performHotUpdate, serializeSessions } from '../../update/index.js';
|
|
3
3
|
import { apiError } from '../middleware/error.js';
|
|
4
4
|
/**
|
|
5
5
|
* GET /api/update — 返回后台调度器缓存的版本检查结果(spec §18.1)。
|
|
@@ -10,6 +10,7 @@ import { apiError } from '../middleware/error.js';
|
|
|
10
10
|
* 序列化当前会话 → performHotUpdate(npm install -g + spawn 新实例 + handoffPort)。
|
|
11
11
|
* 旧实例通过 handoff 端点优雅退出,新实例 restore 快照并接管端口。
|
|
12
12
|
* 响应立即返回(不阻塞 spawn),实际接管由 handoff IPC 异步完成。
|
|
13
|
+
* dev(vite)模式无 handoff server,返回 409——热更新仅在独立 serve 进程可用。
|
|
13
14
|
*/
|
|
14
15
|
function createUpdateRoute(ctx) {
|
|
15
16
|
const app = new Hono();
|
|
@@ -20,20 +21,26 @@ function createUpdateRoute(ctx) {
|
|
|
20
21
|
// 无缓存(首次启动延迟未到):同步触发一次,避免前端首屏空。
|
|
21
22
|
// 不 await——保持 GET 语义非阻塞;前端下次轮询拿到结果。
|
|
22
23
|
void ctx.updateScheduler.checkNow();
|
|
24
|
+
const v = getCurrentVersion();
|
|
23
25
|
return c.json({
|
|
24
26
|
hasUpdate: false,
|
|
25
|
-
currentVersion:
|
|
26
|
-
latestVersion:
|
|
27
|
+
currentVersion: v,
|
|
28
|
+
latestVersion: v,
|
|
27
29
|
});
|
|
28
30
|
});
|
|
29
31
|
app.post('/apply', async (c) => {
|
|
32
|
+
if (!ctx.handoff) {
|
|
33
|
+
return apiError(c, 409, 'HOT_UPDATE_UNAVAILABLE', '热更新仅在独立 serve 进程可用(dev 模式请手动更新)');
|
|
34
|
+
}
|
|
30
35
|
const result = await ctx.updateScheduler.checkNow();
|
|
31
36
|
if (!result.hasUpdate) {
|
|
32
37
|
return apiError(c, 409, 'NO_UPDATE', '已是最新版本,无需热更新');
|
|
33
38
|
}
|
|
34
39
|
const snapshot = await serializeSessions(ctx.db, ctx.config);
|
|
35
40
|
const r = await performHotUpdate(snapshot, {
|
|
36
|
-
handoffPort: ctx.handoff
|
|
41
|
+
handoffPort: ctx.handoff.port,
|
|
42
|
+
port: ctx.port,
|
|
43
|
+
authToken: ctx.authToken,
|
|
37
44
|
});
|
|
38
45
|
if (r._tag === 'success') {
|
|
39
46
|
return c.json({
|
|
@@ -2,9 +2,9 @@ import { unlink } from 'node:fs/promises';
|
|
|
2
2
|
import { Hono } from 'hono';
|
|
3
3
|
import { streamSSE } from 'hono/streaming';
|
|
4
4
|
import { createAgent } from '../../core/agent.js';
|
|
5
|
-
import { executeWorkflow } from '../../core/workflows/runtime.js';
|
|
6
|
-
import { reloadRegistry } from '../../core/workflows/registry.js';
|
|
7
5
|
import { discoverWorkflows, saveWorkflow } from '../../core/workflows/discovery.js';
|
|
6
|
+
import { reloadRegistry } from '../../core/workflows/registry.js';
|
|
7
|
+
import { executeWorkflow } from '../../core/workflows/runtime.js';
|
|
8
8
|
import { getProject } from '../../project/project.js';
|
|
9
9
|
import { createSession } from '../../session/session.js';
|
|
10
10
|
import { autoAllowChecker } from '../../tools/permission.js';
|