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,23 +1,64 @@
1
1
  import { Hono } from 'hono';
2
- import { getCurrentVersion, performHotUpdate, serializeSessions } from '../../update/index.js';
2
+ import { getSession } from '../../session/session.js';
3
+ import { getCurrentVersion, manualInstallCommand, performHandoff, performInstall, serializeSessions, } from '../../update/index.js';
3
4
  import { apiError } from '../middleware/error.js';
4
5
  /**
5
6
  * GET /api/update — 返回后台调度器缓存的版本检查结果(spec §18.1)。
6
7
  * 路由本身不触发 npm registry 请求;调度器周期性 checkNow 写入缓存,
7
8
  * 路由读缓存(避免每个前端轮询都打外网)。
8
9
  *
9
- * POST /api/update/apply — 手动触发热更新(spec §18.2):
10
- * 序列化当前会话 → performHotUpdate(npm install -g + spawn 新实例 + handoffPort)。
11
- * 旧实例通过 handoff 端点优雅退出,新实例 restore 快照并接管端口。
12
- * 响应立即返回(不阻塞 spawn),实际接管由 handoff IPC 异步完成。
13
- * dev(vite)模式无 handoff server,返回 409——热更新仅在独立 serve 进程可用。
10
+ * POST /api/update/apply — 手动触发热更新(spec §18.2),P0 修复后顺序:
11
+ * 1. checkNow 确认有更新;
12
+ * 2. performInstall(仅安装新版本)——失败时**尚未暂停任何会话**,
13
+ * 直接返回可操作错误(409 + 手动更新命令),用户工作零损失;
14
+ * 3. 安装成功 → pauseAll 暂停活跃 run 至安全点(原子操作完成);
15
+ * 4. serializeSessions → performHandoff(写快照 + spawn 新实例);
16
+ * 5. spawn 失败 → resumeAll 回滚暂停的会话,返回明确错误。
17
+ *
18
+ * 旧实例通过 handoff 端点优雅退出,新实例 restore 快照并接管端口。
19
+ * 响应立即返回(不阻塞 spawn),实际接管由 handoff IPC 异步完成。
20
+ * dev(vite)模式无 handoff server,返回 409——热更新仅在独立 serve 进程可用。
14
21
  */
