c0de-agent 1.10.0 → 1.11.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 (79) hide show
  1. package/README.md +5 -2
  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 +8 -0
  6. package/dist/cli/commands/config.d.ts +2 -9
  7. package/dist/cli/commands/config.js +9 -52
  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 +35 -0
  12. package/dist/cli/commands/update.js +18 -10
  13. package/dist/cli/index.js +30 -2
  14. package/dist/cli/modes/print.js +11 -1
  15. package/dist/core/config-path.d.ts +9 -0
  16. package/dist/core/config-path.js +48 -0
  17. package/dist/core/config.d.ts +10 -2
  18. package/dist/core/config.js +32 -10
  19. package/dist/core/index.d.ts +1 -1
  20. package/dist/core/index.js +1 -1
  21. package/dist/core/loop.js +13 -3
  22. package/dist/core/slash.js +92 -30
  23. package/dist/core/title.js +15 -0
  24. package/dist/core/types.d.ts +2 -0
  25. package/dist/db/client.d.ts +4 -0
  26. package/dist/db/client.js +5 -0
  27. package/dist/db/schema.d.ts +34 -0
  28. package/dist/db/schema.js +4 -0
  29. package/dist/server/agent-manager.d.ts +5 -0
  30. package/dist/server/agent-manager.js +28 -0
  31. package/dist/server/app.js +12 -2
  32. package/dist/server/auth-manager.d.ts +79 -0
  33. package/dist/server/auth-manager.js +270 -0
  34. package/dist/server/context.js +1 -0
  35. package/dist/server/middleware/auth.d.ts +4 -3
  36. package/dist/server/middleware/auth.js +5 -5
  37. package/dist/server/permission/interactive.d.ts +2 -0
  38. package/dist/server/permission/interactive.js +5 -1
  39. package/dist/server/permission/store.d.ts +4 -0
  40. package/dist/server/permission/store.js +1 -0
  41. package/dist/server/routes/auth.d.ts +4 -0
  42. package/dist/server/routes/auth.js +94 -0
  43. package/dist/server/routes/chat.d.ts +6 -1
  44. package/dist/server/routes/chat.js +87 -7
  45. package/dist/server/routes/config.js +59 -11
  46. package/dist/server/routes/permissions.d.ts +4 -1
  47. package/dist/server/routes/permissions.js +27 -4
  48. package/dist/server/routes/project.js +56 -2
  49. package/dist/server/routes/session.js +31 -7
  50. package/dist/server/routes/terminal.js +17 -2
  51. package/dist/server/routes/update.js +23 -1
  52. package/dist/server/server.js +47 -8
  53. package/dist/server/types.d.ts +8 -1
  54. package/dist/session/archive.d.ts +1 -1
  55. package/dist/session/branch.d.ts +1 -0
  56. package/dist/session/branch.js +12 -5
  57. package/dist/session/index.d.ts +1 -1
  58. package/dist/session/index.js +1 -1
  59. package/dist/session/session.d.ts +25 -5
  60. package/dist/session/session.js +129 -10
  61. package/dist/session/types.d.ts +1 -1
  62. package/dist/shared/types/agent.d.ts +11 -0
  63. package/dist/shared/types/config.d.ts +5 -0
  64. package/dist/shared/types/message.d.ts +6 -2
  65. package/dist/tools/index.js +2 -3
  66. package/dist/update/index.d.ts +1 -1
  67. package/dist/update/index.js +1 -1
  68. package/dist/update/ipc.d.ts +6 -0
  69. package/dist/update/ipc.js +7 -4
  70. package/dist/update/snapshot.d.ts +4 -0
  71. package/dist/update/snapshot.js +8 -0
  72. package/dist/update/updater.d.ts +55 -8
  73. package/dist/update/updater.js +230 -27
  74. package/drizzle/0006_sour_susan_delgado.sql +2 -0
  75. package/drizzle/0007_same_wilson_fisk.sql +2 -0
  76. package/drizzle/meta/0006_snapshot.json +867 -0
  77. package/drizzle/meta/0007_snapshot.json +869 -0
  78. package/drizzle/meta/_journal.json +14 -0
  79. package/package.json +1 -1
@@ -1,30 +1,78 @@
1
1
  import { Hono } from 'hono';
2
- import { loadConfigScopes, mergeConfig, mergeRaw, saveConfigScoped } from '../../core/config.js';
3
- import { encryptSecret, isEncryptedSecret } from '../../core/secret.js';
2
+ import { applyScopedPatch, loadConfigScopes, mergeConfig, saveConfigScoped, } from '../../core/config.js';
3
+ import { decryptSecret, encryptSecret, isEncryptedSecret } from '../../core/secret.js';
4
+ import { apiError } from '../middleware/error.js';
4
5
  import { syncRegistryFromConfig } from '../server.js';
