c0de-agent 1.10.0 → 1.12.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 (124) hide show
  1. package/README.md +28 -6
  2. package/dist/cli/commands/acp.js +3 -3
  3. package/dist/cli/commands/auth.d.ts +17 -0
  4. package/dist/cli/commands/auth.js +73 -0
  5. package/dist/cli/commands/chat.js +95 -1
  6. package/dist/cli/commands/config.d.ts +2 -9
  7. package/dist/cli/commands/config.js +25 -54
  8. package/dist/cli/commands/init.js +4 -2
  9. package/dist/cli/commands/plugin.js +8 -4
  10. package/dist/cli/commands/sessions.d.ts +10 -0
  11. package/dist/cli/commands/sessions.js +94 -0
  12. package/dist/cli/commands/update.js +18 -10
  13. package/dist/cli/index.js +51 -4
  14. package/dist/cli/modes/print.js +19 -2
  15. package/dist/core/compact.d.ts +2 -0
  16. package/dist/core/compact.js +1 -0
  17. package/dist/core/config-path.d.ts +9 -0
  18. package/dist/core/config-path.js +48 -0
  19. package/dist/core/config.d.ts +20 -3
  20. package/dist/core/config.js +103 -14
  21. package/dist/core/index.d.ts +1 -1
  22. package/dist/core/index.js +1 -1
  23. package/dist/core/loop/compaction.d.ts +2 -1
  24. package/dist/core/loop/compaction.js +19 -6
  25. package/dist/core/loop/stream-collect.js +9 -1
  26. package/dist/core/loop/subagent.js +23 -5
  27. package/dist/core/loop.js +13 -3
  28. package/dist/core/redact.d.ts +11 -0
  29. package/dist/core/redact.js +49 -0
  30. package/dist/core/slash.js +167 -63
  31. package/dist/core/title.js +22 -1
  32. package/dist/core/types.d.ts +4 -0
  33. package/dist/db/client.d.ts +4 -0
  34. package/dist/db/client.js +5 -0
  35. package/dist/db/schema.d.ts +51 -0
  36. package/dist/db/schema.js +6 -0
  37. package/dist/kanban/index.d.ts +1 -1
  38. package/dist/kanban/index.js +1 -1
  39. package/dist/kanban/store.d.ts +5 -1
  40. package/dist/kanban/store.js +76 -2
  41. package/dist/llm/index.d.ts +1 -1
  42. package/dist/llm/index.js +1 -1
  43. package/dist/llm/provider.js +1 -1
  44. package/dist/llm/retry.d.ts +5 -2
  45. package/dist/llm/retry.js +6 -5
  46. package/dist/llm/routing.d.ts +24 -1
  47. package/dist/llm/routing.js +29 -1
  48. package/dist/project/index.d.ts +1 -1
  49. package/dist/project/index.js +1 -1
  50. package/dist/project/project.d.ts +14 -0
  51. package/dist/project/project.js +77 -1
  52. package/dist/server/agent-manager.d.ts +14 -0
  53. package/dist/server/agent-manager.js +41 -0
  54. package/dist/server/app.js +12 -2
  55. package/dist/server/auth-manager.d.ts +85 -0
  56. package/dist/server/auth-manager.js +289 -0
  57. package/dist/server/context.js +4 -0
  58. package/dist/server/middleware/auth.d.ts +4 -3
  59. package/dist/server/middleware/auth.js +5 -5
  60. package/dist/server/permission/interactive.d.ts +7 -2
  61. package/dist/server/permission/interactive.js +25 -5
  62. package/dist/server/permission/store.d.ts +15 -1
  63. package/dist/server/permission/store.js +31 -2
  64. package/dist/server/registry-config.d.ts +14 -0
  65. package/dist/server/registry-config.js +44 -0
  66. package/dist/server/routes/auth.d.ts +4 -0
  67. package/dist/server/routes/auth.js +124 -0
  68. package/dist/server/routes/chat.d.ts +18 -1
  69. package/dist/server/routes/chat.js +295 -37
  70. package/dist/server/routes/commands.d.ts +4 -2
  71. package/dist/server/routes/commands.js +10 -3
  72. package/dist/server/routes/config.js +143 -15
  73. package/dist/server/routes/files.js +22 -4
  74. package/dist/server/routes/kanban.js +68 -6
  75. package/dist/server/routes/permissions.d.ts +0 -1
  76. package/dist/server/routes/permissions.js +153 -4
  77. package/dist/server/routes/project.js +106 -3
  78. package/dist/server/routes/provider.js +48 -3
  79. package/dist/server/routes/session.js +319 -19
  80. package/dist/server/routes/terminal.js +17 -2
  81. package/dist/server/routes/update.d.ts +11 -5
  82. package/dist/server/routes/update.js +98 -16
  83. package/dist/server/server.d.ts +4 -12
  84. package/dist/server/server.js +235 -100
  85. package/dist/server/types.d.ts +15 -1
  86. package/dist/session/archive.d.ts +4 -2
  87. package/dist/session/archive.js +13 -1
  88. package/dist/session/branch.d.ts +1 -0
  89. package/dist/session/branch.js +32 -7
  90. package/dist/session/import.d.ts +44 -0
  91. package/dist/session/import.js +83 -0
  92. package/dist/session/index.d.ts +1 -1
  93. package/dist/session/index.js +1 -1
  94. package/dist/session/jobs.d.ts +12 -0
  95. package/dist/session/jobs.js +53 -0
  96. package/dist/session/message.d.ts +7 -1
  97. package/dist/session/message.js +21 -1
  98. package/dist/session/session.d.ts +143 -6
  99. package/dist/session/session.js +561 -11
  100. package/dist/session/snapshot.d.ts +5 -2
  101. package/dist/session/snapshot.js +10 -2
  102. package/dist/session/types.d.ts +12 -2
  103. package/dist/shared/types/agent.d.ts +22 -0
  104. package/dist/shared/types/config.d.ts +10 -1
  105. package/dist/shared/types/kanban.d.ts +13 -0
  106. package/dist/shared/types/message.d.ts +15 -2
  107. package/dist/tools/index.js +2 -3
  108. package/dist/tools/websearch/index.js +6 -3
  109. package/dist/update/index.d.ts +3 -3
  110. package/dist/update/index.js +2 -2
  111. package/dist/update/ipc.d.ts +19 -2
  112. package/dist/update/ipc.js +43 -6
  113. package/dist/update/snapshot.d.ts +5 -0
  114. package/dist/update/snapshot.js +10 -0
  115. package/dist/update/updater.d.ts +69 -8
  116. package/dist/update/updater.js +263 -32
  117. package/drizzle/0006_sour_susan_delgado.sql +2 -0
  118. package/drizzle/0007_same_wilson_fisk.sql +2 -0
  119. package/drizzle/0008_tranquil_lily_hollister.sql +1 -0
  120. package/drizzle/meta/0006_snapshot.json +867 -0
  121. package/drizzle/meta/0007_snapshot.json +869 -0
  122. package/drizzle/meta/0008_snapshot.json +875 -0
  123. package/drizzle/meta/_journal.json +21 -0
  124. package/package.json +41 -42