15
22
  function createUpdateRoute(ctx) {
16
23
  const app = new Hono();
17
- app.get('/', (c) => {
24
+ app.get('/', async (c) => {
25
+ // 热更新影响面(P2-11):apply 前由前端确认框逐项展示受影响对象。
26
+ // 顶层 run(主 agent)+ 终端数;子 agent 归父会话,不重复列出。
27
+ const active = ctx.agentManager.listActive();
28
+ const topRuns = active.filter((r) => !r.parentSessionId);
29
+ const runs = [];
30
+ for (const r of topRuns) {
31
+ let title = r.sessionId.slice(0, 8);
32
+ try {
33
+ const session = await getSession(ctx.db, r.sessionId);
34
+ if (session)
35
+ title = session.title;
36
+ }
37
+ catch {
38
+ // 会话查询失败:回退 id 前缀展示
39
+ }
40
+ runs.push({
41
+ sessionId: r.sessionId,
42
+ title,
43
+ ...(r.agentType ? { agentType: r.agentType } : {}),
44
+ });
45
+ }
46
+ const impact = { runs, terminalCount: ctx.ptyManager.list().length };
47
+ // P2-8:update.enabled=false 时无 handoff server,apply 必然 409。
48
+ // 不再触发 checkNow,也不返回 hasUpdate,避免横幅出现一个点了必失败的应用按钮。
49
+ if (ctx.config.update.enabled === false) {
50
+ const v = getCurrentVersion();
51
+ return c.json({
52
+ hasUpdate: false,
53
+ disabled: true,
54
+ currentVersion: v,
55
+ latestVersion: v,
56
+ impact,
57
+ });
58
+ }
18
59
  const cached = ctx.updateScheduler.getLastResult();
19
60
  if (cached)
20
- return c.json(cached);
61
+ return c.json({ ...cached, impact });
21
62
  // 无缓存(首次启动延迟未到):同步触发一次,避免前端首屏空。
22
63
  // 不 await——保持 GET 语义非阻塞;前端下次轮询拿到结果。
23
64
  void ctx.updateScheduler.checkNow();
@@ -26,6 +67,7 @@ function createUpdateRoute(ctx) {
26
67
  hasUpdate: false,
27
68
  currentVersion: v,
28
69
  latestVersion: v,
70
+ impact,
29
71
  });
30
72
  });
31
73
  app.post('/apply', async (c) => {
@@ -36,20 +78,60 @@ function createUpdateRoute(ctx) {
36
78
  if (!result.hasUpdate) {
37
79
  return apiError(c, 409, 'NO_UPDATE', '已是最新版本,无需热更新');
38
80
  }
81
+ // P0 修复:install 先行。失败(网络/权限/unknown 安装方式)时未触碰任何
82
+ // 会话状态,直接返回可操作错误——旧流程先 pause 后 install,失败时
83
+ // 已暂停/强杀的会话无回滚路径。
84
+ const install = await performInstall({});
85
+ if (install._tag === 'install_failed') {
86
+ return apiError(c, 409, 'INSTALL_FAILED', install.error, {
87
+ command: manualInstallCommand('c0de-agent'),
88
+ });
89
+ }
90
+ if (install._tag === 'manual_install_required') {
91
+ return apiError(c, 409, 'MANUAL_UPDATE_REQUIRED', install.error, {
92
+ command: install.command,
93
+ });
94
+ }
95
+ const method = { kind: install.installMethod };
96
+ // 安装已成功 → 暂停所有活跃 run,等其到达安全暂停点(原子操作完成)。
97
+ // 注意:暂停不是「可恢复快照」——新实例只有 DB 状态、无内存 agent run,
98
+ // 进行中的对话会以 interrupted 呈现,用户重发上一条消息即可继续(幂等
99
+ // 检查跳过重复 append)。工具副作用不回滚。
100
+ const pauseTimeoutMs = ctx.config.update.pauseTimeoutMs ?? 30_000;
101
+ const pauseResult = await ctx.agentManager.pauseAll(pauseTimeoutMs);
39
102
  const snapshot = await serializeSessions(ctx.db, ctx.config);
40
- const r = await performHotUpdate(snapshot, {
103
+ const r = await performHandoff(snapshot, method, {
41
104
  handoffPort: ctx.handoff.port,
42
105
  port: ctx.port,
43
- authToken: ctx.authToken,
106
+ // P2-16:传当前 bootstrap(轮换后 ctx.authToken 可能已过期);
107
+ // 旧实例 handoff 端点经 verifyHandoff 接受当前/历史 bootstrap 与设备 token。
108
+ authToken: ctx.authManager?.bootstrap ?? ctx.authToken,
44
109
  });
45
- if (r._tag === 'success') {
46
- return c.json({
47
- ok: true,
48
- snapshotPath: r.snapshotPath,
49
- latestVersion: result.latestVersion,
110
+ if (r._tag !== 'success') {
111
+ // P0 回滚:spawn 失败(含程序文件未就绪等)→ 恢复被暂停的会话。
112
+ // forcedAbort 的无法恢复(已中止),但这是超时兜底,属可接受损失。
113
+ for (const id of pauseResult.pausedIds) {
114
+ ctx.agentManager.resume(id);
115
+ }
116
+ if (r._tag === 'manual_install_required') {
117
+ return apiError(c, 409, 'MANUAL_UPDATE_REQUIRED', r.error, {
118
+ command: r.command,
119
+ snapshotPath: r.snapshotPath,
120
+ resumedSessions: pauseResult.pausedIds.length,
121
+ });
122
+ }
123
+ return apiError(c, 500, 'HOT_UPDATE_FAILED', `${r._tag}: ${r.error}`, {
124
+ resumedSessions: pauseResult.pausedIds.length,
50
125
  });
51
126
  }
52
- return apiError(c, 500, 'HOT_UPDATE_FAILED', `${r._tag}: ${'error' in r ? r.error : 'unknown'}`);
127
+ return c.json({
128
+ ok: true,
129
+ snapshotPath: r.snapshotPath,
130
+ latestVersion: result.latestVersion,
131
+ installMethod: r.installMethod,
132
+ pausedSessions: pauseResult.paused,
133
+ forcedAbort: pauseResult.forcedAbort,
134
+ });
53
135
  });
54
136
  return app;
55
137
  }
@@ -1,8 +1,8 @@
1
1
  import type { Hono } from 'hono';
2
2
  import type { DB } from '../db/client.js';
3
- import type { Registry } from '../llm/registry.js';
4
3
  import type { Config } from '../shared/types/config.js';
5
4
  import { checkForUpdate, createHandoffServer } from '../update/index.js';
5
+ import { buildRegistryFromConfig, syncRegistryFromConfig } from './registry-config.js';
6
6
  import type { ServerContext } from './types.js';
7
7
  type StartServerOptions = {
8
8
  port?: number;
@@ -31,15 +31,6 @@ type BootstrappedServer = {
31
31
  ctx: ServerContext;
32
32
  close(): Promise<void>;
33
33
  };
34
- /** 把 config.providers 注册到新建的 LLM registry(修复此前空 registry 的遗漏)。 */
35
- declare function buildRegistryFromConfig(config: Config): Registry;
36
- /**
37
- * config 变更后原子地同步 registry:在隔离的 next registry 上重建全部路由,
38
- * 完成后一次性替换 registry 内部 table 引用。运行中的 resolveRoute 任何时刻
39
- * 看到的都是完整的旧表或完整的新表,不会读到「已清空但未注册完」的半状态,
40
- * 因此不会把本可用的 provider 误判为 NoRoute。ServerContext 立即生效,无需重启。
41
- */
42
- declare function syncRegistryFromConfig(registry: Registry, config: Config): void;
43
34
  /** 解析 PGLite 持久化数据目录:优先 C0DE_DB_DIR,否则全局数据根下 pglite 子目录。
44
35
  *
45
36
  * 历史上数据库放在 <cwd>/.c0de/pglite(项目级),但项目 DB schema 通过 projectId
@@ -50,8 +41,9 @@ declare function resolveDbDir(): string;
50
41
  * 1. security.authEnabled 显式为 false → 无认证(用户显式选择)。
51
42
  * 2. 用户配置 security.token → 使用。
52
43
  * 3. 环境变量 C0DE_AUTH_TOKEN(热更新新实例经 updater 传入)→ 采用并落盘。
53
- * 4. dataDir 下 auth-token 文件(跨重启/热更新稳定)→ 复用。
54
- * 5. 都没有 → 生成随机 token 并写入 auth-token(0600)。
44
+ * 4. 已有已注册设备 → 复用 auth-token 文件(跨重启稳定;设备 token 存 devices.json)。
45
+ * 5. 无已注册设备(首设备待注册态)→ 每次 serve 都重新生成 bootstrap,刷新
46
+ * firstDeviceTtlMs 窗口。此前「复用旧文件」会让过期 bootstrap 无法通过重启恢复。
55
47
  */
56
48
  declare function resolveAuthToken(config: Config, dataDir: string): string | undefined;
57
49
  /**
@@ -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';
@@ -8,56 +8,20 @@ import { serve } from '@hono/node-server';
8
8
  import { WebSocketServer } from 'ws';
9
9
  import { BUILTIN_AGENTS, createAgentRegistry } from '../core/agents/index.js';
10
10
  import { loadConfig } from '../core/config.js';
11
- import { decryptSecret } from '../core/secret.js';
12
11
  import { createAndPopulateRegistry } from '../core/workflows/index.js';
13
12
  import { createDB, migrateDB } from '../db/index.js';
14
- import { createRegistry, overrideToCapabilities, rebuildRegistry, registerProvider, } from '../llm/registry.js';
15
13
  import { initPlugins } from '../plugins/index.js';
14
+ import { markDeadBackgroundJobs } from '../session/jobs.js';
15
+ import { purgeDeletedSessions, purgeEmptySessions, purgeTemporarySessions, TRASH_PURGE_GRACE_MS, TRASH_RETENTION_MS, } from '../session/session.js';
16
16
  import { createDefaultRegistry, createDefaultURLRegistry } from '../tools/index.js';
17
- import { checkForUpdate, createHandoffServer, createUpdateScheduler, requestHandoff, restoreSessions, } from '../update/index.js';
17
+ import { checkForUpdate, confirmHandoff, createHandoffServer, createUpdateScheduler, requestHandoff, restoreSessions, } from '../update/index.js';
18
18
  import { createAgentManager } from './agent-manager.js';
19
19
  import { createApp } from './app.js';
20
+ import { createAuthManager } from './auth-manager.js';
20
21
  import { isAllowedOrigin } from './middleware/cors.js';
21
22
  import { createPermissionStore } from './permission/store.js';
23
+ import { buildRegistryFromConfig, syncRegistryFromConfig } from './registry-config.js';
22
24
  import { PTYManager } from './terminal/pty-manager.js';
23
- /** 把 config.providers 注册到新建的 LLM registry(修复此前空 registry 的遗漏)。 */
24
- function buildRegistryFromConfig(config) {
25
- const registry = createRegistry();
26
- for (const p of config.providers) {
27
- registerProviderFromConfig(registry, p);
28
- }
29
- return registry;
30
- }
31
- function registerProviderFromConfig(registry, p) {
32
- // 兼容 config.json 中以 _tag 标识 provider 的格式(name 缺失时回退到 _tag)
33
- const name = p.name || p._tag;
34
- if (!name || !p.baseURL)
35
- return;
36
- // baseURL 已含 /v1 时用 /chat/completions,避免 /v1/v1 双重前缀
37
- const path = p.baseURL.replace(/\/+$/, '').endsWith('/v1') ? '/chat/completions' : undefined;
38
- registerProvider(registry, {
39
- name,
40
- baseURL: p.baseURL,
41
- apiKey: p.apiKey ? decryptSecret(p.apiKey) : p.apiKey,
42
- ...(path ? { path } : {}),
43
- // 传递用户配置的 per-model capabilities(contextWindow 等),
44
- // 否则 resolveRoute 回退到 DEFAULT_MODEL_CAPABILITIES,可能导致预算过小。
45
- ...(p.models ? { models: overrideToCapabilities(p.models) } : {}),
46
- });
47
- }
48
- /**
49
- * config 变更后原子地同步 registry:在隔离的 next registry 上重建全部路由,
50
- * 完成后一次性替换 registry 内部 table 引用。运行中的 resolveRoute 任何时刻
51
- * 看到的都是完整的旧表或完整的新表,不会读到「已清空但未注册完」的半状态,
52
- * 因此不会把本可用的 provider 误判为 NoRoute。ServerContext 立即生效,无需重启。
53
- */
54
- function syncRegistryFromConfig(registry, config) {
55
- rebuildRegistry(registry, (next) => {
56
- for (const p of config.providers) {
57
- registerProviderFromConfig(next, p);
58
- }
59
- });
60
- }
61
25
  /** 全局数据根目录:XDG_DATA_HOME 优先,否则 ~/.local/share/c0de。
62
26
  * 与 opencode (~/.local/share/opencode/)、oh-my-pi (~/.omp/agent/) 同约定——
63
27
  * 数据库等运行时数据放全局,项目 .c0de/ 只留配置和扩展。 */
@@ -82,8 +46,9 @@ function resolveDbDir() {
82
46
  * 1. security.authEnabled 显式为 false → 无认证(用户显式选择)。
83
47
  * 2. 用户配置 security.token → 使用。
84
48
  * 3. 环境变量 C0DE_AUTH_TOKEN(热更新新实例经 updater 传入)→ 采用并落盘。
85
- * 4. dataDir 下 auth-token 文件(跨重启/热更新稳定)→ 复用。
86
- * 5. 都没有 → 生成随机 token 并写入 auth-token(0600)。
49
+ * 4. 已有已注册设备 → 复用 auth-token 文件(跨重启稳定;设备 token 存 devices.json)。
50
+ * 5. 无已注册设备(首设备待注册态)→ 每次 serve 都重新生成 bootstrap,刷新
51
+ * firstDeviceTtlMs 窗口。此前「复用旧文件」会让过期 bootstrap 无法通过重启恢复。
87
52
  */
88
53
  function resolveAuthToken(config, dataDir) {
89
54
  if (config.security.authEnabled === false)
@@ -102,13 +67,16 @@ function resolveAuthToken(config, dataDir) {
102
67
  }
103
68
  return envToken;
104
69
  }
105
- try {
106
- const existing = readFileSync(tokenFile, 'utf-8').trim();
107
- if (existing)
108
- return existing;
109
- }
110
- catch {
111
- // 文件不存在,走生成分支
70
+ // 已有设备 → 复用 auth-token 文件,保持跨重启稳定(已授权设备经 devices.json 认 token)。
71
+ if (hasRegisteredDevices(dataDir)) {
72
+ try {
73
+ const existing = readFileSync(tokenFile, 'utf-8').trim();
74
+ if (existing)
75
+ return existing;
76
+ }
77
+ catch {
78
+ // 文件不存在,走生成分支
79
+ }
112
80
  }
113
81
  const generated = randomBytes(24).toString('hex');
114
82
  try {
@@ -120,6 +88,19 @@ function resolveAuthToken(config, dataDir) {
120
88
  }
121
89
  return generated;
122
90
  }
91
+ /** dataDir 下是否存在已授权设备(devices.json 非空)。 */
92
+ function hasRegisteredDevices(dataDir) {
93
+ try {
94
+ const path = join(dataDir, 'devices.json');
95
+ if (!existsSync(path))
96
+ return false;
97
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
98
+ return Array.isArray(parsed.devices) && parsed.devices.length > 0;
99
+ }
100
+ catch {
101
+ return false;
102
+ }
103
+ }
123
104
  /** 只读 dataDir 下已存在的 auth-token(新实例 handoff 握手用;不生成)。 */
124
105
  function readAuthTokenFile(dataDir) {
125
106
  try {
@@ -185,6 +166,19 @@ async function buildServerContext(db, opts = {}) {
185
166
  // 工作流注册表:三级发现(builtin → global → project),eager 初始化。
186
167
  // 此前是惰性 getter 只注册 builtin,导致项目级 .c0de/workflows/*.js 永远不可见。
187
168
  const workflowRegistry = await createAndPopulateRegistry(cwd);
169
+ const dataDir = resolveDbDir();
170
+ // 先解析/生成 bootstrap token(落盘),再创建 authManager 读取——
171
+ // 首启时文件由 resolveAuthToken 生成,顺序颠倒会导致 bootstrap 读到空值。
172
+ const resolvedToken = resolveAuthToken(config, dataDir);
173
+ const authManager = createAuthManager({
174
+ dataDir,
175
+ ...(config.security.token && config.security.token.length > 0
176
+ ? { staticToken: config.security.token }
177
+ : {}),
178
+ ...(config.security.firstDeviceTtlMs !== undefined
179
+ ? { firstDeviceTtlMs: config.security.firstDeviceTtlMs }
180
+ : {}),
181
+ });
188
182
  const ctx = {
189
183
  db,
190
184
  config,
@@ -196,7 +190,10 @@ async function buildServerContext(db, opts = {}) {
196
190
  agentManager: createAgentManager(),
197
191
  permissionStore: createPermissionStore(),
198
192
  permissionMode: config.permission.defaultMode,
199
- authToken: resolveAuthToken(config, resolveDbDir()),
193
+ sessionPermissionModes: new Map(),
194
+ sessionAlwaysAllow: new Map(),
195
+ authToken: resolvedToken,
196
+ authManager: config.security.authEnabled === false ? undefined : authManager,
200
197
  // Agent 注册表:内置 4 个默认 agent;项目/用户自定义 agent 可在启动后补充加载。
201
198
  agentRegistry: (() => {
202
199
  const reg = createAgentRegistry();
@@ -220,11 +217,12 @@ async function buildServerContext(db, opts = {}) {
220
217
  ctx,
221
218
  dispose: async () => {
222
219
  // dev 重建前调用:中止活跃 run + settle pending permission +
223
- // 停 scheduler。**不 close db**(调用方持有)。
220
+ // 停 scheduler + 关 devices.json watcher。**不 close db**(调用方持有)。
224
221
  ctx.agentManager.dispose();
225
222
  ctx.permissionStore.dispose();
226
223
  ctx.updateScheduler.stop();
227
224
  ctx.ptyManager.dispose();
225
+ ctx.authManager?.dispose();
228
226
  },
229
227
  };
230
228
  }
@@ -331,12 +329,60 @@ async function bootstrapServerContext(opts = {}) {
331
329
  db = await createDevDb(cwd);
332
330
  }
333
331
  const { ctx, dispose } = await buildServerContext(db, opts);
332
+ // P2-4 + A3:回收站两阶段清理——启动时清一次 + 每 24h 清一次。
333
+ // 阶段一标记到期条目进入宽限期(UI 可见可恢复),阶段二物理清除宽限期满的条目。
334
+ // fire-and-forget:清理失败不阻塞服务启动;结果落日志可查。
335
+ const runTrashPurge = () => {
336
+ void purgeDeletedSessions(db)
337
+ .then(({ marked, deleted }) => {
338
+ if (marked > 0) {
339
+ console.log(`[server] 回收站清理:${marked} 个会话已到期,进入 ${TRASH_PURGE_GRACE_MS / (24 * 60 * 60 * 1000)} 天宽限期(期间可恢复)`);
340
+ }
341
+ if (deleted > 0) {
342
+ console.log(`[server] 回收站清理:已物理清除 ${deleted} 个超过宽限期的会话(保留期 ${TRASH_RETENTION_MS / (24 * 60 * 60 * 1000)} 天)`);
343
+ }
344
+ })
345
+ .catch(() => { });
346
+ };
347
+ runTrashPurge();
348
+ const purgeTimer = setInterval(() => {
349
+ runTrashPurge();
350
+ // P2:CLI print / 工作流临时会话同样每日清理(30 天保留)
351
+ void purgeTemporarySessions(db).catch(() => { });
352
+ // C4:长期空置的 web 空会话每日清理(7 天保留)
353
+ void purgeEmptySessions(db).catch(() => { });
354
+ }, 24 * 60 * 60 * 1000);
355
+ purgeTimer.unref();
356
+ // P2:启动时同步清一次临时会话(每日定时之外的兜底)
357
+ void purgeTemporarySessions(db).catch(() => { });
358
+ // C4:启动时同步清一次空会话(兜底)
359
+ void purgeEmptySessions(db).catch(() => { });
360
+ // P2:标记崩溃遗留的后台子 agent 任务(lastRun=running 的 subagent 会话)
361
+ // 并给父会话注入失败通知。fire-and-forget:失败不阻塞启动。
362
+ void markDeadBackgroundJobs(db).catch(() => { });
363
+ // P2-15:PGLite WAL 周期刷盘(30s),缩短 kill -9 等突然停机时的已提交数据丢失窗口。
364
+ const syncTimer = db.sync
365
+ ? setInterval(() => {
366
+ void db.sync?.().catch(() => { });
367
+ }, 30_000)
368
+ : null;
369
+ syncTimer?.unref();
334
370
  return {
335
371
  ctx,
336
372
  close: async () => {
373
+ clearInterval(purgeTimer);
374
+ if (syncTimer)
375
+ clearInterval(syncTimer);
337
376
  await dispose();
338
- if (ownsDb)
377
+ if (ownsDb) {
339
378
  await db.close();
379
+ // P0 修复:优雅关闭(含热更新 handoff)时显式释放 .dev.lock。
380
+ // 旧流程依赖新实例的 stale-PID 检测清理锁文件,而旧进程在 handoff
381
+ // 响应后延迟 250ms 才退出——新实例若在该窗口内做锁检查(PID 仍存活)
382
+ // 会抛「Database is locked」崩溃,且旧实例已退出,双输。显式释放后
383
+ // 锁文件在进程存活期就已移除,新实例直接重建锁,无竞态窗口。
384
+ releaseDevDbLock(resolveDbDir());
385
+ }
340
386
  },
341
387
  };
342
388
  }
@@ -378,25 +424,15 @@ async function requestHandoffWithRetry(port, token, maxAttempts = 30, delayMs =
378
424
  }
379
425
  throw new Error(`handoff: old instance on port ${port} did not release after ${maxAttempts} attempts`);
380
426
  }
381
- async function startServer(opts = {}) {
382
- const port = opts.port ?? 3000;
383
- // spec §18.3:新实例从 --handoff-port 拿到旧实例端口,请求优雅退出后绑端口。
384
- // 握手 token:updater spawn 时经 C0DE_AUTH_TOKEN 环境变量传入,否则读 auth-token 文件。
385
- if (opts.handoffPort && !opts.skipHandoff) {
386
- const pendingToken = process.env.C0DE_AUTH_TOKEN ?? readAuthTokenFile(resolveDbDir());
387
- await requestHandoffWithRetry(opts.handoffPort, pendingToken);
388
- }
389
- const { ctx, close: closeCtx } = await bootstrapServerContext(opts);
390
- const app = createApp(ctx);
391
- // config.update.enabled 时启动后台调度器。
392
- if (ctx.config.update.enabled)
393
- ctx.updateScheduler.start();
394
- const server = serve({ fetch: app.fetch, port });
395
- ctx.port = port;
396
- // WebSocket:终端双向流。Hono v2 无原生 WS,用 ws 包直接挂载到 HTTP server。
397
- // 匹配 /api/terminal/:id/ws → ptyManager.attachWebSocket
398
- const wss = new WebSocketServer({ noServer: true });
399
- const expectedToken = ctx.authToken;
427
+ /**
428
+ * 两阶段 handoff 的确认窗口(P3 回滚修复):旧实例响应 POST /handoff 后不退出,
429
+ * 等待新实例绑定端口成功后 POST /handoff-confirm 再退。窗口内未收到确认
430
+ * (新实例启动失败)→ 旧实例原地复活重新接管端口,用户工作零损失。
431
+ * 窗口需覆盖新实例 bootstrap(DB 迁移/快照恢复)耗时,取 60s 留裕量。
432
+ */
433
+ const HANDOFF_CONFIRM_WINDOW_MS = 60_000;
434
+ /** 组装终端 WebSocket upgrade 处理器(bringUp 与复活共用)。 */
435
+ function attachTerminalUpgrade(ctx, wss, server) {
400
436
  server.on('upgrade', (req, socket, head) => {
401
437
  const url = new URL(req.url ?? '', `http://${req.headers.host ?? 'localhost'}`);
402
438
  const match = url.pathname.match(/^\/api\/terminal\/([^/]+)\/ws$/);
@@ -412,10 +448,14 @@ async function startServer(opts = {}) {
412
448
  socket.destroy();
413
449
  return;
414
450
  }
415
- // 认证:token 通过 query 参数传递(浏览器 WS 无法设置 Authorization header)
416
- if (expectedToken) {
451
+ // 认证:token 通过 query 参数传递(浏览器 WS 无法设置 Authorization header)。
452
+ // P2-16:优先 authManager 校验(设备 token);回退 ctx.authToken 兼容测试上下文。
453
+ if (ctx.authManager || ctx.authToken) {
417
454
  const token = url.searchParams.get('token');
418
- if (token !== expectedToken) {
455
+ const ok = ctx.authManager
456
+ ? ctx.authManager.verify(token ?? undefined)
457
+ : token === ctx.authToken;
458
+ if (!ok) {
419
459
  socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
420
460
  socket.destroy();
421
461
  return;
@@ -434,31 +474,126 @@ async function startServer(opts = {}) {
434
474
  }
435
475
  });
436
476
  });
437
- let closed = false;
438
- const closeMain = async () => {
439
- if (closed)
440
- return;
441
- closed = true;
442
- wss.close();
443
- server.close();
444
- await closeCtx();
477
+ }
478
+ async function startServer(opts = {}) {
479
+ const port = opts.port ?? 3000;
480
+ // spec §18.3:新实例从 --handoff-port 拿到旧实例端口,请求优雅退出后绑端口。
481
+ // 握手 token:updater spawn 时经 C0DE_AUTH_TOKEN 环境变量传入,否则读 auth-token 文件。
482
+ if (opts.handoffPort && !opts.skipHandoff) {
483
+ const pendingToken = process.env.C0DE_AUTH_TOKEN ?? readAuthTokenFile(resolveDbDir());
484
+ await requestHandoffWithRetry(opts.handoffPort, pendingToken);
485
+ }
486
+ // 两阶段 handoff 状态(旧实例视角):确认窗口定时器与复活互斥锁。
487
+ let confirmTimer = null;
488
+ let reviving = false;
489
+ let handoffServer;
490
+ let currentClose = null;
491
+ // RunningServer 暴露的稳定句柄(复活后更新;app/authToken 每次 bringUp 重建)。
492
+ let currentApp = null;
493
+ let currentAuthToken;
494
+ const clearConfirmTimer = () => {
495
+ if (confirmTimer) {
496
+ clearTimeout(confirmTimer);
497
+ confirmTimer = null;
498
+ }
499
+ };
500
+ /**
501
+ * 组装完整服务栈(bootstrap → app → 调度器 → 绑定端口 → WS → handoff server)。
502
+ * 首次启动与超时复活共用。复活时新实例可能已绑定端口:serve() 抛 EADDRINUSE
503
+ * 说明新实例实际上已接管(确认请求丢失),旧实例退出即可。
504
+ */
505
+ const bringUp = async () => {
506
+ const { ctx, close: closeCtx } = await bootstrapServerContext(opts);
507
+ const app = createApp(ctx);
508
+ // config.update.enabled 时启动后台调度器。
509
+ if (ctx.config.update.enabled)
510
+ ctx.updateScheduler.start();
511
+ const server = serve({ fetch: app.fetch, port });
512
+ ctx.port = port;
513
+ // WebSocket:终端双向流。Hono v2 无原生 WS,用 ws 包直接挂载到 HTTP server。
514
+ const wss = new WebSocketServer({ noServer: true });
515
+ attachTerminalUpgrade(ctx, wss, server);
516
+ let closed = false;
517
+ const closeMain = async () => {
518
+ if (closed)
519
+ return;
520
+ closed = true;
521
+ wss.close();
522
+ server.close();
523
+ await closeCtx();
524
+ };
525
+ const close = async () => {
526
+ clearConfirmTimer();
527
+ await closeMain();
528
+ await handoffServer?.close();
529
+ };
530
+ currentClose = close;
531
+ currentApp = app;
532
+ currentAuthToken = ctx.authToken;
533
+ // spec §18.3 handoff server:旧实例收到 POST /handoff 后关闭主服务与资源,
534
+ // 响应 200。两阶段交接:不再立即退出——等待新实例 POST /handoff-confirm
535
+ // (onConfirm)确认接管;窗口超时未确认则 revive 原地复活。
536
+ // config.update.enabled=false 或 skipHandoff 时跳过。
537
+ if (ctx.config.update.enabled && !opts.skipHandoff) {
538
+ const createHandoff = opts.createHandoffFn ?? createHandoffServer;
539
+ handoffServer = await createHandoff(closeMain, {
540
+ // P2-16:token 轮换后新旧实例 bootstrap 可能不同,用 verifyHandoff 接受
541
+ // 当前/历史 bootstrap 与设备 token;无 authManager 回退字符串比较。
542
+ ...(ctx.authManager
543
+ ? { verify: (t) => ctx.authManager?.verifyHandoff(t) ?? false }
544
+ : { expectedToken: ctx.authToken }),
545
+ exitAfterResponse: false,
546
+ onConfirm: () => {
547
+ // 新实例确认已接管端口 → 旧实例退出(延迟保证确认响应刷出)。
548
+ clearConfirmTimer();
549
+ setTimeout(() => process.exit(0), 250);
550
+ },
551
+ });
552
+ ctx.handoff = { port: handoffServer.port, server: handoffServer };
553
+ // 确认窗口:新实例启动失败时旧实例复活(保留进程句柄,定时器不可 unref)。
554
+ confirmTimer = setTimeout(() => {
555
+ void (async () => {
556
+ if (reviving)
557
+ return;
558
+ reviving = true;
559
+ try {
560
+ await bringUp();
561
+ console.warn('[handoff] 新实例未确认接管,旧实例已复活并重新接管端口');
562
+ }
563
+ catch (err) {
564
+ if (err.code === 'EADDRINUSE') {
565
+ // 新实例已绑定端口(确认请求丢失)→ 它已接管,旧实例正常退出。
566
+ console.warn('[handoff] 端口已被新实例接管,旧实例退出');
567
+ process.exit(0);
568
+ }
569
+ console.error('[handoff] 旧实例复活失败,退出:', err);
570
+ process.exit(1);
571
+ }
572
+ finally {
573
+ reviving = false;
574
+ }
575
+ })();
576
+ }, HANDOFF_CONFIRM_WINDOW_MS);
577
+ }
578
+ // 两阶段交接第二阶段:新实例绑定成功后确认旧实例可退出。
579
+ // 旧实例(无 handoffPort)跳过;确认失败静默(窗口超时兜底)。
580
+ if (opts.handoffPort) {
581
+ const confirmToken = process.env.C0DE_AUTH_TOKEN ?? readAuthTokenFile(resolveDbDir());
582
+ void confirmHandoff(opts.handoffPort, '127.0.0.1', confirmToken);
583
+ }
445
584
  };
446
- const close = async () => {
447
- await closeMain();
448
- await handoffServer?.close();
585
+ await bringUp();
586
+ if (!currentApp)
587
+ throw new Error('server failed to start: app not built');
588
+ return {
589
+ app: currentApp,
590
+ port,
591
+ close: async () => {
592
+ await currentClose?.();
593
+ },
594
+ ...(currentAuthToken ? { authToken: currentAuthToken } : {}),
449
595
  };
450
- // spec §18.3 handoff server:旧实例收到 POST /handoff 后关闭主服务与资源,
451
- // 响应 200,随后退出进程(exitAfterResponse),端口让渡给新实例。
452
- // config.update.enabled=false 或 skipHandoff 时跳过。
453
- let handoffServer;
454
- if (ctx.config.update.enabled && !opts.skipHandoff) {
455
- const createHandoff = opts.createHandoffFn ?? createHandoffServer;
456
- handoffServer = await createHandoff(closeMain, {
457
- expectedToken: ctx.authToken,
458
- exitAfterResponse: true,
459
- });
460
- ctx.handoff = { port: handoffServer.port, server: handoffServer };
461
- }
462
- return { app, port, close, ...(ctx.authToken ? { authToken: ctx.authToken } : {}) };
463
596
  }
597
+ // P1-1:buildRegistryFromConfig/syncRegistryFromConfig 实现移入 registry-config.ts,
598
+ // 此处 re-export 保留 server.ts 历史导出兼容(测试/CLI 引用)。
464
599
  export { acquireDevDbLock, bootstrapServerContext, buildRegistryFromConfig, buildServerContext, createDevDb, releaseDevDbLock, resolveAuthToken, resolveDbDir, startServer, syncRegistryFromConfig, };