6
+ /** 检查每个 provider 的 apiKey 能否在本机解密(机器绑定密钥换机/容器重建后会失败)。 */
7
+ function providerApiKeyWarnings(providers) {
8
+ const warnings = [];
9
+ for (const p of providers) {
10
+ if (!p.apiKey || !isEncryptedSecret(p.apiKey))
11
+ continue;
12
+ try {
13
+ decryptSecret(p.apiKey);
14
+ }
15
+ catch {
16
+ warnings.push(`provider "${p.name || '(未命名)'}" 的 API Key 无法在本机解密(密钥与机器绑定)。` +
17
+ '若配置来自其他机器或容器重建,请重新填写 API Key。');
18
+ }
19
+ }
20
+ return warnings;
21
+ }
5
22
  function createConfigRoute(ctx) {
6
23
  const app = new Hono();
24
+ // GET / — 合并后配置 + 作用域信息 + apiKey 解密警告
7
25
  app.get('/', (c) => {
8
- return c.json(ctx.config);
26
+ const scopes = loadConfigScopes(ctx.cwd);
27
+ return c.json({
28
+ config: ctx.config,
29
+ scopes: {
30
+ global: scopes.global ?? null,
31
+ project: scopes.project ?? null,
32
+ },
33
+ warnings: providerApiKeyWarnings(ctx.config.providers),
34
+ });
9
35
  });
36
+ // PATCH / — 按作用域写入。body.scope: 'global' | 'project'(默认 project,兼容旧客户端)。
10
37
  app.patch('/', async (c) => {
11
- const patch = (await c.req.json());
38
+ const body = (await c.req.json());
39
+ const scope = body.scope === 'global' ? 'global' : 'project';
40
+ const { scope: _omit, ...patch } = body;
12
41
  // spec §24.2:provider apiKey 落盘前加密,明文不持久化。
13
42
  // 已加密(enc: 前缀)或无 apiKey 的透传。
14
43
  if (Array.isArray(patch.providers)) {
15
44
  patch.providers = patch.providers.map((p) => p.apiKey && !isEncryptedSecret(p.apiKey) ? { ...p, apiKey: encryptSecret(p.apiKey) } : p);
16
45
  }
17
- // 按作用域最小落盘:patch 只合并进项目作用域原始文件,
18
- // 不把合并结果(含默认值/global 配置)整体序列化进项目文件。
46
+ // 按作用域最小落盘:patch 只合并进指定作用域原始文件,
47
+ // 不把合并结果(含默认值/另一作用域配置)整体序列化进文件。
48
+ // null 值 = 删除该作用域中的键(回落到另一作用域/默认值)。
19
49
  const scopes = loadConfigScopes(ctx.cwd);
20
- const nextProject = mergeRaw(scopes.project ?? {}, patch);
21
- ctx.config = mergeConfig(scopes.global, nextProject);
50
+ const nextScoped = scope === 'global'
51
+ ? applyScopedPatch(scopes.global ?? {}, patch)
52
+ : applyScopedPatch(scopes.project ?? {}, patch);
53
+ // P2-3:落盘失败必须反馈给前端(此前静默吞掉,UI 显示已保存但重启后丢失)。
54
+ // 先落盘、成功后才更新内存配置与 registry,保证「已保存」反馈与磁盘状态一致。
55
+ try {
56
+ await saveConfigScoped(scope, ctx.cwd, nextScoped);
57
+ }
58
+ catch (err) {
59
+ return apiError(c, 500, 'CONFIG_SAVE_FAILED', `配置保存失败:${err instanceof Error ? err.message : String(err)}`);
60
+ }
61
+ ctx.config =
62
+ scope === 'global'
63
+ ? mergeConfig(nextScoped, scopes.project)
64
+ : mergeConfig(scopes.global, nextScoped);
22
65
  // providers 变更后原地同步 registry,使运行中的连接立即生效
23
66
  syncRegistryFromConfig(ctx.llmRegistry, ctx.config);
24
- await saveConfigScoped('project', ctx.cwd, nextProject).catch(() => {
25
- // 保存失败不影响内存配置
67
+ // 安全类配置(token/authEnabled/allowedOrigins)由 authManager 在启动时一次性读取,
68
+ // 运行时修改不生效——告知前端提示重启。
69
+ const needsRestart = patch.security !== undefined;
70
+ return c.json({
71
+ config: ctx.config,
72
+ scope,
73
+ needsRestart,
74
+ warnings: providerApiKeyWarnings(ctx.config.providers),
26
75
  });
27
- return c.json(ctx.config);
28
76
  });
29
77
  return app;
30
78
  }
@@ -1,5 +1,8 @@
1
1
  import { Hono } from 'hono';
2
2
  import type { ServerContext } from '../types.js';
3
- /** GET/PUT /api/permissions — 全局授权模式(default 逐个确认 / auto 自动放行 ask 工具)。 */
3
+ /** GET/PUT /api/permissions — 授权模式(default 逐个确认 / auto 自动放行 ask 工具)。
4
+ * - 根路径:默认模式(全局,启动时取 config.permission.defaultMode)
5
+ * - /:sessionId:会话级覆盖(P1-5 按会话隔离 auto 高风险状态)
6
+ */
4
7
  declare function createPermissionsRoute(ctx: ServerContext): Hono;
5
8
  export { createPermissionsRoute };
@@ -1,14 +1,16 @@
1
1
  import { Hono } from 'hono';
2
2
  import { apiError } from '../middleware/error.js';
3
- /** GET/PUT /api/permissions — 全局授权模式(default 逐个确认 / auto 自动放行 ask 工具)。 */
3
+ /** GET/PUT /api/permissions — 授权模式(default 逐个确认 / auto 自动放行 ask 工具)。
4
+ * - 根路径:默认模式(全局,启动时取 config.permission.defaultMode)
5
+ * - /:sessionId:会话级覆盖(P1-5 按会话隔离 auto 高风险状态)
6
+ */
4
7
  function createPermissionsRoute(ctx) {
5
8
  const app = new Hono();
6
- // GET / — 当前授权模式
9
+ // GET / — 默认授权模式
7
10
  app.get('/', (c) => {
8
11
  return c.json({ mode: ctx.permissionMode });
9
12
  });
10
- // PUT / — 运行时切换授权模式(仅本次运行生效,不回写 config;
11
- // 重启后回到 config.permission.defaultMode 持久化默认值)
13
+ // PUT / — 运行时切换默认模式(仅本次运行生效,不回写 config)
12
14
  app.put('/', async (c) => {
13
15
  const body = (await c.req.json().catch(() => null));
14
16
  const mode = body?.mode;
@@ -18,6 +20,27 @@ function createPermissionsRoute(ctx) {
18
20
  ctx.permissionMode = mode;
19
21
  return c.json({ mode: ctx.permissionMode });
20
22
  });
23
+ // GET /:sessionId — 会话实际生效模式(覆盖优先,回退默认)
24
+ app.get('/:sessionId', (c) => {
25
+ const sessionId = c.req.param('sessionId');
26
+ return c.json({ mode: ctx.sessionPermissionModes.get(sessionId) ?? ctx.permissionMode });
27
+ });
28
+ // PUT /:sessionId — 设置会话级覆盖;不传 mode 则清除覆盖
29
+ app.put('/:sessionId', async (c) => {
30
+ const sessionId = c.req.param('sessionId');
31
+ const body = (await c.req.json().catch(() => null));
32
+ const mode = body?.mode;
33
+ if (mode !== undefined && mode !== 'default' && mode !== 'auto') {
34
+ return apiError(c, 400, 'INVALID_MODE', "mode 必须是 'default' 或 'auto'");
35
+ }
36
+ if (mode === undefined) {
37
+ ctx.sessionPermissionModes.delete(sessionId);
38
+ }
39
+ else {
40
+ ctx.sessionPermissionModes.set(sessionId, mode);
41
+ }
42
+ return c.json({ mode: ctx.sessionPermissionModes.get(sessionId) ?? ctx.permissionMode });
43
+ });
21
44
  return app;
22
45
  }
23
46
  export { createPermissionsRoute };
@@ -1,10 +1,15 @@
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';
5
+ import { kanbanBoards, projects, sessions } from '../../db/schema.js';
2
6
  import { fromDirectory, getProject, listProjects, resolveProject, updateProjectName, } from '../../project/index.js';
3
7
  import { apiError } from '../middleware/error.js';
4
8
  import { expandPath } from './filesystem.js';
5
- /** 实时取 git 分支(分支随时变,不存 DB)。 */
9
+ /** 实时取 git 分支(分支随时变,不存 DB)。worktree 不存在时标记缺失。 */
6
10
  function withBranch(project) {
7
- return { ...project, gitBranch: resolveProject(project.worktree).gitBranch };
11
+ const worktreeMissing = !existsSync(project.worktree);
12
+ return { ...project, gitBranch: resolveProject(project.worktree).gitBranch, worktreeMissing };
8
13
  }
9
14
  function createProjectRoute(ctx) {
10
15
  const app = new Hono();
@@ -46,6 +51,55 @@ function createProjectRoute(ctx) {
46
51
  return apiError(c, 404, 'NOT_FOUND', 'Project not found');
47
52
  return c.json(withBranch(project));
48
53
  });
54
+ // P1-9 + P2-2:删除项目记录(看板级联删除)。
55
+ // 会话不再留孤儿(FK set null 后在任何项目视图都不可见且 cwd 回退 serve 目录):
56
+ // 删除前把该项目全部未删除会话软删除进回收站(30 天内可恢复),
57
+ // 并把 worktreePath 落盘——恢复后 resolveAgentCwd 仍能解析原工作目录。
58
+ // 有活跃 run 绑定该项目时拒绝删除,避免 agent 工作目录悬空。
59
+ app.delete('/:id', async (c) => {
60
+ const id = c.req.param('id');
61
+ const project = await getProject(ctx.db, id);
62
+ if (!project)
63
+ return apiError(c, 404, 'NOT_FOUND', 'Project not found');
64
+ // 活跃 run 守卫:任一活跃会话归属于该项目 → 拒绝
65
+ const activeSessionIds = new Set();
66
+ for (const run of ctx.agentManager.children(id)) {
67
+ activeSessionIds.add(run.sessionId);
68
+ }
69
+ if (activeSessionIds.size === 0) {
70
+ // 检查该项目下是否有正在进行(含占位)的会话
71
+ const rows = await ctx.db.db
72
+ .select({ id: sessions.id, projectId: sessions.projectId })
73
+ .from(sessions)
74
+ .where(and(eq(sessions.projectId, id)));
75
+ const active = rows.filter((r) => ctx.agentManager.get(r.id));
76
+ if (active.length > 0) {
77
+ return apiError(c, 409, 'PROJECT_HAS_ACTIVE_SESSIONS', `项目下有 ${active.length} 个进行中的对话,请先中止后再删除`);
78
+ }
79
+ }
80
+ let deletedSessions = 0;
81
+ await ctx.db.db.transaction(async (tx) => {
82
+ // 该项目下所有未删除会话:记录 worktreePath(若尚无)并软删除进回收站
83
+ const now = new Date();
84
+ const bound = await tx
85
+ .select({ id: sessions.id, worktreePath: sessions.worktreePath })
86
+ .from(sessions)
87
+ .where(and(eq(sessions.projectId, id), isNull(sessions.deletedAt)));
88
+ for (const s of bound) {
89
+ if (!s.worktreePath) {
90
+ await tx
91
+ .update(sessions)
92
+ .set({ worktreePath: project.worktree })
93
+ .where(eq(sessions.id, s.id));
94
+ }
95
+ await tx.update(sessions).set({ deletedAt: now }).where(eq(sessions.id, s.id));
96
+ deletedSessions += 1;
97
+ }
98
+ await tx.delete(kanbanBoards).where(eq(kanbanBoards.projectId, id));
99
+ await tx.delete(projects).where(eq(projects.id, id));
100
+ });
101
+ return c.json({ ok: true, deletedSessions });
102
+ });
49
103
  return app;
50
104
  }
51
105
  export { createProjectRoute };
@@ -4,7 +4,7 @@ import { fromDirectory } from '../../project/index.js';
4
4
  import { archiveOriginalEntries } from '../../session/archive.js';
5
5
  import { BranchPointOutOfRangeError, forkSession, getBranches, getTree, } from '../../session/branch.js';
6
6
  import { deleteEntriesByIds, getMessages, insertEntry } from '../../session/message.js';
7
- import { createSession, deleteSession, getLLMSegments, getSession, listSessions, listSessionsByProject, touchLastOpened, } from '../../session/session.js';
7
+ import { createSession, getLLMSegments, getSession, listDeletedSessions, listSessions, listSessionsByProject, restoreSession, softDeleteSession, touchLastOpened, } from '../../session/session.js';
8
8
  import { applyShakeRegions, collectShakeRegions, DEFAULT_SHAKE_CONFIG, toRegionViews, } from '../../session/shake.js';
9
9
  import { estimateMessageTokens } from '../../session/token.js';
10
10
  import { generateId } from '../../shared/index.js';
@@ -41,6 +41,11 @@ function createSessionRoute(ctx) {
41
41
  const tree = await getTree(ctx.db);
42
42
  return c.json(tree);
43
43
  });
44
+ // 回收站:已软删除的会话列表(必须注册在 /:id 之前,避免被参数路由吞掉)
45
+ app.get('/deleted', async (c) => {
46
+ const sessions = await listDeletedSessions(ctx.db);
47
+ return c.json(sessions);
48
+ });
44
49
  // 获取会话详情
45
50
  app.get('/:id', async (c) => {
46
51
  try {
@@ -54,11 +59,18 @@ function createSessionRoute(ctx) {
54
59
  return apiError(c, 404, 'NOT_FOUND', 'Session not found');
55
60
  }
56
61
  });
57
- // 分支会话
62
+ // 分支会话:未指定 messageIndex 时默认在最新一条消息处分叉(fork=完整副本语义)
58
63
  app.post('/:id/fork', async (c) => {
59
64
  const id = c.req.param('id');
60
65
  const body = await c.req.json().catch(() => ({}));
61
- const messageIndex = body.messageIndex ?? 0;
66
+ let messageIndex = body.messageIndex;
67
+ if (messageIndex === undefined || !Number.isFinite(messageIndex)) {
68
+ const messages = await getMessages(ctx.db, id);
69
+ if (messages.length === 0) {
70
+ return apiError(c, 400, 'EMPTY_SESSION', '空会话无法分支');
71
+ }
72
+ messageIndex = messages.length - 1;
73
+ }
62
74
  try {
63
75
  const forked = await forkSession(ctx.db, id, messageIndex);
64
76
  return c.json(forked, 201);
@@ -71,11 +83,20 @@ function createSessionRoute(ctx) {
71
83
  return apiError(c, 404, 'NOT_FOUND', 'Session not found');
72
84
  }
73
85
  });
74
- // 删除会话
86
+ // 删除会话(软删除:级联其 fork 后代进入回收站,30 天后物理清除)
75
87
  app.delete('/:id', async (c) => {
76
- await deleteSession(ctx.db, c.req.param('id'));
88
+ const ok = await softDeleteSession(ctx.db, c.req.param('id'));
89
+ if (!ok)
90
+ return apiError(c, 404, 'NOT_FOUND', 'Session not found');
77
91
  return c.body(null, 204);
78
92
  });
93
+ // 恢复会话(从回收站还原;仅还原该会话本身)
94
+ app.post('/:id/restore', async (c) => {
95
+ const ok = await restoreSession(ctx.db, c.req.param('id'));
96
+ if (!ok)
97
+ return apiError(c, 404, 'NOT_FOUND', '会话不存在或未删除');
98
+ return c.json({ ok: true });
99
+ });
79
100
  // 获取消息列表
80
101
  app.get('/:id/messages', async (c) => {
81
102
  const messages = await getMessages(ctx.db, c.req.param('id'));
@@ -117,13 +138,16 @@ function createSessionRoute(ctx) {
117
138
  }
118
139
  });
119
140
  // 获取会话状态:内存有活跃 run → 返回其 status;否则查 DB lastRun。
120
- // lastRun.status='running' 但无活跃 run → 服务重启被中断。
141
+ // lastRun.status='running' 且无活跃 run → 服务重启被中断。
142
+ // lastRun.status='paused' 但无活跃 run(热更新/重启后 run 状态未迁移)→ 同样按中断处理:
143
+ // agent 内存态已丢失,resume 端点无法恢复,唯一可行路径是重发上一条消息。
121
144
  app.get('/:id/status', async (c) => {
122
145
  const run = ctx.agentManager.get(c.req.param('id'));
123
146
  if (run)
124
147
  return c.json(run.state.status);
125
148
  const session = await getSession(ctx.db, c.req.param('id'));
126
- if (session?.metadata.lastRun?.status === 'running') {
149
+ if (session?.metadata.lastRun?.status === 'running' ||
150
+ session?.metadata.lastRun?.status === 'paused') {
127
151
  return c.json({ _tag: 'interrupted' });
128
152
  }
129
153
  return c.json({ _tag: 'idle' });
@@ -1,6 +1,8 @@
1
1
  // src/server/routes/terminal.ts
2
+ import { existsSync } from 'node:fs';
2
3
  import { isAbsolute } from 'node:path';
3
4
  import { Hono } from 'hono';
5
+ import { getProject } from '../../project/index.js';
4
6
  import { apiError } from '../middleware/error.js';
5
7
  /** 允许的 shell 白名单(P2-6:拒绝路径分隔符与任意二进制)。 */
6
8
  const ALLOWED_SHELLS = new Set(['bash', 'zsh', 'fish', 'sh']);
@@ -15,12 +17,25 @@ function createTerminalRoute(ctx) {
15
17
  // 创建新 PTY 会话
16
18
  app.post('/', async (c) => {
17
19
  const body = await c.req.json().catch(() => ({}));
18
- const cwd = typeof body.cwd === 'string' && body.cwd.length > 0 ? body.cwd : ctx.cwd;
20
+ const projectId = typeof body.projectId === 'string' && body.projectId.length > 0 ? body.projectId : undefined;
21
+ // P2-13:未显式传 cwd 且带 projectId 时,默认目录跟随项目 worktree
22
+ // (与 chat 的 resolveAgentCwd 行为一致;项目缺失/目录失效时回退 ctx.cwd)。
23
+ let defaultCwd = ctx.cwd;
24
+ if (!(typeof body.cwd === 'string' && body.cwd.length > 0) && projectId) {
25
+ try {
26
+ const project = await getProject(ctx.db, projectId);
27
+ if (project && existsSync(project.worktree))
28
+ defaultCwd = project.worktree;
29
+ }
30
+ catch {
31
+ // db 不可用(测试桩/临时故障):保持 ctx.cwd
32
+ }
33
+ }
34
+ const cwd = typeof body.cwd === 'string' && body.cwd.length > 0 ? body.cwd : defaultCwd;
19
35
  const cols = Number.isFinite(body.cols) ? Number(body.cols) : undefined;
20
36
  const rows = Number.isFinite(body.rows) ? Number(body.rows) : undefined;
21
37
  const title = typeof body.title === 'string' ? body.title : undefined;
22
38
  const shell = typeof body.shell === 'string' && body.shell.length > 0 ? body.shell : undefined;
23
- const projectId = typeof body.projectId === 'string' && body.projectId.length > 0 ? body.projectId : undefined;
24
39
  // 入参校验(P2-6):shell 白名单;cwd 必须为绝对路径。
25
40
  if (shell !== undefined) {
26
41
  if (shell.includes('/') || shell.includes('\\') || !ALLOWED_SHELLS.has(shell)) {
@@ -15,6 +15,12 @@ import { apiError } from '../middleware/error.js';
15
15
  function createUpdateRoute(ctx) {
16
16
  const app = new Hono();
17
17
  app.get('/', (c) => {
18
+ // P2-8:update.enabled=false 时无 handoff server,apply 必然 409。
19
+ // 不再触发 checkNow,也不返回 hasUpdate,避免横幅出现一个点了必失败的应用按钮。
20
+ if (ctx.config.update.enabled === false) {
21
+ const v = getCurrentVersion();
22
+ return c.json({ hasUpdate: false, disabled: true, currentVersion: v, latestVersion: v });
23
+ }
18
24
  const cached = ctx.updateScheduler.getLastResult();
19
25
  if (cached)
20
26
  return c.json(cached);
@@ -36,17 +42,33 @@ function createUpdateRoute(ctx) {
36
42
  if (!result.hasUpdate) {
37
43
  return apiError(c, 409, 'NO_UPDATE', '已是最新版本,无需热更新');
38
44
  }
45
+ // P1-8:热更新前先暂停所有活跃 run,等其到达安全暂停点(原子操作完成)。
46
+ // 暂停状态已持久化(lastRun.status='paused'),新实例接管后可恢复继续执行。
47
+ const pauseTimeoutMs = ctx.config.update.pauseTimeoutMs ?? 30_000;
48
+ const pauseResult = await ctx.agentManager.pauseAll(pauseTimeoutMs);
39
49
  const snapshot = await serializeSessions(ctx.db, ctx.config);
40
50
  const r = await performHotUpdate(snapshot, {
41
51
  handoffPort: ctx.handoff.port,
42
52
  port: ctx.port,
43
- authToken: ctx.authToken,
53
+ // P2-16:传当前 bootstrap(轮换后 ctx.authToken 可能已过期);
54
+ // 旧实例 handoff 端点经 verifyHandoff 接受当前/历史 bootstrap 与设备 token。
55
+ authToken: ctx.authManager?.bootstrap ?? ctx.authToken,
44
56
  });
45
57
  if (r._tag === 'success') {
46
58
  return c.json({
47
59
  ok: true,
48
60
  snapshotPath: r.snapshotPath,
49
61
  latestVersion: result.latestVersion,
62
+ installMethod: r.installMethod,
63
+ pausedSessions: pauseResult.paused,
64
+ forcedAbort: pauseResult.forcedAbort,
65
+ });
66
+ }
67
+ if (r._tag === 'manual_install_required') {
68
+ // P0-2:无法自动安装(未知安装方式/自动安装失败/等待超时)→ 引导手动更新
69
+ return apiError(c, 409, 'MANUAL_UPDATE_REQUIRED', r.error, {
70
+ command: r.command,
71
+ snapshotPath: r.snapshotPath,
50
72
  });
51
73
  }
52
74
  return apiError(c, 500, 'HOT_UPDATE_FAILED', `${r._tag}: ${'error' in r ? r.error : 'unknown'}`);
@@ -1,6 +1,6 @@
1
1
  // src/server/server.ts
2
- import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, } from 'node:fs';
3
2
  import { randomBytes } from 'node:crypto';
3
+ import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, } from 'node:fs';
4
4
  import { connect as tcpConnect } from 'node:net';
5
5
  import { homedir } from 'node:os';
6
6
  import { dirname, join } from 'node:path';
@@ -13,10 +13,12 @@ import { createAndPopulateRegistry } from '../core/workflows/index.js';
13
13
  import { createDB, migrateDB } from '../db/index.js';
14
14
  import { createRegistry, overrideToCapabilities, rebuildRegistry, registerProvider, } from '../llm/registry.js';
15
15
  import { initPlugins } from '../plugins/index.js';
16
+ import { purgeDeletedSessions } from '../session/session.js';
16
17
  import { createDefaultRegistry, createDefaultURLRegistry } from '../tools/index.js';
17
18
  import { checkForUpdate, createHandoffServer, createUpdateScheduler, requestHandoff, restoreSessions, } from '../update/index.js';
18
19
  import { createAgentManager } from './agent-manager.js';
19
20
  import { createApp } from './app.js';
21
+ import { createAuthManager } from './auth-manager.js';
20
22
  import { isAllowedOrigin } from './middleware/cors.js';
21
23
  import { createPermissionStore } from './permission/store.js';
22
24
  import { PTYManager } from './terminal/pty-manager.js';
@@ -185,6 +187,16 @@ async function buildServerContext(db, opts = {}) {
185
187
  // 工作流注册表:三级发现(builtin → global → project),eager 初始化。
186
188
  // 此前是惰性 getter 只注册 builtin,导致项目级 .c0de/workflows/*.js 永远不可见。
187
189
  const workflowRegistry = await createAndPopulateRegistry(cwd);
190
+ const dataDir = resolveDbDir();
191
+ // 先解析/生成 bootstrap token(落盘),再创建 authManager 读取——
192
+ // 首启时文件由 resolveAuthToken 生成,顺序颠倒会导致 bootstrap 读到空值。
193
+ const resolvedToken = resolveAuthToken(config, dataDir);
194
+ const authManager = createAuthManager({
195
+ dataDir,
196
+ ...(config.security.token && config.security.token.length > 0
197
+ ? { staticToken: config.security.token }
198
+ : {}),
199
+ });
188
200
  const ctx = {
189
201
  db,
190
202
  config,
@@ -196,7 +208,9 @@ async function buildServerContext(db, opts = {}) {
196
208
  agentManager: createAgentManager(),
197
209
  permissionStore: createPermissionStore(),
198
210
  permissionMode: config.permission.defaultMode,
199
- authToken: resolveAuthToken(config, resolveDbDir()),
211
+ sessionPermissionModes: new Map(),
212
+ authToken: resolvedToken,
213
+ authManager: config.security.authEnabled === false ? undefined : authManager,
200
214
  // Agent 注册表:内置 4 个默认 agent;项目/用户自定义 agent 可在启动后补充加载。
201
215
  agentRegistry: (() => {
202
216
  const reg = createAgentRegistry();
@@ -220,11 +234,12 @@ async function buildServerContext(db, opts = {}) {
220
234
  ctx,
221
235
  dispose: async () => {
222
236
  // dev 重建前调用:中止活跃 run + settle pending permission +
223
- // 停 scheduler。**不 close db**(调用方持有)。
237
+ // 停 scheduler + 关 devices.json watcher。**不 close db**(调用方持有)。
224
238
  ctx.agentManager.dispose();
225
239
  ctx.permissionStore.dispose();
226
240
  ctx.updateScheduler.stop();
227
241
  ctx.ptyManager.dispose();
242
+ ctx.authManager?.dispose();
228
243
  },
229
244
  };
230
245
  }
@@ -331,9 +346,26 @@ async function bootstrapServerContext(opts = {}) {
331
346
  db = await createDevDb(cwd);
332
347
  }
333
348
  const { ctx, dispose } = await buildServerContext(db, opts);
349
+ // 回收站清理:启动时清一次 + 每 24h 清一次(软删除保留 30 天)。
350
+ // fire-and-forget:清理失败不阻塞服务启动。
351
+ void purgeDeletedSessions(db).catch(() => { });
352
+ const purgeTimer = setInterval(() => {
353
+ void purgeDeletedSessions(db).catch(() => { });
354
+ }, 24 * 60 * 60 * 1000);
355
+ purgeTimer.unref();
356
+ // P2-15:PGLite WAL 周期刷盘(30s),缩短 kill -9 等突然停机时的已提交数据丢失窗口。
357
+ const syncTimer = db.sync
358
+ ? setInterval(() => {
359
+ void db.sync?.().catch(() => { });
360
+ }, 30_000)
361
+ : null;
362
+ syncTimer?.unref();
334
363
  return {
335
364
  ctx,
336
365
  close: async () => {
366
+ clearInterval(purgeTimer);
367
+ if (syncTimer)
368
+ clearInterval(syncTimer);
337
369
  await dispose();
338
370
  if (ownsDb)
339
371
  await db.close();
@@ -396,7 +428,6 @@ async function startServer(opts = {}) {
396
428
  // WebSocket:终端双向流。Hono v2 无原生 WS,用 ws 包直接挂载到 HTTP server。
397
429
  // 匹配 /api/terminal/:id/ws → ptyManager.attachWebSocket
398
430
  const wss = new WebSocketServer({ noServer: true });
399
- const expectedToken = ctx.authToken;
400
431
  server.on('upgrade', (req, socket, head) => {
401
432
  const url = new URL(req.url ?? '', `http://${req.headers.host ?? 'localhost'}`);
402
433
  const match = url.pathname.match(/^\/api\/terminal\/([^/]+)\/ws$/);
@@ -412,10 +443,14 @@ async function startServer(opts = {}) {
412
443
  socket.destroy();
413
444
  return;
414
445
  }
415
- // 认证:token 通过 query 参数传递(浏览器 WS 无法设置 Authorization header)
416
- if (expectedToken) {
446
+ // 认证:token 通过 query 参数传递(浏览器 WS 无法设置 Authorization header)。
447
+ // P2-16:优先 authManager 校验(设备 token);回退 ctx.authToken 兼容测试上下文。
448
+ if (ctx.authManager || ctx.authToken) {
417
449
  const token = url.searchParams.get('token');
418
- if (token !== expectedToken) {
450
+ const ok = ctx.authManager
451
+ ? ctx.authManager.verify(token ?? undefined)
452
+ : token === ctx.authToken;
453
+ if (!ok) {
419
454
  socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
420
455
  socket.destroy();
421
456
  return;
@@ -454,7 +489,11 @@ async function startServer(opts = {}) {
454
489
  if (ctx.config.update.enabled && !opts.skipHandoff) {
455
490
  const createHandoff = opts.createHandoffFn ?? createHandoffServer;
456
491
  handoffServer = await createHandoff(closeMain, {
457
- expectedToken: ctx.authToken,
492
+ // P2-16:token 轮换后新旧实例 bootstrap 可能不同,用 verifyHandoff 接受
493
+ // 当前/历史 bootstrap 与设备 token;无 authManager 回退字符串比较。
494
+ ...(ctx.authManager
495
+ ? { verify: (t) => ctx.authManager?.verifyHandoff(t) ?? false }
496
+ : { expectedToken: ctx.authToken }),
458
497
  exitAfterResponse: true,
459
498
  });
460
499
  ctx.handoff = { port: handoffServer.port, server: handoffServer };
@@ -9,6 +9,7 @@ import type { URLRegistry } from '../shared/types/tool.js';
9
9
  import type { ToolRegistry } from '../tools/types.js';
10
10
  import type { HandoffServer, UpdateScheduler } from '../update/index.js';
11
11
  import type { AgentManager } from './agent-manager.js';
12
+ import type { AuthManager } from './auth-manager.js';
12
13
  import type { PermissionStore } from './permission/store.js';
13
14
  import type { PTYManager } from './terminal/pty-manager.js';
14
15
  /** 持有所有服务依赖的不可变上下文(config 字段可变用于 PATCH 更新)。 */
@@ -26,14 +27,20 @@ type ServerContext = {
26
27
  agentManager: AgentManager;
27
28
  /** 全局权限确认 store(单例),独立于 agent run 生命周期。 */
28
29
  permissionStore: PermissionStore;
29
- /** 全局授权模式(运行时可临时切换):'default' 逐个确认;'auto' 自动放行 ask 工具(YOLO)。启动时取 config.permission.defaultMode,PUT /api/permissions 临时切换不回写 config。 */
30
+ /** 默认授权模式(config.permission.defaultMode)。 */
30
31
  permissionMode: 'default' | 'auto';
32
+ /** 会话级授权模式覆盖:Map<sessionId, mode>。会话未覆盖时回退 permissionMode。
33
+ * P1-5:auto 是高风险状态,按会话隔离避免一个标签页切换影响全部会话。 */
34
+ sessionPermissionModes: Map<string, 'default' | 'auto'>;
31
35
  /**
32
36
  * API Bearer token(认证未显式关闭时必存在)。
33
37
  * 用户配置 security.token 优先;否则自动生成并持久化到数据目录 auth-token 文件
34
38
  * (跨重启/热更新稳定,浏览器首访经 ?token= URL 获取后本地保存)。
39
+ * P2-16:bootstrap token 仅用于首设备注册;注册后轮换,API 请求认设备 token。
35
40
  */
36
41
  authToken?: string;
42
+ /** 认证管理器(token 轮换 + 设备配对审批,P2-16)。authEnabled=false 或测试上下文为 undefined。 */
43
+ authManager?: AuthManager;
37
44
  /** 主 HTTP 服务实际绑定端口(startServer 绑定后回填;热更新 spawn 新实例复用)。 */
38
45
  port?: number;
39
46
  /** Agent 类型注册表(spec: multi-agent-design)。注入 agent loop 的 runSubAgent。 */
@@ -1,7 +1,7 @@
1
1
  import type { DB } from '../db/client.js';
2
2
  import type { ArchiveRef, CompactionArchive, SessionEntry } from './types.js';
3
3
  /** Archive original entries before compaction/squash. Returns the archive id. */
4
- declare function archiveOriginalEntries(handle: DB, sessionId: string, entries: SessionEntry[], archiveType: 'compaction' | 'squash' | 'shake', summary: string, compactionId: string): Promise<string>;
4
+ declare function archiveOriginalEntries(handle: DB, sessionId: string, entries: SessionEntry[], archiveType: 'compaction' | 'squash' | 'shake' | 'clear', summary: string, compactionId: string): Promise<string>;
5
5
  /** Get an archive by id. Returns null for invalid or non-existent ids. */
6
6
  declare function getArchive(handle: DB, id: string): Promise<CompactionArchive | null>;
7
7
  /** Get the original entries stored in an archive. */
@@ -14,6 +14,7 @@ declare function forkSession(handle: DB, sessionId: string, messageIndex: number
14
14
  declare function getBranches(handle: DB, sessionId: string): Promise<Session[]>;
15
15
  /** Build a full session tree from root sessions down.
16
16
  * 每层按 metadata.lastOpenedAt 降序(fallback updatedAt、createdAt)。
17
+ * 排除软删除会话与 CLI 来源会话(Web 树仅展示 web 会话)。
17
18
  */
18
19
  declare function getTree(handle: DB): Promise<SessionTreeNode[]>;
19
20
  export { BranchPointOutOfRangeError, forkSession, getBranches, getTree };
@@ -1,5 +1,5 @@
1
- import { eq } from 'drizzle-orm';
2
- import { sessionEntries, sessions } from '../db/schema.js';
1
+ import { and, eq, isNull, ne, or } from 'drizzle-orm';
2
+ import { sessions } from '../db/schema.js';
3
3
  import { generateId } from '../shared/index.js';
4
4
  import { getEntries, insertEntry } from './message.js';
5
5
  import { createSession, getSession, rowToSession } from './session.js';
@@ -82,7 +82,7 @@ async function forkSession(handle, sessionId, messageIndex) {
82
82
  // 中途失败整体回滚,不残留永久可见的半成品分支(同 compactSession/squashRecent 写法)。
83
83
  const updated = await handle.db.transaction(async (tx) => {
84
84
  const txHandle = { db: tx, close: handle.close };
85
- const forked = await createSession(txHandle, `Branch of ${source.title}`, source.projectId ?? undefined);
85
+ const forked = await createSession(txHandle, `Branch of ${source.title}`, source.projectId ?? undefined, undefined, source.source === 'cli' ? 'cli' : 'web');
86
86
  await tx
87
87
  .update(sessions)
88
88
  .set({ parentId: sessionId, branchPoint: messageIndex })
@@ -110,14 +110,21 @@ async function forkSession(handle, sessionId, messageIndex) {
110
110
  }
111
111
  /** Get direct child sessions (branches) of a session. */
112
112
  async function getBranches(handle, sessionId) {
113
- const rows = await handle.db.select().from(sessions).where(eq(sessions.parentId, sessionId));
113
+ const rows = await handle.db
114
+ .select()
115
+ .from(sessions)
116
+ .where(and(eq(sessions.parentId, sessionId), isNull(sessions.deletedAt)));
114
117
  return rows.map(rowToSession);
115
118
  }
116
119
  /** Build a full session tree from root sessions down.
117
120
  * 每层按 metadata.lastOpenedAt 降序(fallback updatedAt、createdAt)。
121
+ * 排除软删除会话与 CLI 来源会话(Web 树仅展示 web 会话)。
118
122
  */
119
123
  async function getTree(handle) {
120
- const rows = await handle.db.select().from(sessions);
124
+ const rows = await handle.db
125
+ .select()
126
+ .from(sessions)
127
+ .where(and(isNull(sessions.deletedAt), or(isNull(sessions.source), ne(sessions.source, 'cli'))));
121
128
  const byParent = new Map();
122
129
  for (const row of rows) {
123
130
  const session = rowToSession(row);
@@ -3,7 +3,7 @@ export { forkSession, getBranches, getTree } from './branch.js';
3
3
  export { buildCompactionPrompt, compactSession, extractHotFiles, findSafeCutPoint, } from './compaction.js';
4
4
  export { entriesToChatMessages, getSessionContext, injectSnapshots, messageToChatMessage, } from './context.js';
5
5
  export { appendMessage, deleteEntriesByIds, deleteMessagesAfter, getEntries, getMessageCount, getMessages, insertEntry, } from './message.js';
6
- export { createSession, deleteSession, getLLMSegments, getSession, listSessions, migrateLegacyDetails, saveLLMSegments, segmentFingerprint, touchSession, updateSessionTitle, } from './session.js';
6
+ export { createSession, getLLMSegments, getSession, listDeletedSessions, listSessions, migrateLegacyDetails, purgeDeletedSessions, restoreSession, saveLLMSegments, segmentFingerprint, softDeleteSession, touchSession, updateSessionTitle, } from './session.js';
7
7
  export { checkFileSnapshot, getFileSnapshots, getLatestFileSnapshot, upsertFileSnapshot, } from './snapshot.js';
8
8
  export { squashRecent } from './squash.js';
9
9
  export { estimateMessageTokens, estimateTokens } from './token.js';
@@ -4,7 +4,7 @@ export { forkSession, getBranches, getTree } from './branch.js';
4
4
  export { buildCompactionPrompt, compactSession, extractHotFiles, findSafeCutPoint, } from './compaction.js';
5
5
  export { entriesToChatMessages, getSessionContext, injectSnapshots, messageToChatMessage, } from './context.js';
6
6
  export { appendMessage, deleteEntriesByIds, deleteMessagesAfter, getEntries, getMessageCount, getMessages, insertEntry, } from './message.js';
7
- export { createSession, deleteSession, getLLMSegments, getSession, listSessions, migrateLegacyDetails, saveLLMSegments, segmentFingerprint, touchSession, updateSessionTitle, } from './session.js';
7
+ export { createSession, getLLMSegments, getSession, listDeletedSessions, listSessions, migrateLegacyDetails, purgeDeletedSessions, restoreSession, saveLLMSegments, segmentFingerprint, softDeleteSession, touchSession, updateSessionTitle, } from './session.js';
8
8
  export { checkFileSnapshot, getFileSnapshots, getLatestFileSnapshot, upsertFileSnapshot, } from './snapshot.js';
9
9
  export { squashRecent } from './squash.js';
10
10
  export { estimateMessageTokens, estimateTokens } from './token.js';