c0de-agent 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/README.md +51 -23
  2. package/dist/cli/commands/acp.js +26 -8
  3. package/dist/cli/commands/chat.js +70 -6
  4. package/dist/cli/commands/config.d.ts +2 -0
  5. package/dist/cli/commands/config.js +46 -0
  6. package/dist/cli/commands/serve.js +11 -2
  7. package/dist/cli/commands/sessions.js +51 -4
  8. package/dist/cli/commands/trust.d.ts +11 -0
  9. package/dist/cli/commands/trust.js +31 -0
  10. package/dist/cli/commands/update.js +4 -0
  11. package/dist/cli/deps.d.ts +9 -6
  12. package/dist/cli/deps.js +66 -19
  13. package/dist/cli/index.d.ts +4 -1
  14. package/dist/cli/index.js +92 -12
  15. package/dist/cli/modes/acp.js +39 -8
  16. package/dist/cli/modes/print.d.ts +3 -0
  17. package/dist/cli/modes/print.js +71 -8
  18. package/dist/core/agent.d.ts +1 -1
  19. package/dist/core/agent.js +9 -4
  20. package/dist/core/agents/builtin.js +2 -2
  21. package/dist/core/agents/index.d.ts +0 -1
  22. package/dist/core/agents/index.js +0 -1
  23. package/dist/core/config.d.ts +43 -1
  24. package/dist/core/config.js +163 -14
  25. package/dist/core/loop/segment.d.ts +3 -1
  26. package/dist/core/loop/segment.js +24 -1
  27. package/dist/core/loop/subagent.js +65 -32
  28. package/dist/core/loop.d.ts +7 -0
  29. package/dist/core/loop.js +90 -6
  30. package/dist/core/slash.d.ts +10 -0
  31. package/dist/core/slash.js +150 -20
  32. package/dist/core/types.d.ts +20 -1
  33. package/dist/core/workflows/context.js +44 -4
  34. package/dist/core/workflows/discovery.d.ts +2 -2
  35. package/dist/core/workflows/discovery.js +5 -7
  36. package/dist/core/workflows/index.d.ts +2 -1
  37. package/dist/core/workflows/index.js +2 -1
  38. package/dist/core/workflows/registry.d.ts +13 -3
  39. package/dist/core/workflows/registry.js +14 -9
  40. package/dist/core/workflows/resolve.d.ts +22 -0
  41. package/dist/core/workflows/resolve.js +23 -0
  42. package/dist/core/workflows/runtime.d.ts +6 -1
  43. package/dist/core/workflows/runtime.js +19 -2
  44. package/dist/db/schema.d.ts +356 -1
  45. package/dist/db/schema.js +57 -3
  46. package/dist/kanban/index.d.ts +2 -1
  47. package/dist/kanban/index.js +1 -1
  48. package/dist/kanban/store.d.ts +84 -2
  49. package/dist/kanban/store.js +358 -25
  50. package/dist/llm/registry.d.ts +7 -1
  51. package/dist/llm/registry.js +7 -1
  52. package/dist/plugins/init.d.ts +7 -0
  53. package/dist/plugins/init.js +22 -1
  54. package/dist/plugins/loader.d.ts +7 -1
  55. package/dist/plugins/loader.js +12 -4
  56. package/dist/project/index.d.ts +1 -1
  57. package/dist/project/index.js +1 -1
  58. package/dist/project/project.d.ts +23 -0
  59. package/dist/project/project.js +65 -0
  60. package/dist/project/trust.d.ts +65 -0
  61. package/dist/project/trust.js +328 -0
  62. package/dist/server/agent-manager.d.ts +6 -2
  63. package/dist/server/agent-manager.js +4 -2
  64. package/dist/server/app.js +45 -30
  65. package/dist/server/auth-manager.d.ts +15 -4
  66. package/dist/server/auth-manager.js +41 -24
  67. package/dist/server/context.js +11 -1
  68. package/dist/server/index.d.ts +1 -1
  69. package/dist/server/index.js +1 -1
  70. package/dist/server/middleware/auth.d.ts +11 -1
  71. package/dist/server/middleware/auth.js +25 -3
  72. package/dist/server/permission/interactive.d.ts +5 -0
  73. package/dist/server/permission/interactive.js +4 -1
  74. package/dist/server/permission/store.d.ts +2 -0
  75. package/dist/server/permission/store.js +8 -0
  76. package/dist/server/routes/auth.js +35 -9
  77. package/dist/server/routes/chat.d.ts +16 -7
  78. package/dist/server/routes/chat.js +483 -28
  79. package/dist/server/routes/commands.d.ts +2 -2
  80. package/dist/server/routes/commands.js +4 -6
  81. package/dist/server/routes/config.js +81 -4
  82. package/dist/server/routes/files.js +120 -24
  83. package/dist/server/routes/kanban.js +152 -22
  84. package/dist/server/routes/permissions.js +7 -0
  85. package/dist/server/routes/project.js +76 -22
  86. package/dist/server/routes/provider.js +6 -0
  87. package/dist/server/routes/session.js +177 -13
  88. package/dist/server/routes/tool.js +24 -5
  89. package/dist/server/routes/update.js +58 -2
  90. package/dist/server/routes/usage.d.ts +4 -0
  91. package/dist/server/routes/usage.js +133 -0
  92. package/dist/server/routes/workflows.js +476 -87
  93. package/dist/server/server.d.ts +12 -1
  94. package/dist/server/server.js +194 -33
  95. package/dist/server/terminal/pty-manager.d.ts +12 -0
  96. package/dist/server/terminal/pty-manager.js +63 -7
  97. package/dist/server/types.d.ts +9 -0
  98. package/dist/session/branch.d.ts +2 -1
  99. package/dist/session/branch.js +75 -10
  100. package/dist/session/context.js +28 -2
  101. package/dist/session/index.d.ts +1 -1
  102. package/dist/session/index.js +1 -1
  103. package/dist/session/session.d.ts +67 -10
  104. package/dist/session/session.js +180 -67
  105. package/dist/session/types.d.ts +2 -0
  106. package/dist/session/usage.d.ts +62 -0
  107. package/dist/session/usage.js +194 -0
  108. package/dist/shared/types/agent.d.ts +19 -3
  109. package/dist/shared/types/config.d.ts +59 -4
  110. package/dist/shared/types/kanban.d.ts +4 -2
  111. package/dist/shared/types/message.d.ts +14 -0
  112. package/dist/tools/builtin/kanban.js +13 -2
  113. package/dist/tools/index.d.ts +14 -3
  114. package/dist/tools/index.js +23 -5
  115. package/dist/tools/websearch/index.d.ts +5 -1
  116. package/dist/tools/websearch/index.js +40 -4
  117. package/dist/tools/websearch/providers/brave.js +2 -2
  118. package/dist/tools/websearch/providers/duckduckgo.js +2 -1
  119. package/dist/tools/websearch/providers/tavily.js +2 -2
  120. package/dist/tools/websearch/types.d.ts +5 -1
  121. package/dist/tools/websearch/types.js +7 -1
  122. package/dist/update/scheduler.js +5 -2
  123. package/dist/update/snapshot.d.ts +17 -3
  124. package/dist/update/snapshot.js +3 -2
  125. package/dist/update/updater.d.ts +2 -0
  126. package/dist/update/updater.js +2 -1
  127. package/dist/update/version.d.ts +3 -0
  128. package/dist/update/version.js +9 -3
  129. package/drizzle/0010_usage_events_ledger.sql +17 -0
  130. package/drizzle/0011_slow_slapstick.sql +12 -0
  131. package/drizzle/0012_chief_timeslip.sql +1 -0
  132. package/drizzle/0013_swift_zeigeist.sql +1 -0
  133. package/drizzle/0014_flimsy_legion.sql +1 -0
  134. package/drizzle/meta/0010_snapshot.json +1010 -0
  135. package/drizzle/meta/0011_snapshot.json +1055 -0
  136. package/drizzle/meta/0012_snapshot.json +1062 -0
  137. package/drizzle/meta/0013_snapshot.json +1068 -0
  138. package/drizzle/meta/0014_snapshot.json +1075 -0
  139. package/drizzle/meta/_journal.json +35 -0
  140. package/package.json +24 -23
  141. package/dist/core/agents/discovery.d.ts +0 -6
  142. package/dist/core/agents/discovery.js +0 -96