@@ -1,11 +1,18 @@
1
1
  import { Hono } from 'hono';
2
2
  import { createSlashRegistry } from '../../core/slash.js';
3
- /** GET / — 返回内置斜杠命令列表(name/description/argsHint)。 */
4
- function createCommandsRoute(_ctx) {
3
+ /** GET / — 返回内置斜杠命令列表(name/description/argsHint)。
4
+ * 按 config.slashCommands.enabled 过滤:enabled 为空 = 全部启用;
5
+ * 否则只返回启用项,保证弹窗发现性 = 可执行性(避免展示必被拒的命令)。 */
6
+ function createCommandsRoute(ctx) {
5
7
  const app = new Hono();
6
8
  app.get('/', (c) => {
7
9
  const registry = createSlashRegistry();
8
- const commands = registry.list().map((cmd) => ({
10
+ const enabledList = ctx.config.slashCommands?.enabled ?? [];
11
+ const enabledSet = new Set(enabledList.map((n) => (n.startsWith('/') ? n.slice(1) : n)));
12
+ const commands = registry
13
+ .list()
14
+ .filter((cmd) => enabledSet.size === 0 || enabledSet.has(cmd.name))
15
+ .map((cmd) => ({
9
16
  name: cmd.name,
10
17
  description: cmd.description,
11
18
  ...(cmd.argsHint ? { argsHint: cmd.argsHint } : {}),
@@ -1,30 +1,158 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
1
3
  import { Hono } from 'hono';
2
- import { loadConfigScopes, mergeConfig, mergeRaw, saveConfigScoped } from '../../core/config.js';
3
- import { encryptSecret, isEncryptedSecret } from '../../core/secret.js';
4
+ import { applyScopedPatch, collectUnknownConfigKeys, KNOWN_CONFIG_KEYS, loadConfigScopes, mergeConfig, saveConfigScoped, } from '../../core/config.js';
5
+ import { containsSecrets } from '../../core/redact.js';
6
+ import { decryptSecret, encryptSecret, isEncryptedSecret } from '../../core/secret.js';
7
+ import { getProject } from '../../project/project.js';
8
+ import { apiError } from '../middleware/error.js';
4
9
  import { syncRegistryFromConfig } from '../server.js';
10
+ /** 检查每个 provider 的 apiKey 能否在本机解密(机器绑定密钥换机/容器重建后会失败)。 */
11
+ function providerApiKeyWarnings(providers) {
12
+ const warnings = [];
13
+ for (const p of providers) {
14
+ if (!p.apiKey || !isEncryptedSecret(p.apiKey))
15
+ continue;
16
+ try {
17
+ decryptSecret(p.apiKey);
18
+ }
19
+ catch {
20
+ warnings.push(`provider "${p.name || ''}" 的 apiKey 无法在本机解密(配置来自其他机器),请重新设置`);
21
+ }
22
+ }
23
+ return warnings;
24
+ }
25
+ /** 项目级配置含密钥且位于 git 仓库内时提示 .gitignore(防误提交)。 */
26
+ function projectConfigGitWarning(project, cwd) {
27
+ if (!project || !containsSecrets(project))
28
+ return null;
29
+ let dir = cwd;
30
+ while (true) {
31
+ if (existsSync(join(dir, '.git'))) {
32
+ return ('项目级配置(.c0de/config.json)含 API Key/Token 且位于 git 仓库内,' +
33
+ '建议将 .c0de/ 加入 .gitignore,防止密钥被误提交。');
34
+ }
35
+ const parent = dirname(dir);
36
+ if (parent === dir)
37
+ return null;
38
+ dir = parent;
39
+ }
40
+ }
41
+ /**
42
+ * 解析配置读取/写入的目标目录(P1-1 多项目配置贯通):
43
+ * - projectId 提供且存在 → 该项目 worktree(配置读写与设置页展示都对准该项目)。
44
+ * - 否则 → 服务启动目录(向后兼容:serve cwd 项目与全局作用域)。
45
+ */
46
+ async function resolveConfigDir(ctx, projectId) {
47
+ if (projectId) {
48
+ const project = await getProject(ctx.db, projectId);
49
+ if (!project)
50
+ return null;
51
+ return { dir: project.worktree, isProjectScoped: true };
52
+ }
53
+ return { dir: ctx.cwd, isProjectScoped: false };
54
+ }
5
55
  function createConfigRoute(ctx) {
6
56
  const app = new Hono();
7
- app.get('/', (c) => {
8
- return c.json(ctx.config);
57
+ // GET / — 合并后配置 + 作用域信息 + apiKey 解密警告 + git 误提交警告。
58
+ // ?projectId= 时读取该项目的 global+project 作用域(默认读取服务启动目录项目,
59
+ // 复用服务内存合并配置 ctx.config)。
60
+ app.get('/', async (c) => {
61
+ const projectId = c.req.query('projectId');
62
+ const target = await resolveConfigDir(ctx, projectId);
63
+ if (!target)
64
+ return apiError(c, 404, 'PROJECT_NOT_FOUND', '项目不存在');
65
+ const scopes = loadConfigScopes(target.dir);
66
+ const config = projectId ? mergeConfig(scopes.global, scopes.project) : ctx.config;
67
+ // 未知顶层键告警并入 warnings:用户在设置页即可看到拼写错误/过时键,不必看终端日志。
68
+ const unknownWarnings = [
69
+ ...collectUnknownConfigKeys(scopes.global).map((k) => `全局配置含未知键 "${k}",不会生效`),
70
+ ...collectUnknownConfigKeys(scopes.project).map((k) => `项目配置含未知键 "${k}",不会生效`),
71
+ ];
72
+ return c.json({
73
+ config,
74
+ scopes: {
75
+ global: scopes.global ?? null,
76
+ project: scopes.project ?? null,
77
+ },
78
+ warnings: [...providerApiKeyWarnings(config.providers), ...unknownWarnings],
79
+ gitWarning: projectConfigGitWarning(scopes.project, target.dir),
80
+ projectDir: target.isProjectScoped ? target.dir : undefined,
81
+ });
9
82
  });
83
+ // PATCH / — 按作用域写入。body.scope: 'global' | 'project'(默认 project,兼容旧客户端)。
84
+ // body.projectId:目标项目(P1-1;缺省 = 服务启动目录项目)。
85
+ // 仅当写入目标是服务启动目录项目时同步服务级内存配置与 LLM registry——
86
+ // 其他项目的配置不污染服务级 provider 注册表(会话级按需解析)。
10
87
  app.patch('/', async (c) => {
11
- const patch = (await c.req.json());
88
+ const body = (await c.req.json());
89
+ const scope = body.scope === 'global' ? 'global' : 'project';
90
+ const { scope: _omit, projectId, ...patch } = body;
91
+ // 未知顶层键拒绝:拼写错误/过时键经此路径写入只会埋雷(永不生效且难发现)。
92
+ const unknown = collectUnknownConfigKeys(patch);
93
+ if (unknown.length > 0) {
94
+ return apiError(c, 400, 'UNKNOWN_CONFIG_KEYS', `未知配置键:${unknown.join(', ')}。有效顶层键:${[...KNOWN_CONFIG_KEYS].join(', ')}`);
95
+ }
12
96
  // spec §24.2:provider apiKey 落盘前加密,明文不持久化。
13
97
  // 已加密(enc: 前缀)或无 apiKey 的透传。
14
98
  if (Array.isArray(patch.providers)) {
15
99
  patch.providers = patch.providers.map((p) => p.apiKey && !isEncryptedSecret(p.apiKey) ? { ...p, apiKey: encryptSecret(p.apiKey) } : p);
16
100
  }
17
- // 按作用域最小落盘:patch 只合并进项目作用域原始文件,
18
- // 不把合并结果(含默认值/global 配置)整体序列化进项目文件。
19
- const scopes = loadConfigScopes(ctx.cwd);
20
- const nextProject = mergeRaw(scopes.project ?? {}, patch);
21
- ctx.config = mergeConfig(scopes.global, nextProject);
22
- // providers 变更后原地同步 registry,使运行中的连接立即生效
23
- syncRegistryFromConfig(ctx.llmRegistry, ctx.config);
24
- await saveConfigScoped('project', ctx.cwd, nextProject).catch(() => {
25
- // 保存失败不影响内存配置
101
+ // 按作用域最小落盘:patch 只合并进指定作用域原始文件,
102
+ // 不把合并结果(含默认值/另一作用域配置)整体序列化进文件。
103
+ // null 值 = 删除该作用域中的键(回落到另一作用域/默认值)。
104
+ const target = await resolveConfigDir(ctx, projectId);
105
+ if (!target)
106
+ return apiError(c, 404, 'PROJECT_NOT_FOUND', '项目不存在');
107
+ const scopes = loadConfigScopes(target.dir);
108
+ const nextScoped = scope === 'global'
109
+ ? applyScopedPatch(scopes.global ?? {}, patch)
110
+ : applyScopedPatch(scopes.project ?? {}, patch);
111
+ // P2-3:落盘失败必须反馈给前端(此前静默吞掉,UI 显示已保存但重启后丢失)。
112
+ // 先落盘、成功后才更新内存配置与 registry,保证「已保存」反馈与磁盘状态一致。
113
+ try {
114
+ await saveConfigScoped(scope, target.dir, nextScoped);
115
+ }
116
+ catch (err) {
117
+ return apiError(c, 500, 'CONFIG_SAVE_FAILED', `配置保存失败:${err instanceof Error ? err.message : String(err)}`);
118
+ }
119
+ const isServerCwdProject = target.dir === ctx.cwd;
120
+ if (isServerCwdProject || scope === 'global') {
121
+ // global 作用域写入影响所有项目(含服务级 provider 注册表),
122
+ // 按服务启动目录作用域重建 ctx.config 并同步 registry。
123
+ const serverScopes = loadConfigScopes(ctx.cwd);
124
+ ctx.config = mergeConfig(serverScopes.global, serverScopes.project);
125
+ syncRegistryFromConfig(ctx.llmRegistry, ctx.config);
126
+ // P1 修复:权限默认模式运行时同步——设置页保存后立即生效,
127
+ // 与草稿页底栏开关(PUT /api/permissions,既持久化又即时生效)语义一致。
128
+ // 此前仅落盘、ctx.permissionMode 仍为启动值,UI 显示已保存但弹窗照旧。
129
+ ctx.permissionMode = ctx.config.permission.defaultMode;
130
+ // 项目级注册表基于全局作用域构建,global 变更后全部失效。
131
+ ctx.projectRegistries?.clear();
132
+ }
133
+ else if (projectId) {
134
+ // 项目作用域写入:该项目的会话级注册表缓存失效。
135
+ ctx.projectRegistries?.delete(projectId);
136
+ }
137
+ const freshScopes = loadConfigScopes(target.dir);
138
+ const merged = mergeConfig(freshScopes.global, freshScopes.project);
139
+ // 启动时一次性读取的配置:运行时修改不生效——告知前端提示重启。
140
+ // - security:authManager/CORS 在启动时构建
141
+ // - update:调度器 interval 与 handoff server 在启动时构建
142
+ // - plugins:hookRunner/插件生命周期在启动时初始化
143
+ // 仅当写入影响运行中的服务级配置(服务启动目录项目或 global 作用域)时提示。
144
+ const needsRestart = (isServerCwdProject || scope === 'global') &&
145
+ (patch.security !== undefined || patch.update !== undefined || patch.plugins !== undefined);
146
+ return c.json({
147
+ config: merged,
148
+ scopes: {
149
+ global: freshScopes.global ?? null,
150
+ project: freshScopes.project ?? null,
151
+ },
152
+ warnings: providerApiKeyWarnings(merged.providers),
153
+ needsRestart,
154
+ projectDir: target.isProjectScoped ? target.dir : undefined,
26
155
  });
27
- return c.json(ctx.config);
28
156
  });
29
157
  return app;
30
158
  }
@@ -3,9 +3,12 @@ import { dirname, join, relative } from 'node:path';
3
3
  import { Hono } from 'hono';
4
4
  import trash from 'trash';
5
5
  import { createSummarizer } from '../../core/compact.js';
6
+ import { loadConfigScopes, mergeConfig } from '../../core/config.js';
7
+ import { buildFallbackChain } from '../../llm/routing.js';
6
8
  import { getProject } from '../../project/project.js';
7
9
  import { appendToGitignore, checkIgnored, checkoutGitBranch, createGitBranch, getGitBranch, getGitDiffSummary, getGitLastCommit, getGitStatus, listGitBranches, performGitCommit, } from '../../project/resolve.js';
8
10
  import { apiError } from '../middleware/error.js';
11
+ import { buildRegistryFromConfig } from '../registry-config.js';
9
12
  import { safeResolve } from '../util/safe-path.js';
10
13
  /** 递归搜索时跳过的目录(体积大/为元数据噪音,避免递归进入)。 */
11
14
  const SEARCH_SKIP_DIRS = new Set(['.git', 'node_modules']);
@@ -160,9 +163,16 @@ function createFilesRoute(ctx) {
160
163
  });
161
164
  }
162
165
  // --- 默认模式:LLM 生成 message + 检查可疑文件 ---
163
- const cm = ctx.config.commitModel;
164
- const provider = cm?.provider ?? ctx.config.defaultProvider;
165
- const model = cm?.model ?? ctx.config.defaultModel;
166
+ // P1-1:commitModel/defaultProvider 按项目配置解析(此前一律用服务启动目录配置)。
167
+ let projectConfig = ctx.config;
168
+ if (root !== ctx.cwd) {
169
+ const projectScope = loadConfigScopes(root).project;
170
+ if (projectScope)
171
+ projectConfig = mergeConfig(ctx.config, projectScope);
172
+ }
173
+ const cm = projectConfig.commitModel;
174
+ const provider = cm?.provider ?? projectConfig.defaultProvider;
175
+ const model = cm?.model ?? projectConfig.defaultModel;
166
176
  const prompt = `Based on the following git diff, generate a concise commit message in conventional-commits format (e.g. "feat: add login page").
167
177
 
168
178
  ALSO review the changed/new files: are any of them files that SHOULD be in .gitignore but are currently missing? (e.g. secrets, .env, build output, dependencies, temp files, large binaries)
@@ -175,7 +185,15 @@ If no files need ignoring, return an empty array for ignoreSuggestions.
175
185
  ${summary.diff.slice(0, 8000)}`;
176
186
  let raw;
177
187
  try {
178
- const summarizer = createSummarizer(ctx.llmRegistry, provider, model, { maxTokens: 400 });
188
+ // P1-1:项目级 provider 注册表(项目配置的 provider 才能路由)。
189
+ const registry = root !== ctx.cwd && projectConfig.providers.length > 0
190
+ ? buildRegistryFromConfig(projectConfig)
191
+ : ctx.llmRegistry;
192
+ const fallback = buildFallbackChain(projectConfig, provider, model);
193
+ const summarizer = createSummarizer(registry, provider, model, {
194
+ maxTokens: 400,
195
+ ...(fallback ? { fallback } : {}),
196
+ });
179
197
  raw = (await summarizer(prompt)).trim();
180
198
  }
181
199
  catch (err) {
@@ -1,7 +1,7 @@
1
1
  // REST routes for the kanban board — frontend UI uses these for drag-and-drop
2
2
  // card operations, board config, and initial load.
3
3
  import { Hono } from 'hono';
4
- import { createKanbanStore } from '../../kanban/index.js';
4
+ import { createKanbanStore, KanbanColumnInUseError } from '../../kanban/index.js';
5
5
  import { apiError } from '../middleware/error.js';
6
6
  function createKanbanRoute(ctx) {
7
7
  const app = new Hono();
@@ -12,16 +12,78 @@ function createKanbanRoute(ctx) {
12
12
  const board = await store.getBoard();
13
13
  return c.json(board);
14
14
  });
15
+ // GET /:projectId/export — 看板 JSON 导出(项目删除会永久级联删除看板,
16
+ // 会话有 60 天回收站而看板没有——导出是唯一的备份途径)。
17
+ app.get('/:projectId/export', async (c) => {
18
+ const projectId = c.req.param('projectId');
19
+ const store = createKanbanStore(ctx.db, projectId);
20
+ const board = await store.getBoard();
21
+ return c.json({
22
+ version: 1,
23
+ exportedAt: new Date().toISOString(),
24
+ projectId,
25
+ columns: board.columns,
26
+ labels: board.labels,
27
+ cards: board.cards.map(({ id: _id, boardId: _b, createdAt: _c, updatedAt: _u, ...card }) => card),
28
+ });
29
+ });
30
+ // POST /:projectId/import — 导入/替换整板(列+标签+卡片原子重建)。
31
+ app.post('/:projectId/import', async (c) => {
32
+ const projectId = c.req.param('projectId');
33
+ const body = (await c.req.json().catch(() => null));
34
+ if (body?.version !== 1 || !Array.isArray(body.columns) || !Array.isArray(body.cards)) {
35
+ return apiError(c, 400, 'INVALID_EXPORT', '无效的看板导出 JSON:需要 version/columns/cards 字段');
36
+ }
37
+ const columns = body.columns;
38
+ const labels = Array.isArray(body.labels) ? body.labels : [];
39
+ // 宽松校验卡片:只保留结构完整的条目(导入宁少勿坏,与会话导入一致)。
40
+ const cards = body.cards
41
+ .filter((card) => card !== null &&
42
+ typeof card === 'object' &&
43
+ typeof card.title === 'string' &&
44
+ card.title.length > 0 &&
45
+ typeof card.columnId === 'string' &&
46
+ typeof card.priority === 'string')
47
+ .map((card) => ({
48
+ title: card.title,
49
+ description: typeof card.description === 'string' ? card.description : null,
50
+ columnId: card.columnId,
51
+ priority: (card.priority === 'high' || card.priority === 'medium' || card.priority === 'low'
52
+ ? card.priority
53
+ : 'medium'),
54
+ position: typeof card.position === 'number' ? card.position : 0,
55
+ labels: Array.isArray(card.labels)
56
+ ? card.labels.filter((l) => typeof l === 'string')
57
+ : [],
58
+ }));
59
+ // 卡片只能落在导入的列上——悬空列会导致卡片静默不可见(与列删除保护同理)。
60
+ const columnIds = new Set(columns.map((col) => col.id));
61
+ const orphanCards = cards.filter((card) => !columnIds.has(card.columnId));
62
+ if (orphanCards.length > 0) {
63
+ return apiError(c, 400, 'INVALID_CARDS', `${orphanCards.length} 张卡片引用了不存在的列(${[...new Set(orphanCards.map((x) => x.columnId))].join(', ')})`);
64
+ }
65
+ const store = createKanbanStore(ctx.db, projectId);
66
+ const board = await store.replaceBoard({ columns, labels, cards });
67
+ return c.json({ ok: true, cardCount: board.cards.length });
68
+ });
15
69
  // PATCH /:projectId — update board columns/labels config
16
70
  app.patch('/:projectId', async (c) => {
17
71
  const projectId = c.req.param('projectId');
18
72
  const body = await c.req.json().catch(() => ({}));
19
73
  const store = createKanbanStore(ctx.db, projectId);
20
- const board = await store.updateBoard({
21
- ...(body.columns !== undefined && { columns: body.columns }),
22
- ...(body.labels !== undefined && { labels: body.labels }),
23
- });
24
- return c.json(board);
74
+ try {
75
+ const board = await store.updateBoard({
76
+ ...(body.columns !== undefined && { columns: body.columns }),
77
+ ...(body.labels !== undefined && { labels: body.labels }),
78
+ });
79
+ return c.json(board);
80
+ }
81
+ catch (err) {
82
+ if (err instanceof KanbanColumnInUseError) {
83
+ return apiError(c, 409, 'KANBAN_COLUMN_IN_USE', err.message);
84
+ }
85
+ throw err;
86
+ }
25
87
  });
26
88
  // POST /:projectId/cards — add a card
27
89
  app.post('/:projectId/cards', async (c) => {
@@ -1,5 +1,4 @@
1
1
  import { Hono } from 'hono';
2
2
  import type { ServerContext } from '../types.js';
3
- /** GET/PUT /api/permissions — 全局授权模式(default 逐个确认 / auto 自动放行 ask 工具)。 */
4
3
  declare function createPermissionsRoute(ctx: ServerContext): Hono;
5
4
  export { createPermissionsRoute };
@@ -1,14 +1,76 @@
1
+ import { eq } from 'drizzle-orm';
1
2
  import { Hono } from 'hono';
3
+ import { sessions } from '../../db/schema.js';
2
4
  import { apiError } from '../middleware/error.js';
3
- /** GET/PUT /api/permissions — 全局授权模式(default 逐个确认 / auto 自动放行 ask 工具)。 */
5
+ /** GET/PUT /api/permissions — 授权模式(default 逐个确认 / auto 自动放行 ask 工具)。
6
+ * - 根路径:全局默认模式(启动时取 config.permission.defaultMode;
7
+ * PUT / 持久化到 global 作用域 config,重启后仍生效)
8
+ * - /:sessionId:会话级覆盖(P1-5 按会话隔离 auto 高风险状态)。
9
+ * P2:会话级覆盖持久化到 session.metadata.permissionMode,重启后经 GET 或
10
+ * chat 路由懒加载恢复——此前仅内存 Map,重启静默回退 default,用户困惑。
11
+ * - /:sessionId/always-allow:会话级「始终允许」工具白名单(P2 权限疲劳治理),
12
+ * 持久化到 session.metadata.alwaysAllow,checker 经 getter 实时读取。
13
+ */
14
+ /** 懒加载会话级权限覆盖到内存 Map(幂等;查询失败静默回退默认语义)。 */
15
+ async function ensureSessionModeLoaded(ctx, sessionId) {
16
+ if (ctx.sessionPermissionModes.has(sessionId))
17
+ return;
18
+ try {
19
+ const [row] = await ctx.db.db
20
+ .select({ metadata: sessions.metadata })
21
+ .from(sessions)
22
+ .where(eq(sessions.id, sessionId));
23
+ const mode = row?.metadata
24
+ ?.permissionMode;
25
+ if (mode === 'auto' || mode === 'default') {
26
+ ctx.sessionPermissionModes.set(sessionId, mode);
27
+ }
28
+ }
29
+ catch {
30
+ // 会话不存在/查询失败:回退默认模式
31
+ }
32
+ }
33
+ /** 懒加载会话级始终允许白名单到内存 Map(幂等;失败回退空列表)。 */
34
+ async function ensureSessionAllowLoaded(ctx, sessionId) {
35
+ if (ctx.sessionAlwaysAllow.has(sessionId))
36
+ return;
37
+ try {
38
+ const [row] = await ctx.db.db
39
+ .select({ metadata: sessions.metadata })
40
+ .from(sessions)
41
+ .where(eq(sessions.id, sessionId));
42
+ const list = row?.metadata?.alwaysAllow;
43
+ ctx.sessionAlwaysAllow.set(sessionId, Array.isArray(list) ? list.filter((t) => typeof t === 'string') : []);
44
+ }
45
+ catch {
46
+ ctx.sessionAlwaysAllow.set(sessionId, []);
47
+ }
48
+ }
49
+ /** 持久化会话 metadata(失败不致命:内存态本次运行仍生效)。 */
50
+ async function persistSessionMetadata(ctx, sessionId, mutate) {
51
+ try {
52
+ const [row] = await ctx.db.db
53
+ .select({ metadata: sessions.metadata })
54
+ .from(sessions)
55
+ .where(eq(sessions.id, sessionId));
56
+ if (!row)
57
+ return;
58
+ const meta = { ...(row.metadata ?? {}) };
59
+ mutate(meta);
60
+ await ctx.db.db.update(sessions).set({ metadata: meta }).where(eq(sessions.id, sessionId));
61
+ }
62
+ catch {
63
+ // 持久化失败不致命
64
+ }
65
+ }
4
66
  function createPermissionsRoute(ctx) {
5
67
  const app = new Hono();
6
- // GET / — 当前授权模式
68
+ // GET / — 默认授权模式
7
69
  app.get('/', (c) => {
8
70
  return c.json({ mode: ctx.permissionMode });
9
71
  });
10
- // PUT / — 运行时切换授权模式(仅本次运行生效,不回写 config;
11
- // 重启后回到 config.permission.defaultMode 持久化默认值)
72
+ // PUT / — 设置全局默认授权模式(P1-4:持久化到 global 作用域 config)。
73
+ // 此前仅本次运行生效、重启静默回退,与同款控件在会话页的持久化语义相悖。
12
74
  app.put('/', async (c) => {
13
75
  const body = (await c.req.json().catch(() => null));
14
76
  const mode = body?.mode;
@@ -16,8 +78,95 @@ function createPermissionsRoute(ctx) {
16
78
  return apiError(c, 400, 'INVALID_MODE', "mode 必须是 'default' 或 'auto'");
17
79
  }
18
80
  ctx.permissionMode = mode;
81
+ try {
82
+ const { applyScopedPatch, loadConfigScopes, saveConfigScoped } = await import('../../core/config.js');
83
+ const scopes = loadConfigScopes(ctx.cwd);
84
+ const next = applyScopedPatch(scopes.global ?? {}, {
85
+ permission: { defaultMode: mode },
86
+ });
87
+ await saveConfigScoped('global', ctx.cwd, next);
88
+ }
89
+ catch {
90
+ // 持久化失败不致命:本次运行仍生效(与 /:sessionId 覆盖的降级语义一致)
91
+ }
19
92
  return c.json({ mode: ctx.permissionMode });
20
93
  });
94
+ // GET /:sessionId — 会话实际生效模式(覆盖优先,回退默认)+ 始终允许白名单
95
+ app.get('/:sessionId', async (c) => {
96
+ const sessionId = c.req.param('sessionId');
97
+ await ensureSessionModeLoaded(ctx, sessionId);
98
+ await ensureSessionAllowLoaded(ctx, sessionId);
99
+ return c.json({
100
+ mode: ctx.sessionPermissionModes.get(sessionId) ?? ctx.permissionMode,
101
+ alwaysAllow: ctx.sessionAlwaysAllow.get(sessionId) ?? [],
102
+ });
103
+ });
104
+ // PUT /:sessionId — 设置会话级覆盖;不传 mode 则清除覆盖。P2:落库持久化。
105
+ app.put('/:sessionId', async (c) => {
106
+ const sessionId = c.req.param('sessionId');
107
+ const body = (await c.req.json().catch(() => null));
108
+ const mode = body?.mode;
109
+ if (mode !== undefined && mode !== 'default' && mode !== 'auto') {
110
+ return apiError(c, 400, 'INVALID_MODE', "mode 必须是 'default' 或 'auto'");
111
+ }
112
+ if (mode === undefined) {
113
+ ctx.sessionPermissionModes.delete(sessionId);
114
+ }
115
+ else {
116
+ ctx.sessionPermissionModes.set(sessionId, mode);
117
+ }
118
+ // 持久化:写 session.metadata.permissionMode(无该字段时删除,回退全局默认)。
119
+ // 失败仅影响重启后的恢复,本次运行的 Map 状态仍生效。
120
+ await persistSessionMetadata(ctx, sessionId, (meta) => {
121
+ if (mode === undefined) {
122
+ delete meta.permissionMode;
123
+ }
124
+ else {
125
+ meta.permissionMode = mode;
126
+ }
127
+ });
128
+ return c.json({ mode: ctx.sessionPermissionModes.get(sessionId) ?? ctx.permissionMode });
129
+ });
130
+ // GET /:sessionId/pending — 查询会话当前挂起的权限请求(P1:挂起期间切换页面/
131
+ // 刷新后前端重挂确认弹窗,恢复被阻塞 run 的可操作路径。无挂起返回 pending: null。
132
+ app.get('/:sessionId/pending', (c) => {
133
+ const sessionId = c.req.param('sessionId');
134
+ const pending = ctx.permissionStore.pendingForSession(sessionId);
135
+ return c.json({ pending });
136
+ });
137
+ // POST /:sessionId/always-allow — 追加工具到会话白名单(幂等)
138
+ app.post('/:sessionId/always-allow', async (c) => {
139
+ const sessionId = c.req.param('sessionId');
140
+ const body = (await c.req.json().catch(() => null));
141
+ const tool = body?.tool;
142
+ if (typeof tool !== 'string' || tool.length === 0) {
143
+ return apiError(c, 400, 'BAD_REQUEST', 'tool is required');
144
+ }
145
+ await ensureSessionAllowLoaded(ctx, sessionId);
146
+ const list = ctx.sessionAlwaysAllow.get(sessionId) ?? [];
147
+ if (!list.includes(tool))
148
+ list.push(tool);
149
+ ctx.sessionAlwaysAllow.set(sessionId, list);
150
+ await persistSessionMetadata(ctx, sessionId, (meta) => {
151
+ meta.alwaysAllow = list;
152
+ });
153
+ return c.json({ alwaysAllow: list });
154
+ });
155
+ // DELETE /:sessionId/always-allow/:tool — 从会话白名单移除
156
+ app.delete('/:sessionId/always-allow/:tool', async (c) => {
157
+ const sessionId = c.req.param('sessionId');
158
+ const tool = c.req.param('tool');
159
+ await ensureSessionAllowLoaded(ctx, sessionId);
160
+ const list = (ctx.sessionAlwaysAllow.get(sessionId) ?? []).filter((t) => t !== tool);
161
+ ctx.sessionAlwaysAllow.set(sessionId, list);
162
+ await persistSessionMetadata(ctx, sessionId, (meta) => {
163
+ if (list.length === 0)
164
+ delete meta.alwaysAllow;
165
+ else
166
+ meta.alwaysAllow = list;
167
+ });
168
+ return c.json({ alwaysAllow: list });
169
+ });
21
170
  return app;
22
171
  }
23
172
  export { createPermissionsRoute };
@@ -1,10 +1,17 @@
1
+ // src/server/routes/project.ts
2
+ import { existsSync } from 'node:fs';
3
+ import { and, eq, isNull } from 'drizzle-orm';
1
4
  import { Hono } from 'hono';
2
- import { fromDirectory, getProject, listProjects, resolveProject, updateProjectName, } from '../../project/index.js';
5
+ import { kanbanBoards, projects, sessions } from '../../db/schema.js';
6
+ import { fromDirectory, getProject, listProjects, relocateProject, resolveProject, updateProjectName, } from '../../project/index.js';
7
+ import { clearTrashMarks } from '../../session/session.js';
8
+ import { generateId } from '../../shared/index.js';
3
9
  import { apiError } from '../middleware/error.js';
4
10
  import { expandPath } from './filesystem.js';
5
- /** 实时取 git 分支(分支随时变,不存 DB)。 */
11
+ /** 实时取 git 分支(分支随时变,不存 DB)。worktree 不存在时标记缺失。 */
6
12
  function withBranch(project) {
7
- return { ...project, gitBranch: resolveProject(project.worktree).gitBranch };
13
+ const worktreeMissing = !existsSync(project.worktree);
14
+ return { ...project, gitBranch: resolveProject(project.worktree).gitBranch, worktreeMissing };
8
15
  }
9
16
  function createProjectRoute(ctx) {
10
17
  const app = new Hono();
@@ -46,6 +53,102 @@ function createProjectRoute(ctx) {
46
53
  return apiError(c, 404, 'NOT_FOUND', 'Project not found');
47
54
  return c.json(withBranch(project));
48
55
  });
56
+ // A1:项目重新定位——目录被移动/重命名后的恢复通道。
57
+ // 整体迁移会话与看板到新目录身份(替代「删项目 + 逐条恢复 + 看板丢失」)。
58
+ // 目标目录已注册为另一项目 → 409;有活跃 run → 拒绝(agent 工作目录悬空)。
59
+ app.post('/:id/relocate', async (c) => {
60
+ const id = c.req.param('id');
61
+ const body = (await c.req.json().catch(() => ({})));
62
+ const directory = typeof body.directory === 'string' ? body.directory.trim() : '';
63
+ if (!directory)
64
+ return apiError(c, 400, 'DIRECTORY_REQUIRED', 'directory is required');
65
+ if (!existsSync(directory)) {
66
+ return apiError(c, 400, 'DIRECTORY_MISSING', `目录不存在或不可访问:${directory}`);
67
+ }
68
+ // 活跃 run 守卫(与 DELETE 同语义)
69
+ const rows = await ctx.db.db
70
+ .select({ id: sessions.id })
71
+ .from(sessions)
72
+ .where(eq(sessions.projectId, id));
73
+ const activeCount = rows.filter((r) => ctx.agentManager.get(r.id)).length;
74
+ if (activeCount > 0) {
75
+ return apiError(c, 409, 'PROJECT_HAS_ACTIVE_SESSIONS', `项目下有 ${activeCount} 个进行中的对话,请先中止后再重新定位`);
76
+ }
77
+ try {
78
+ const project = await relocateProject(ctx.db, id, directory);
79
+ return c.json({ ok: true, project: withBranch(project) });
80
+ }
81
+ catch (err) {
82
+ const msg = err instanceof Error ? err.message : String(err);
83
+ if (msg === 'PROJECT_NOT_FOUND')
84
+ return apiError(c, 404, 'PROJECT_NOT_FOUND', '项目不存在');
85
+ if (msg.startsWith('TARGET_OCCUPIED')) {
86
+ return apiError(c, 409, 'TARGET_OCCUPIED', msg.replace('TARGET_OCCUPIED: ', ''));
87
+ }
88
+ return apiError(c, 500, 'RELOCATE_FAILED', msg);
89
+ }
90
+ });
91
+ // P1-9 + P2-2:删除项目记录(看板级联删除)。
92
+ // 会话不再留孤儿(FK set null 后在任何项目视图都不可见且 cwd 回退 serve 目录):
93
+ // 删除前把该项目全部未删除会话软删除进回收站(30 天内可恢复),
94
+ // 并把 worktreePath 落盘——恢复后 resolveAgentCwd 仍能解析原工作目录。
95
+ // 有活跃 run 绑定该项目时拒绝删除,避免 agent 工作目录悬空。
96
+ app.delete('/:id', async (c) => {
97
+ const id = c.req.param('id');
98
+ const project = await getProject(ctx.db, id);
99
+ if (!project)
100
+ 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);
105
+ }
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
+ }
116
+ }
117
+ let deletedSessions = 0;
118
+ await ctx.db.db.transaction(async (tx) => {
119
+ // 该项目下所有未删除会话:记录 worktreePath(若尚无)并软删除进回收站。
120
+ // A2:删除项目是一次用户操作——全部会话共享同一 deletedBatchId,
121
+ // 恢复任一会话时其 fork 子树一并还原(与 softDeleteSession 语义对齐),
122
+ // 不再因批次断裂导致分支滞留回收站被静默清除。
123
+ const now = new Date();
124
+ const batchId = generateId();
125
+ const bound = await tx
126
+ .select({
127
+ id: sessions.id,
128
+ worktreePath: sessions.worktreePath,
129
+ metadata: sessions.metadata,
130
+ })
131
+ .from(sessions)
132
+ .where(and(eq(sessions.projectId, id), isNull(sessions.deletedAt)));
133
+ for (const s of bound) {
134
+ // A3:软删除清除回收站倒计时/宽限期标记(恢复后重删须重新看到才起算)。
135
+ const metadata = clearTrashMarks((s.metadata ?? {}));
136
+ await tx
137
+ .update(sessions)
138
+ .set({
139
+ worktreePath: s.worktreePath ?? project.worktree,
140
+ deletedAt: now,
141
+ deletedBatchId: batchId,
142
+ metadata,
143
+ })
144
+ .where(eq(sessions.id, s.id));
145
+ deletedSessions += 1;
146
+ }
147
+ await tx.delete(kanbanBoards).where(eq(kanbanBoards.projectId, id));
148
+ await tx.delete(projects).where(eq(projects.id, id));
149
+ });
150
+ return c.json({ ok: true, deletedSessions });
151
+ });
49
152
  return app;
50
153
  }
51
154
  export { createProjectRoute };