@@ -1,13 +1,24 @@
1
1
  // src/server/routes/project.ts
2
- import { existsSync } from 'node:fs';
2
+ import { existsSync, realpathSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { parse } from 'node:path';
3
5
  import { and, eq, isNull } from 'drizzle-orm';
4
6
  import { Hono } from 'hono';
5
7
  import { kanbanBoards, projects, sessions } from '../../db/schema.js';
6
- import { fromDirectory, getProject, listProjects, relocateProject, resolveProject, updateProjectName, } from '../../project/index.js';
8
+ import { fromDirectory, getByDirectory, getProject, listProjects, relocateProject, resolveProject, trustProject, updateProjectName, } from '../../project/index.js';
7
9
  import { clearTrashMarks } from '../../session/session.js';
8
10
  import { generateId } from '../../shared/index.js';
9
11
  import { apiError } from '../middleware/error.js';
10
12
  import { expandPath } from './filesystem.js';
13
+ /** realpath 失败(目录不存在等)时返回 null,调用方回退原路径。 */
14
+ function realpathSafe(p) {
15
+ try {
16
+ return realpathSync(p);
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
11
22
  /** 实时取 git 分支(分支随时变,不存 DB)。worktree 不存在时标记缺失。 */
12
23
  function withBranch(project) {
13
24
  const worktreeMissing = !existsSync(project.worktree);
@@ -30,8 +41,21 @@ function createProjectRoute(ctx) {
30
41
  const list = await listProjects(ctx.db);
31
42
  return c.json(list.map(withBranch));
32
43
  });
33
- // 解析服务端 cwd 对应的项目(未注册则自动创建)
44
+ // 解析服务端 cwd 对应的项目(未注册则自动创建)。
45
+ // 注册过滤(P2):serve 在 home/文件系统根目录启动时不做自动注册——home 不是项目,
46
+ // 自动注册只会把 home 变成污染项目列表的假项目。已显式注册(/from-directory)的
47
+ // 目录照常返回,用户主动添加 home 为项目的选择被尊重。
34
48
  app.get('/current', async (c) => {
49
+ const existing = await getByDirectory(ctx.db, ctx.cwd);
50
+ if (existing)
51
+ return c.json(withBranch(existing), 200);
52
+ const dir = realpathSafe(ctx.cwd) ?? ctx.cwd;
53
+ const home = realpathSafe(homedir());
54
+ const root = parse(dir).root;
55
+ if ((home && dir === home) || dir === root) {
56
+ return apiError(c, 404, 'PROJECT_DIR_REQUIRED', '当前目录(home 目录)不是项目,未自动注册。请在项目目录启动 c0de serve,' +
57
+ '或在项目列表中显式添加该目录。');
58
+ }
35
59
  const project = await fromDirectory(ctx.db, ctx.cwd);
36
60
  return c.json(withBranch(project), 200);
37
61
  });
@@ -53,6 +77,14 @@ function createProjectRoute(ctx) {
53
77
  return apiError(c, 404, 'NOT_FOUND', 'Project not found');
54
78
  return c.json(withBranch(project));
55
79
  });
80
+ // P0-2:显式信任项目——用户在信任确认弹窗中批准项目作用域配置/插件后调用。
81
+ // 信任是一次性动作(trustedAt 落盘);此后聊天入口不再拦截本项目。
82
+ app.post('/:id/trust', async (c) => {
83
+ const project = await trustProject(ctx.db, c.req.param('id'));
84
+ if (!project)
85
+ return apiError(c, 404, 'NOT_FOUND', 'Project not found');
86
+ return c.json({ ok: true, project: withBranch(project) });
87
+ });
56
88
  // A1:项目重新定位——目录被移动/重命名后的恢复通道。
57
89
  // 整体迁移会话与看板到新目录身份(替代「删项目 + 逐条恢复 + 看板丢失」)。
58
90
  // 目标目录已注册为另一项目 → 409;有活跃 run → 拒绝(agent 工作目录悬空)。
@@ -65,15 +97,21 @@ function createProjectRoute(ctx) {
65
97
  if (!existsSync(directory)) {
66
98
  return apiError(c, 400, 'DIRECTORY_MISSING', `目录不存在或不可访问:${directory}`);
67
99
  }
68
- // 活跃 run 守卫(与 DELETE 同语义)
100
+ // 活跃 run 守卫(与 DELETE 同语义;含 starting 占位态)
69
101
  const rows = await ctx.db.db
70
102
  .select({ id: sessions.id })
71
103
  .from(sessions)
72
104
  .where(eq(sessions.projectId, id));
73
- const activeCount = rows.filter((r) => ctx.agentManager.get(r.id)).length;
105
+ const activeCount = rows.filter((r) => ctx.agentManager.get(r.id) !== undefined || ctx.agentManager.isStarting(r.id)).length;
74
106
  if (activeCount > 0) {
75
107
  return apiError(c, 409, 'PROJECT_HAS_ACTIVE_SESSIONS', `项目下有 ${activeCount} 个进行中的对话,请先中止后再重新定位`);
76
108
  }
109
+ // P2-5:打开的终端守卫——终端绑定项目 worktree,重定位后 shell 仍留在旧目录,
110
+ // cwd 身份悬空。与活跃 run 同语义:先关闭再操作。
111
+ const openTerminals = ctx.ptyManager.list().filter((t) => t.projectId === id);
112
+ if (openTerminals.length > 0) {
113
+ return apiError(c, 409, 'PROJECT_HAS_OPEN_TERMINALS', `项目下有 ${openTerminals.length} 个打开的终端,请先关闭终端后再重新定位`);
114
+ }
77
115
  try {
78
116
  const project = await relocateProject(ctx.db, id, directory);
79
117
  return c.json({ ok: true, project: withBranch(project) });
@@ -88,31 +126,35 @@ function createProjectRoute(ctx) {
88
126
  return apiError(c, 500, 'RELOCATE_FAILED', msg);
89
127
  }
90
128
  });
91
- // P1-9 + P2-2:删除项目记录(看板级联删除)。
129
+ // P1-9 + P2-2:删除项目记录。
92
130
  // 会话不再留孤儿(FK set null 后在任何项目视图都不可见且 cwd 回退 serve 目录):
93
- // 删除前把该项目全部未删除会话软删除进回收站(30 天内可恢复),
131
+ // 删除前把该项目全部未删除会话软删除进回收站(60 天内可恢复),
94
132
  // 并把 worktreePath 落盘——恢复后 resolveAgentCwd 仍能解析原工作目录。
133
+ // P2-5:看板同步软删除进回收站(可恢复/重新归属),不再永久级联销毁。
95
134
  // 有活跃 run 绑定该项目时拒绝删除,避免 agent 工作目录悬空。
96
135
  app.delete('/:id', async (c) => {
97
136
  const id = c.req.param('id');
98
137
  const project = await getProject(ctx.db, id);
99
138
  if (!project)
100
139
  return apiError(c, 404, 'NOT_FOUND', 'Project not found');
101
- // 活跃 run 守卫:任一活跃会话归属于该项目 → 拒绝
102
- const activeSessionIds = new Set();
103
- for (const run of ctx.agentManager.children(id)) {
104
- activeSessionIds.add(run.sessionId);
140
+ // 活跃 run 守卫:该项目下任一进行中(含占位 isStarting)的会话 → 拒绝。
141
+ // P1 修复:原实现先查 agentManager.children(projectId)——children 按
142
+ // parentSessionId(子 agent 归属)过滤,项目 id 恒不命中属死代码;且回退
143
+ // 检查只用 get() 漏掉 tryAcquire 后的 starting 占位态,窗口内删除会让 run
144
+ // 继续向已软删除会话写消息。现在直接按项目会话查 get()+isStarting()。
145
+ const rows = await ctx.db.db
146
+ .select({ id: sessions.id, projectId: sessions.projectId })
147
+ .from(sessions)
148
+ .where(and(eq(sessions.projectId, id)));
149
+ const active = rows.filter((r) => ctx.agentManager.get(r.id) !== undefined || ctx.agentManager.isStarting(r.id));
150
+ if (active.length > 0) {
151
+ return apiError(c, 409, 'PROJECT_HAS_ACTIVE_SESSIONS', `项目下有 ${active.length} 个进行中的对话,请先中止后再删除`);
105
152
  }
106
- if (activeSessionIds.size === 0) {
107
- // 检查该项目下是否有正在进行(含占位)的会话
108
- const rows = await ctx.db.db
109
- .select({ id: sessions.id, projectId: sessions.projectId })
110
- .from(sessions)
111
- .where(and(eq(sessions.projectId, id)));
112
- const active = rows.filter((r) => ctx.agentManager.get(r.id));
113
- if (active.length > 0) {
114
- return apiError(c, 409, 'PROJECT_HAS_ACTIVE_SESSIONS', `项目下有 ${active.length} 个进行中的对话,请先中止后再删除`);
115
- }
153
+ // P2-5:打开的终端守卫——项目删除后其 worktree 上的 PTY 仍继续运行,
154
+ // cwd 指向已注销身份的目录。与活跃 run 同语义:先关闭再删除。
155
+ const openTerminals = ctx.ptyManager.list().filter((t) => t.projectId === id);
156
+ if (openTerminals.length > 0) {
157
+ return apiError(c, 409, 'PROJECT_HAS_OPEN_TERMINALS', `项目下有 ${openTerminals.length} 个打开的终端,请先关闭终端后再删除项目`);
116
158
  }
117
159
  let deletedSessions = 0;
118
160
  await ctx.db.db.transaction(async (tx) => {
@@ -144,7 +186,19 @@ function createProjectRoute(ctx) {
144
186
  .where(eq(sessions.id, s.id));
145
187
  deletedSessions += 1;
146
188
  }
147
- await tx.delete(kanbanBoards).where(eq(kanbanBoards.projectId, id));
189
+ // P2-5:看板不再永久级联销毁——标记软删除(记录原项目名),项目行删除后
190
+ // FK set null 使其进入回收站「未归属看板」分组,60 天后随清理任务物理清除;
191
+ // 恢复端点可重新归属到任意项目。
192
+ await tx
193
+ .update(kanbanBoards)
194
+ .set({
195
+ deletedAt: now,
196
+ deletedProjectName: project.name ?? project.worktree,
197
+ deletedProjectWorktree: project.worktree,
198
+ purgePendingAt: null,
199
+ updatedAt: now,
200
+ })
201
+ .where(and(eq(kanbanBoards.projectId, id), isNull(kanbanBoards.deletedAt)));
148
202
  await tx.delete(projects).where(eq(projects.id, id));
149
203
  });
150
204
  return c.json({ ok: true, deletedSessions });
@@ -100,6 +100,12 @@ function createProviderRoute(ctx) {
100
100
  const { baseURL, apiKey } = body;
101
101
  if (!baseURL)
102
102
  return apiError(c, 400, 'BAD_REQUEST', 'baseURL is required');
103
+ // P3:仅允许 http/https——服务端按调用方提供的 URL 发起请求,非 http 协议
104
+ // 不应由「测试连接」端点代理(本机 localhost/Ollama 等 http 服务不受影响)。
105
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(baseURL)?.[1]?.toLowerCase();
106
+ if (!scheme || (scheme !== 'http' && scheme !== 'https')) {
107
+ return apiError(c, 400, 'BAD_REQUEST', 'baseURL must be an http(s) URL');
108
+ }
103
109
  // apiKey 可能是 Settings 页回传的 enc: 密文(保存后刷新、未重输时):探测前解密,
104
110
  // 否则把 enc: 串当 Bearer token 发给上游必然 401,造成「保存了却测试失败」的误判。
105
111
  const secret = apiKey ? decryptSecret(apiKey) : '';
@@ -11,13 +11,13 @@ import { archiveOriginalEntries, listArchives } from '../../session/archive.js';
11
11
  import { BranchPointOutOfRangeError, forkSession, getBranches, getTree, } from '../../session/branch.js';
12
12
  import { importSessionData } from '../../session/import.js';
13
13
  import { deleteEntriesByIds, getMessages, getSteeringAsMessages, insertEntry, } from '../../session/message.js';
14
- import { createSession, emptyTrash, getLLMSegments, getSession, listDeletedSessions, listOrphanDeletedSessions, listSessions, listSessionsByProject, permanentlyDeleteSession, rebindSession, restoreSessionCore, searchSessions, softDeleteSession, touchLastOpened, touchTrashSeen, updateSessionTitle, } from '../../session/session.js';
14
+ import { createSession, emptyTrash, getLLMSegments, getSession, listDeletedSessions, listOrphanDeletedSessions, listSessions, listSessionsByProject, permanentlyDeleteSession, purgeEmptySession, rebindSession, restoreSessionCore, searchSessions, softDeleteSession, touchLastOpened, touchTrashSeen, updateSessionTitle, } from '../../session/session.js';
15
15
  import { applyShakeRegions, collectShakeRegions, DEFAULT_SHAKE_CONFIG, toRegionViews, } from '../../session/shake.js';
16
16
  import { estimateMessageTokens } from '../../session/token.js';
17
17
  import { generateId } from '../../shared/index.js';
18
18
  import { apiError } from '../middleware/error.js';
19
19
  import { buildRegistryFromConfig } from '../registry-config.js';
20
- import { resolveAgentCwd } from './chat.js';
20
+ import { hasBusySession, resolveAgentCwd } from './chat.js';
21
21
  function createSessionRoute(ctx) {
22
22
  const app = new Hono();
23
23
  // 会话导入:GET /:id/export 的逆操作(数据备份/迁移闭环)。
@@ -29,6 +29,16 @@ function createSessionRoute(ctx) {
29
29
  if (body?.version !== 1 || !body?.session || !Array.isArray(body?.messages)) {
30
30
  return apiError(c, 400, 'INVALID_EXPORT', '无效的会话导出 JSON:需要 version/session/messages 字段');
31
31
  }
32
+ // P3:导入体积上限——导出文件可任意大,无上限的数组会让服务端
33
+ // 逐条插入时 OOM/长事务。上限按合理会话规模取 20 倍余量。
34
+ const MAX_IMPORT_MESSAGES = 20000;
35
+ const MAX_IMPORT_ARCHIVES = 2000;
36
+ if (body.messages.length > MAX_IMPORT_MESSAGES) {
37
+ return apiError(c, 400, 'IMPORT_TOO_LARGE', `导入消息数 ${body.messages.length} 超过上限 ${MAX_IMPORT_MESSAGES}`);
38
+ }
39
+ if (Array.isArray(body.archives) && body.archives.length > MAX_IMPORT_ARCHIVES) {
40
+ return apiError(c, 400, 'IMPORT_TOO_LARGE', `导入归档数 ${body.archives.length} 超过上限 ${MAX_IMPORT_ARCHIVES}`);
41
+ }
32
42
  const projectId = typeof body.projectId === 'string' && body.projectId ? body.projectId : '';
33
43
  if (!projectId) {
34
44
  return apiError(c, 400, 'PROJECT_REQUIRED', '导入会话必须指定目标项目(projectId)');
@@ -56,6 +66,44 @@ function createSessionRoute(ctx) {
56
66
  }
57
67
  }
58
68
  }
69
+ // P2:携带用量 segments(会话信息面板/徽标显示历史成本与 token)。
70
+ // 与权限态不同,segments 是纯统计数据无安全含义,默认随迁。
71
+ // 宽松校验形状:非法调用静默丢弃,不阻塞导入。
72
+ // P0-1:导入的调用从未对本机 API key 计费——剥离 calls[].id 使
73
+ // backfillUsageEvents 自然跳过它们(要求 id 非空),成本账本保持
74
+ // 「本机真实消费」语义;cost/usage 字段保留,展示继承成本不受影响。
75
+ // P2-7:calls 按导入消息时间窗裁剪——导出后原会话新增的调用不属于本副本。
76
+ const importedMsgs = body.messages;
77
+ let maxMsgTs = 0;
78
+ for (const m of importedMsgs) {
79
+ const raw = m?.createdAt;
80
+ const ts = typeof raw === 'number' && Number.isFinite(raw) && raw > 0
81
+ ? raw
82
+ : typeof raw === 'string'
83
+ ? Date.parse(raw)
84
+ : Number.NaN;
85
+ if (Number.isFinite(ts) && ts > maxMsgTs)
86
+ maxMsgTs = ts;
87
+ }
88
+ const srcMetaAny = body.session?.metadata;
89
+ if (maxMsgTs > 0 && Array.isArray(srcMetaAny?.segments)) {
90
+ metadata.segments = srcMetaAny.segments
91
+ .filter((seg) => !!seg &&
92
+ typeof seg === 'object' &&
93
+ !Array.isArray(seg) &&
94
+ typeof seg.provider === 'string' &&
95
+ typeof seg.model === 'string' &&
96
+ Array.isArray(seg.calls))
97
+ .map((seg) => ({
98
+ ...seg,
99
+ calls: seg.calls
100
+ .filter((c) => {
101
+ const ts = c?.timestamp;
102
+ return typeof ts === 'number' && ts > 0 && ts <= maxMsgTs;
103
+ })
104
+ .map(({ id: _stripped, ...rest }) => rest),
105
+ }));
106
+ }
59
107
  // P2:导出含分支树结构(parentId),导入为独立根会话——告知前端提示扁平化。
60
108
  const flattened = typeof body.session.parentId === 'string' && body.session.parentId.length > 0;
61
109
  const result = await importSessionData(ctx.db, {
@@ -171,16 +219,34 @@ function createSessionRoute(ctx) {
171
219
  const session = await getSession(ctx.db, id);
172
220
  if (!session)
173
221
  return apiError(c, 404, 'NOT_FOUND', 'Session not found');
222
+ // P3 口径统一:回收站会话不可改名(与 GET /:id、/messages 的 404 语义一致)。
223
+ if (session.deletedAt) {
224
+ return apiError(c, 404, 'NOT_FOUND', '会话不存在或已删除');
225
+ }
174
226
  await updateSessionTitle(ctx.db, id, title);
175
227
  return c.json({ ok: true, title });
176
228
  });
177
229
  // 分支会话:未指定 messageIndex 时默认在最新一条消息处分叉(fork=完整副本语义)
178
230
  app.post('/:id/fork', async (c) => {
179
231
  const id = c.req.param('id');
232
+ // P3:回收站会话不可分支——与 GET /:id、PATCH /:id 的 404 口径一致,
233
+ // 否则 API 直调 fork 已删除会话即可复活其内容、绕过回收站语义。
234
+ const existing = await getSession(ctx.db, id);
235
+ if (!existing)
236
+ return apiError(c, 404, 'NOT_FOUND', 'Session not found');
237
+ if (existing.deletedAt) {
238
+ return apiError(c, 404, 'NOT_FOUND', '会话不存在或已删除');
239
+ }
180
240
  // P3:fork 复制消息树,与正在写库的活跃 run 存在竞态——先拒绝。
181
- if (ctx.agentManager.get(id)) {
241
+ // 守卫与斜杠 /fork 同口径(hasBusySession):此前只查 get(),
242
+ // 漏 tryAcquire→register 占位窗口与工作流运行期。
243
+ const busy = hasBusySession(ctx, id);
244
+ if (busy === 'run' || busy === 'workflow') {
182
245
  return apiError(c, 409, 'RUN_ACTIVE', '该会话已有进行中的对话,请等待完成或中止后再分支');
183
246
  }
247
+ if (busy === 'starting') {
248
+ return apiError(c, 409, 'RUN_STARTING', '该会话的对话正在启动,请稍后重试');
249
+ }
184
250
  const body = await c.req.json().catch(() => ({}));
185
251
  let messageIndex = body.messageIndex;
186
252
  if (messageIndex === undefined || !Number.isFinite(messageIndex)) {
@@ -202,11 +268,25 @@ function createSessionRoute(ctx) {
202
268
  return apiError(c, 404, 'NOT_FOUND', 'Session not found');
203
269
  }
204
270
  });
205
- // 删除会话(软删除:级联其 fork 后代进入回收站,30 天后物理清除)。
271
+ // 删除会话(软删除:级联其 fork 后代进入回收站,60 天后物理清除)。
206
272
  // P2 修复:删除运行中的会话前先中止其活跃 run(含子 agent)——
207
273
  // 否则 run 继续向已删除会话写入消息,用户以为已删除而 agent 仍在执行。
274
+ // P1 工作流路由:发起会话有进行中的工作流时一并中止 workflow run
275
+ //(此前仅中止会话自身 run + 子 agent,工作流继续后台执行且 busy 映射悬挂)。
208
276
  app.delete('/:id', async (c) => {
209
277
  const id = c.req.param('id');
278
+ // starting 占位无法 abort(尚无 run)——删除会让随后 register 的 run 写入
279
+ // 已软删除会话。与控制端点同口径:占位期 409,稍后重试。
280
+ if (ctx.agentManager.isStarting(id)) {
281
+ return apiError(c, 409, 'RUN_STARTING', '该会话的对话正在启动,请稍后重试');
282
+ }
283
+ const busyWorkflowId = ctx.workflowBusyBySession.get(id);
284
+ if (busyWorkflowId) {
285
+ ctx.agentManager.abort(busyWorkflowId);
286
+ for (const child of ctx.agentManager.children(busyWorkflowId)) {
287
+ ctx.agentManager.abort(child.sessionId);
288
+ }
289
+ }
210
290
  ctx.agentManager.abort(id);
211
291
  for (const child of ctx.agentManager.children(id)) {
212
292
  ctx.agentManager.abort(child.sessionId);
@@ -229,6 +309,16 @@ function createSessionRoute(ctx) {
229
309
  return apiError(c, 404, 'NOT_FOUND', '会话不存在或不在回收站');
230
310
  return c.json({ ok: true, deleted: count });
231
311
  });
312
+ // P3-9:物理删除真空会话(无消息且无子会话)——首条消息发送失败后的前端
313
+ // 清理路径。此前走 DELETE /:id 软删除,空壳进回收站污染列表;非空会话 409,
314
+ // 物理删除不绕过回收站语义。
315
+ app.delete('/:id/empty', async (c) => {
316
+ const ok = await purgeEmptySession(ctx.db, c.req.param('id'));
317
+ if (!ok) {
318
+ return apiError(c, 409, 'SESSION_NOT_EMPTY', '会话不存在或非空(请使用常规删除,移入回收站)');
319
+ }
320
+ return c.json({ ok: true });
321
+ });
232
322
  // 会话归档列表(compaction/squash/shake/clear 的原始内容);?q= 搜索归档文本
233
323
  app.get('/:id/archives', async (c) => {
234
324
  const id = c.req.param('id');
@@ -257,14 +347,40 @@ function createSessionRoute(ctx) {
257
347
  }
258
348
  if (!session)
259
349
  return apiError(c, 404, 'NOT_FOUND', 'Session not found');
350
+ // P3 口径统一:回收站会话不可导出——与 GET /:id、PATCH /:id、/messages、
351
+ // /llm-details 的 404 语义一致。回收站内容经「恢复」后即可导出。
352
+ if (session.deletedAt) {
353
+ return apiError(c, 404, 'NOT_FOUND', '会话不存在或已删除');
354
+ }
260
355
  const messages = await getMessages(ctx.db, id);
261
356
  const archives = await listArchives(ctx.db, id);
357
+ // P2-6:归档的 fileSnapshots 含被压缩进上下文的文件内容——导出 JSON 常被
358
+ // 分享/迁移,默认剥离(隐私优先);显式 ?includeSnapshots=1 才携带。
359
+ const includeSnapshots = c.req.query('includeSnapshots') === '1';
360
+ // P2-1:权限态(permissionMode/alwaysAllow)与 fileSnapshots 同口径默认剥离。
361
+ // 导出 JSON 被分享时不应携带用户的工具授权白名单结构;显式
362
+ // ?includePermissions=1 才保留(配合导入侧 importPermissions=true 完成
363
+ // 同机备份/迁移的权限态闭环——两开关对称)。
364
+ const includePermissions = c.req.query('includePermissions') === '1';
365
+ const exportSession = includePermissions
366
+ ? session
367
+ : {
368
+ ...session,
369
+ metadata: (() => {
370
+ const m = { ...(session.metadata ?? {}) };
371
+ delete m.permissionMode;
372
+ delete m.alwaysAllow;
373
+ return m;
374
+ })(),
375
+ };
262
376
  return c.json({
263
377
  version: 1,
264
378
  exportedAt: new Date().toISOString(),
265
- session,
379
+ session: exportSession,
266
380
  messages,
267
- archives,
381
+ archives: includeSnapshots
382
+ ? archives
383
+ : archives.map((a) => ({ ...a, fileSnapshots: undefined })),
268
384
  });
269
385
  });
270
386
  // 恢复会话(从回收站还原;仅还原该会话本身)。
@@ -272,23 +388,31 @@ function createSessionRoute(ctx) {
272
388
  // 项目记录并重新归属——否则恢复成功但会话不属于任何项目视图,UI 不可达。
273
389
  // P1-2:body.projectId 提供时(当前项目视图),目录失效无法重建归属的孤儿
274
390
  // 会话直接归属到该请求项目,恢复即可达。
391
+ // P1-3:静默重建项目改为明示——响应带 recreatedProject(重建的项目信息),
392
+ // 前端据此展示「已重新创建项目 X」;同时支持 restoreMode='current-project'
393
+ // 显式跳过重建、直接归属到请求项目(用户在确认框中二选一)。
275
394
  app.post('/:id/restore', async (c) => {
276
395
  const id = c.req.param('id');
277
396
  const body = (await c.req.json().catch(() => ({})));
278
397
  const requestProjectId = typeof body.projectId === 'string' && body.projectId ? body.projectId : undefined;
398
+ const preferCurrentProject = body.restoreMode === 'current-project';
279
399
  const result = await restoreSessionCore(ctx.db, id);
280
400
  if (!result.restored)
281
401
  return apiError(c, 404, 'NOT_FOUND', '会话不存在或未删除');
282
402
  const session = await getSession(ctx.db, id);
283
403
  let rebound = false;
284
404
  let orphaned = false;
405
+ let recreatedProject = null;
285
406
  if (session && !session.projectId) {
286
407
  const wt = session.worktreePath;
287
- if (wt && existsSync(wt)) {
408
+ // P1-3:用户选择「归属到当前项目」时跳过自动重建(删除项目是用户意图,
409
+ // 不静默复活)。否则目录仍存在则重建项目记录(明示在响应中)。
410
+ if (!preferCurrentProject && wt && existsSync(wt)) {
288
411
  try {
289
412
  const project = await fromDirectory(ctx.db, wt);
290
413
  await ctx.db.db.update(sessions).set({ projectId: project.id }).where(eq(sessions.id, id));
291
414
  rebound = true;
415
+ recreatedProject = { id: project.id, name: project.name };
292
416
  }
293
417
  catch {
294
418
  orphaned = true;
@@ -297,7 +421,8 @@ function createSessionRoute(ctx) {
297
421
  else {
298
422
  orphaned = true;
299
423
  }
300
- // P1-2:目录失效且请求方提供了项目上下文 → 归属到该请求项目
424
+ // P1-2:目录失效且请求方提供了项目上下文 → 归属到该请求项目;
425
+ // P1-3:restoreMode='current-project' 时无论目录是否存在都归属请求项目。
301
426
  if (orphaned && requestProjectId) {
302
427
  const project = await getProject(ctx.db, requestProjectId);
303
428
  if (project) {
@@ -311,6 +436,7 @@ function createSessionRoute(ctx) {
311
436
  ok: true,
312
437
  rebound,
313
438
  orphaned,
439
+ recreatedProject,
314
440
  restoredAncestorCount: result.restoredAncestorCount,
315
441
  crossedBatchAncestor: result.crossedBatchAncestor,
316
442
  // A2:批次不同的已删后代滞留在回收站,显式告知数量供前端提示单独恢复。
@@ -320,6 +446,15 @@ function createSessionRoute(ctx) {
320
446
  // 会话归属变更(P1-2):孤儿会话归属到指定项目,恢复后即可达。
321
447
  app.post('/:id/rebind', async (c) => {
322
448
  const id = c.req.param('id');
449
+ // P3 口径统一:归属变更与 fork/compact/shake 同用 hasBusySession 守卫——
450
+ // 活跃 run 的 cwd 在启动时解析,rebind 会改变后续轮次的工作目录语义。
451
+ const busy = hasBusySession(ctx, id);
452
+ if (busy === 'run' || busy === 'workflow') {
453
+ return apiError(c, 409, 'RUN_ACTIVE', '该会话已有进行中的对话,请等待完成或中止后再更改归属');
454
+ }
455
+ if (busy === 'starting') {
456
+ return apiError(c, 409, 'RUN_STARTING', '该会话的对话正在启动,请稍后重试');
457
+ }
323
458
  const body = (await c.req.json().catch(() => ({})));
324
459
  const projectId = typeof body.projectId === 'string' && body.projectId ? body.projectId : '';
325
460
  if (!projectId)
@@ -353,6 +488,11 @@ function createSessionRoute(ctx) {
353
488
  const run = ctx.agentManager.get(c.req.param('id'));
354
489
  if (run)
355
490
  return c.json(run.state.segments);
491
+ const session = await getSession(ctx.db, c.req.param('id'));
492
+ // P3 口径统一:回收站会话的持久化分段不再下发(会话本身已 404)。
493
+ if (!session || session.deletedAt) {
494
+ return apiError(c, 404, 'NOT_FOUND', '会话不存在或已删除');
495
+ }
356
496
  const persisted = await getLLMSegments(ctx.db, c.req.param('id'));
357
497
  return c.json(persisted);
358
498
  });
@@ -360,10 +500,15 @@ function createSessionRoute(ctx) {
360
500
  // P1 多项目:keepRecentTokens 按会话项目配置解析(此前用启动目录配置)。
361
501
  app.post('/:id/compact', async (c) => {
362
502
  const id = c.req.param('id');
363
- // P3:压缩改写消息树,与活跃 run 的并发写入存在竞态——先拒绝。
364
- if (ctx.agentManager.get(id)) {
503
+ // P2-5 修复:守卫收敛 hasBusySession(与斜杠 /compact、REST fork 同口径)——
504
+ // 此前只查 get(),漏 tryAcquire→register 占位窗口与工作流运行期两态。
505
+ const busy = hasBusySession(ctx, id);
506
+ if (busy === 'run' || busy === 'workflow') {
365
507
  return apiError(c, 409, 'RUN_ACTIVE', '该会话已有进行中的对话,请等待完成或中止后再压缩');
366
508
  }
509
+ if (busy === 'starting') {
510
+ return apiError(c, 409, 'RUN_STARTING', '该会话的对话正在启动,请稍后重试');
511
+ }
367
512
  let session;
368
513
  try {
369
514
  session = await getSession(ctx.db, id);
@@ -410,10 +555,18 @@ function createSessionRoute(ctx) {
410
555
  }
411
556
  });
412
557
  // 获取会话状态:内存有活跃 run → 返回其 status;否则查 DB lastRun。
558
+ // P1 工作流路由:发起会话在 workflowBusyBySession 中时状态取实际 workflow run
559
+ //(此前工作流运行期间状态栏显示 idle)。
413
560
  // lastRun.status='running' 且无活跃 run → 服务重启被中断。
414
561
  // lastRun.status='paused' 但无活跃 run(热更新/重启后 run 状态未迁移)→ 同样按中断处理:
415
562
  // agent 内存态已丢失,resume 端点无法恢复,唯一可行路径是重发上一条消息。
416
563
  app.get('/:id/status', async (c) => {
564
+ const busyWorkflowId = ctx.workflowBusyBySession.get(c.req.param('id'));
565
+ if (busyWorkflowId) {
566
+ const wfRun = ctx.agentManager.get(busyWorkflowId);
567
+ if (wfRun)
568
+ return c.json(wfRun.state.status);
569
+ }
417
570
  const run = ctx.agentManager.get(c.req.param('id'));
418
571
  if (run)
419
572
  return c.json(run.state.status);
@@ -447,10 +600,15 @@ function createSessionRoute(ctx) {
447
600
  // shake apply:归档原始内容 + 原位替换
448
601
  app.post('/:id/shake/apply', async (c) => {
449
602
  const id = c.req.param('id');
450
- // P3:shake 原位改写消息,与活跃 run 的并发写入存在竞态——先拒绝。
451
- if (ctx.agentManager.get(id)) {
603
+ // P2-5 修复:守卫收敛 hasBusySession(与 compact/fork 同口径),
604
+ // 补 starting 占位与工作流运行期两态。
605
+ const busy = hasBusySession(ctx, id);
606
+ if (busy === 'run' || busy === 'workflow') {
452
607
  return apiError(c, 409, 'RUN_ACTIVE', '该会话已有进行中的对话,请等待完成或中止后再 Shake');
453
608
  }
609
+ if (busy === 'starting') {
610
+ return apiError(c, 409, 'RUN_STARTING', '该会话的对话正在启动,请稍后重试');
611
+ }
454
612
  let session;
455
613
  try {
456
614
  session = await getSession(ctx.db, id);
@@ -500,7 +658,13 @@ function createSessionRoute(ctx) {
500
658
  });
501
659
  // 记录会话打开(更新 metadata.lastOpenedAt,用于会话列表按最近打开排序)
502
660
  app.post('/:id/open', async (c) => {
503
- await touchLastOpened(ctx.db, c.req.param('id'));
661
+ const id = c.req.param('id');
662
+ // P3 口径统一:不存在/回收站会话不再静默返回成功(与 GET /:id 404 一致)。
663
+ const session = await getSession(ctx.db, id);
664
+ if (!session || session.deletedAt) {
665
+ return apiError(c, 404, 'NOT_FOUND', '会话不存在或已删除');
666
+ }
667
+ await touchLastOpened(ctx.db, id);
504
668
  return c.json({ ok: true });
505
669
  });
506
670
  // 获取分支
@@ -1,18 +1,37 @@
1
1
  import { Hono } from 'hono';
2
+ import { loadConfigScopes, mergeConfig } from '../../core/config.js';
3
+ import { getProject } from '../../project/project.js';
4
+ import { resolveEnabledToolNames } from '../../tools/index.js';
2
5
  import { listTools } from '../../tools/registry.js';
3
6
  import { apiError } from '../middleware/error.js';
4
7
  function createToolRoute(ctx) {
5
8
  const app = new Hono();
6
- // 列出可用工具(不含 execute 函数)
7
- app.get('/', (c) => {
8
- const tools = listTools(ctx.toolRegistry, { config: {}, cwd: ctx.cwd });
9
- const serializable = tools.map((t) => ({
9
+ // 列出可用工具(不含 execute 函数)。
10
+ // P2 交集语义配套:列表按 config.tools.enabled 过滤——设置页禁用某工具后,
11
+ // 输入区工具开关不再提供该工具,避免「勾选了却被服务端静默丢弃」的脱节。
12
+ // ?projectId= 时按该项目合并配置过滤(与 chat 路由的工具解析同口径)。
13
+ app.get('/', async (c) => {
14
+ let config = ctx.config;
15
+ const projectId = c.req.query('projectId');
16
+ if (projectId) {
17
+ const project = await getProject(ctx.db, projectId);
18
+ if (!project)
19
+ return apiError(c, 404, 'PROJECT_NOT_FOUND', '项目不存在');
20
+ const projectScope = loadConfigScopes(project.worktree).project;
21
+ if (projectScope)
22
+ config = mergeConfig(ctx.config, projectScope);
23
+ }
24
+ const enabledNames = resolveEnabledToolNames(ctx.toolRegistry, config);
25
+ const enabledSet = new Set(enabledNames);
26
+ const tools = listTools(ctx.toolRegistry, { config: {}, cwd: ctx.cwd })
27
+ .filter((t) => enabledSet.has(t.name))
28
+ .map((t) => ({
10
29
  name: t.name,
11
30
  description: t.description,
12
31
  parameters: t.parameters,
13
32
  permission: t.permission,
14
33
  }));
15
- return c.json(serializable);
34
+ return c.json(tools);
16
35
  });
17
36
  // 确认工具执行权限
18
37
  app.post('/confirm', async (c) => {