c0de-agent 1.8.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +21 -7
  2. package/dist/cli/commands/acp.js +2 -0
  3. package/dist/cli/commands/chat.js +2 -0
  4. package/dist/cli/commands/config.js +8 -4
  5. package/dist/cli/commands/serve.d.ts +1 -0
  6. package/dist/cli/commands/serve.js +4 -1
  7. package/dist/cli/deps.d.ts +7 -5
  8. package/dist/cli/deps.js +23 -7
  9. package/dist/cli/index.d.ts +1 -1
  10. package/dist/cli/index.js +68 -37
  11. package/dist/cli/modes/print.d.ts +3 -1
  12. package/dist/cli/modes/print.js +21 -4
  13. package/dist/core/agent.js +3 -4
  14. package/dist/core/config.d.ts +18 -1
  15. package/dist/core/config.js +55 -4
  16. package/dist/core/loop/compaction.d.ts +31 -0
  17. package/dist/core/loop/compaction.js +137 -0
  18. package/dist/core/loop/persist.d.ts +11 -0
  19. package/dist/core/loop/persist.js +107 -0
  20. package/dist/core/loop/segment.d.ts +8 -0
  21. package/dist/core/loop/segment.js +72 -0
  22. package/dist/core/loop/stream-collect.d.ts +47 -0
  23. package/dist/core/loop/stream-collect.js +183 -0
  24. package/dist/core/loop/subagent.d.ts +12 -0
  25. package/dist/core/loop/subagent.js +171 -0
  26. package/dist/core/loop/todo.d.ts +5 -0
  27. package/dist/core/loop/todo.js +26 -0
  28. package/dist/core/loop.d.ts +2 -20
  29. package/dist/core/loop.js +13 -659
  30. package/dist/core/prompt-registry.d.ts +1 -1
  31. package/dist/core/prompt-registry.js +10 -0
  32. package/dist/core/slash.js +1 -1
  33. package/dist/core/todo-tags.d.ts +46 -0
  34. package/dist/core/todo-tags.js +192 -0
  35. package/dist/core/workflows/builtins.js +4 -4
  36. package/dist/core/workflows/discovery.d.ts +1 -1
  37. package/dist/core/workflows/discovery.js +4 -1
  38. package/dist/core/workflows/index.d.ts +1 -1
  39. package/dist/db/schema.d.ts +17 -0
  40. package/dist/db/schema.js +3 -1
  41. package/dist/llm/registry.d.ts +30 -3
  42. package/dist/llm/registry.js +25 -7
  43. package/dist/llm/retry.d.ts +4 -2
  44. package/dist/llm/retry.js +7 -6
  45. package/dist/llm/schema/errors.d.ts +13 -3
  46. package/dist/llm/schema/errors.js +35 -3
  47. package/dist/llm/transport.js +5 -1
  48. package/dist/project/resolve.js +2 -2
  49. package/dist/server/agent-manager.d.ts +15 -0
  50. package/dist/server/agent-manager.js +35 -11
  51. package/dist/server/app.js +2 -1
  52. package/dist/server/context.js +2 -0
  53. package/dist/server/middleware/cors.d.ts +11 -2
  54. package/dist/server/middleware/cors.js +15 -12
  55. package/dist/server/routes/chat.js +212 -145
  56. package/dist/server/routes/config.js +7 -3
  57. package/dist/server/routes/session.js +6 -2
  58. package/dist/server/routes/terminal.js +12 -0
  59. package/dist/server/routes/todo.js +5 -2
  60. package/dist/server/routes/update.d.ts +1 -0
  61. package/dist/server/routes/update.js +11 -4
  62. package/dist/server/routes/workflows.js +2 -2
  63. package/dist/server/server.d.ts +28 -3
  64. package/dist/server/server.js +127 -38
  65. package/dist/server/types.d.ts +9 -1
  66. package/dist/session/branch.d.ts +10 -2
  67. package/dist/session/branch.js +98 -29
  68. package/dist/session/compaction.js +1 -0
  69. package/dist/session/context.d.ts +3 -2
  70. package/dist/session/context.js +36 -3
  71. package/dist/session/snapshot.d.ts +9 -3
  72. package/dist/session/snapshot.js +45 -4
  73. package/dist/session/squash.js +32 -24
  74. package/dist/session/types.d.ts +2 -0
  75. package/dist/shared/types/agent.d.ts +11 -0
  76. package/dist/shared/types/config.d.ts +7 -6
  77. package/dist/shared/utils/path.d.ts +2 -3
  78. package/dist/shared/utils/path.js +20 -4
  79. package/dist/tools/builtin/todo.d.ts +7 -0
  80. package/dist/tools/builtin/todo.js +28 -20
  81. package/dist/tools/index.d.ts +14 -3
  82. package/dist/tools/index.js +36 -14
  83. package/dist/update/index.d.ts +1 -1
  84. package/dist/update/index.js +1 -1
  85. package/dist/update/ipc.d.ts +32 -5
  86. package/dist/update/ipc.js +53 -7
  87. package/dist/update/updater.d.ts +7 -3
  88. package/dist/update/updater.js +14 -6
  89. package/dist/update/version.d.ts +6 -1
  90. package/dist/update/version.js +18 -3
  91. package/drizzle/0005_snapshot_mtime.sql +1 -0
  92. package/drizzle/meta/0005_snapshot.json +855 -0
  93. package/drizzle/meta/_journal.json +7 -0
  94. package/package.json +1 -7
  95. package/dist/mcp/index.d.ts +0 -1
  96. package/dist/mcp/index.js +0 -3
@@ -23,6 +23,8 @@ type StartServerOptions = {
23
23
  type RunningServer = {
24
24
  app: Hono;
25
25
  port: number;
26
+ /** 认证 token(authEnabled=false 时为 undefined);serve 命令据此打印带 token 的 URL。 */
27
+ authToken?: string;
26
28
  close(): Promise<void>;
27
29
  };
28
30
  type BootstrappedServer = {
@@ -32,8 +34,10 @@ type BootstrappedServer = {
32
34
  /** 把 config.providers 注册到新建的 LLM registry(修复此前空 registry 的遗漏)。 */
33
35
  declare function buildRegistryFromConfig(config: Config): Registry;
34
36
  /**
35
- * config 变更后原地同步 registry(清空旧路由 + 重新注册),
36
- * 使运行中的 ServerContext 立即生效,无需重启。
37
+ * config 变更后原子地同步 registry:在隔离的 next registry 上重建全部路由,
38
+ * 完成后一次性替换 registry 内部 table 引用。运行中的 resolveRoute 任何时刻
39
+ * 看到的都是完整的旧表或完整的新表,不会读到「已清空但未注册完」的半状态,
40
+ * 因此不会把本可用的 provider 误判为 NoRoute。ServerContext 立即生效,无需重启。
37
41
  */
38
42
  declare function syncRegistryFromConfig(registry: Registry, config: Config): void;
39
43
  /** 解析 PGLite 持久化数据目录:优先 C0DE_DB_DIR,否则全局数据根下 pglite 子目录。
@@ -41,6 +45,15 @@ declare function syncRegistryFromConfig(registry: Registry, config: Config): voi
41
45
  * 历史上数据库放在 <cwd>/.c0de/pglite(项目级),但项目 DB schema 通过 projectId
42
46
  * 区分项目,设计本意是全局共享单库。旧路径已在 migrateLegacyPglite 中自动迁移。 */
43
47
  declare function resolveDbDir(): string;
48
+ /**
49
+ * 认证 token 解析(P0 安全修复):
50
+ * 1. security.authEnabled 显式为 false → 无认证(用户显式选择)。
51
+ * 2. 用户配置 security.token → 使用。
52
+ * 3. 环境变量 C0DE_AUTH_TOKEN(热更新新实例经 updater 传入)→ 采用并落盘。
53
+ * 4. dataDir 下 auth-token 文件(跨重启/热更新稳定)→ 复用。
54
+ * 5. 都没有 → 生成随机 token 并写入 auth-token(0600)。
55
+ */
56
+ declare function resolveAuthToken(config: Config, dataDir: string): string | undefined;
44
57
  /**
45
58
  * 围绕已有 DB handle 组装 ServerContext(不建/不关闭 DB)。
46
59
  *
@@ -52,6 +65,18 @@ declare function buildServerContext(db: DB, opts?: StartServerOptions): Promise<
52
65
  ctx: ServerContext;
53
66
  dispose: () => Promise<void>;
54
67
  }>;
68
+ /**
69
+ * Cross-process guard for PGLite dataDir.
70
+ *
71
+ * PGLite is single-writer WASM Postgres — two processes on the same dataDir
72
+ * always abort (`RuntimeError: Aborted()`). This lock prevents silent
73
+ * corruption when multiple dev servers (e.g. different ports) target the
74
+ * same project.
75
+ *
76
+ * - Live PID in lock → throw clear error instead of cryptic WASM abort.
77
+ * - Dead PID in lock → stale: remove `.dev.lock` + `postmaster.pid`, proceed.
78
+ */
79
+ declare function acquireDevDbLock(dataDir: string): void;
55
80
  /** Release the dev DB lock (best-effort, stale-detection covers missed calls). */
56
81
  declare function releaseDevDbLock(dataDir: string): void;
57
82
  /** dev 专用:创建 + migrate PGLite,跨热重载复用(单写者,只建一次)。 */
@@ -60,4 +85,4 @@ declare function createDevDb(cwd: string): Promise<DB>;
60
85
  declare function bootstrapServerContext(opts?: StartServerOptions): Promise<BootstrappedServer>;
61
86
  declare function startServer(opts?: StartServerOptions): Promise<RunningServer>;
62
87
  export type { BootstrappedServer, RunningServer, StartServerOptions };
63
- export { bootstrapServerContext, buildRegistryFromConfig, buildServerContext, createDevDb, releaseDevDbLock, resolveDbDir, startServer, syncRegistryFromConfig, };
88
+ export { acquireDevDbLock, bootstrapServerContext, buildRegistryFromConfig, buildServerContext, createDevDb, releaseDevDbLock, resolveAuthToken, resolveDbDir, startServer, syncRegistryFromConfig, };
@@ -1,5 +1,6 @@
1
1
  // src/server/server.ts
2
- import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, } from 'node:fs';
3
+ import { randomBytes } from 'node:crypto';
3
4
  import { connect as tcpConnect } from 'node:net';
4
5
  import { homedir } from 'node:os';
5
6
  import { dirname, join } from 'node:path';
@@ -10,12 +11,13 @@ import { loadConfig } from '../core/config.js';
10
11
  import { decryptSecret } from '../core/secret.js';
11
12
  import { createAndPopulateRegistry } from '../core/workflows/index.js';
12
13
  import { createDB, migrateDB } from '../db/index.js';
13
- import { createRegistry, overrideToCapabilities, registerProvider, } from '../llm/registry.js';
14
+ import { createRegistry, overrideToCapabilities, rebuildRegistry, registerProvider, } from '../llm/registry.js';
14
15
  import { initPlugins } from '../plugins/index.js';
15
16
  import { createDefaultRegistry, createDefaultURLRegistry } from '../tools/index.js';
16
17
  import { checkForUpdate, createHandoffServer, createUpdateScheduler, requestHandoff, restoreSessions, } from '../update/index.js';
17
18
  import { createAgentManager } from './agent-manager.js';
18
19
  import { createApp } from './app.js';
20
+ import { isAllowedOrigin } from './middleware/cors.js';
19
21
  import { createPermissionStore } from './permission/store.js';
20
22
  import { PTYManager } from './terminal/pty-manager.js';
21
23
  /** 把 config.providers 注册到新建的 LLM registry(修复此前空 registry 的遗漏)。 */
@@ -44,15 +46,17 @@ function registerProviderFromConfig(registry, p) {
44
46
  });
45
47
  }
46
48
  /**
47
- * config 变更后原地同步 registry(清空旧路由 + 重新注册),
48
- * 使运行中的 ServerContext 立即生效,无需重启。
49
+ * config 变更后原子地同步 registry:在隔离的 next registry 上重建全部路由,
50
+ * 完成后一次性替换 registry 内部 table 引用。运行中的 resolveRoute 任何时刻
51
+ * 看到的都是完整的旧表或完整的新表,不会读到「已清空但未注册完」的半状态,
52
+ * 因此不会把本可用的 provider 误判为 NoRoute。ServerContext 立即生效,无需重启。
49
53
  */
50
54
  function syncRegistryFromConfig(registry, config) {
51
- registry.routes.clear();
52
- registry.roles.clear();
53
- for (const p of config.providers) {
54
- registerProviderFromConfig(registry, p);
55
- }
55
+ rebuildRegistry(registry, (next) => {
56
+ for (const p of config.providers) {
57
+ registerProviderFromConfig(next, p);
58
+ }
59
+ });
56
60
  }
57
61
  /** 全局数据根目录:XDG_DATA_HOME 优先,否则 ~/.local/share/c0de。
58
62
  * 与 opencode (~/.local/share/opencode/)、oh-my-pi (~/.omp/agent/) 同约定——
@@ -73,6 +77,59 @@ function resolveDbDir() {
73
77
  return envDir;
74
78
  return join(resolveGlobalDataRoot(), 'pglite');
75
79
  }
80
+ /**
81
+ * 认证 token 解析(P0 安全修复):
82
+ * 1. security.authEnabled 显式为 false → 无认证(用户显式选择)。
83
+ * 2. 用户配置 security.token → 使用。
84
+ * 3. 环境变量 C0DE_AUTH_TOKEN(热更新新实例经 updater 传入)→ 采用并落盘。
85
+ * 4. dataDir 下 auth-token 文件(跨重启/热更新稳定)→ 复用。
86
+ * 5. 都没有 → 生成随机 token 并写入 auth-token(0600)。
87
+ */
88
+ function resolveAuthToken(config, dataDir) {
89
+ if (config.security.authEnabled === false)
90
+ return undefined;
91
+ if (config.security.token && config.security.token.length > 0)
92
+ return config.security.token;
93
+ const tokenFile = join(dataDir, 'auth-token');
94
+ const envToken = process.env.C0DE_AUTH_TOKEN;
95
+ if (envToken) {
96
+ try {
97
+ mkdirSync(dataDir, { recursive: true });
98
+ writeFileSync(tokenFile, envToken, { mode: 0o600 });
99
+ }
100
+ catch {
101
+ // 落盘失败不影响本次运行
102
+ }
103
+ return envToken;
104
+ }
105
+ try {
106
+ const existing = readFileSync(tokenFile, 'utf-8').trim();
107
+ if (existing)
108
+ return existing;
109
+ }
110
+ catch {
111
+ // 文件不存在,走生成分支
112
+ }
113
+ const generated = randomBytes(24).toString('hex');
114
+ try {
115
+ mkdirSync(dataDir, { recursive: true });
116
+ writeFileSync(tokenFile, generated, { mode: 0o600 });
117
+ }
118
+ catch {
119
+ // 落盘失败:本次运行仍可用内存 token
120
+ }
121
+ return generated;
122
+ }
123
+ /** 只读 dataDir 下已存在的 auth-token(新实例 handoff 握手用;不生成)。 */
124
+ function readAuthTokenFile(dataDir) {
125
+ try {
126
+ const t = readFileSync(join(dataDir, 'auth-token'), 'utf-8').trim();
127
+ return t || undefined;
128
+ }
129
+ catch {
130
+ return undefined;
131
+ }
132
+ }
76
133
  /** 一次性迁移:<cwd>/.c0de/pglite → 全局路径。仅当目标不存在时执行。
77
134
  *
78
135
  * 多个项目各有 .c0de/pglite 时只迁移第一个遇到的(首次启动即触发);
@@ -139,6 +196,7 @@ async function buildServerContext(db, opts = {}) {
139
196
  agentManager: createAgentManager(),
140
197
  permissionStore: createPermissionStore(),
141
198
  permissionMode: config.permission.defaultMode,
199
+ authToken: resolveAuthToken(config, resolveDbDir()),
142
200
  // Agent 注册表:内置 4 个默认 agent;项目/用户自定义 agent 可在启动后补充加载。
143
201
  agentRegistry: (() => {
144
202
  const reg = createAgentRegistry();
@@ -156,30 +214,17 @@ async function buildServerContext(db, opts = {}) {
156
214
  cwd,
157
215
  ptyManager: new PTYManager(),
158
216
  };
159
- // spec §18.3 handoff server:旧实例监听随机端口,收到 POST /handoff 时
160
- // 序列化当前会话状态 + 优雅关闭,让新实例接管。config.update.enabled=false
161
- // 或测试 skipHandoff 时跳过(减少后台资源占用)。
162
- let handoffServer;
163
- if (config.update.enabled && !opts.skipHandoff) {
164
- const createHandoff = opts.createHandoffFn ?? createHandoffServer;
165
- handoffServer = await createHandoff(async () => {
166
- // 序列化在热更新主链路(performHotUpdate)已完成;handoff 回调只需释放资源。
167
- // 这里提前停 scheduler 以避免 close 期间还在跑检查。
168
- ctx.updateScheduler.stop();
169
- });
170
- ctx.handoff = { port: handoffServer.port, server: handoffServer };
171
- }
217
+ // handoff server 不在此创建:它需要持有主 HTTP server 引用以触发完整关停,
218
+ // 由 startServer 在 serve() 之后创建(dev 模式走 vite,不创建)。
172
219
  return {
173
220
  ctx,
174
221
  dispose: async () => {
175
222
  // dev 重建前调用:中止活跃 run + settle pending permission +
176
- // 停 scheduler + 关 handoff。**不 close db**(调用方持有)。
223
+ // 停 scheduler。**不 close db**(调用方持有)。
177
224
  ctx.agentManager.dispose();
178
225
  ctx.permissionStore.dispose();
179
226
  ctx.updateScheduler.stop();
180
227
  ctx.ptyManager.dispose();
181
- if (handoffServer)
182
- await handoffServer.close();
183
228
  },
184
229
  };
185
230
  }
@@ -221,11 +266,15 @@ function acquireDevDbLock(dataDir) {
221
266
  try {
222
267
  unlinkSync(lockPath);
223
268
  }
224
- catch { /* best-effort */ }
269
+ catch {
270
+ /* best-effort */
271
+ }
225
272
  try {
226
273
  unlinkSync(join(dataDir, 'postmaster.pid'));
227
274
  }
228
- catch { /* best-effort */ }
275
+ catch {
276
+ /* best-effort */
277
+ }
229
278
  }
230
279
  writeFileSync(lockPath, String(process.pid));
231
280
  }
@@ -234,7 +283,9 @@ function releaseDevDbLock(dataDir) {
234
283
  try {
235
284
  unlinkSync(join(dataDir, DEV_LOCK_FILE));
236
285
  }
237
- catch { /* best-effort */ }
286
+ catch {
287
+ /* best-effort */
288
+ }
238
289
  }
239
290
  /** dev 专用:创建 + migrate PGLite,跨热重载复用(单写者,只建一次)。 */
240
291
  async function createDevDb(cwd) {
@@ -293,13 +344,24 @@ async function bootstrapServerContext(opts = {}) {
293
344
  * 请求旧实例 handoff 后轮询端口释放(spec §18.3)。旧实例收到 /handoff 后
294
345
  * 优雅退出释放端口;新实例轮询 TCP 连接直到被拒(端口已释放)或超时报错。
295
346
  */
296
- async function requestHandoffWithRetry(port, maxAttempts = 30, delayMs = 100) {
347
+ async function requestHandoffWithRetry(port, token, maxAttempts = 30, delayMs = 100) {
297
348
  try {
298
- await requestHandoff(port);
349
+ await requestHandoff(port, '127.0.0.1', token);
299
350
  }
300
- catch {
301
- // 旧实例未启 handoff 端点(update 未启用);直接尝试绑端口。
302
- return;
351
+ catch (err) {
352
+ const handoffErr = err;
353
+ // 连接层失败(ECONNREFUSED 等):旧实例未启 handoff 端点(update 未启用);
354
+ // 直接尝试绑端口。
355
+ if (handoffErr?.kind === 'connect')
356
+ return;
357
+ // HTTP 401:旧实例存在但 auth token 不匹配——立即明确报错退出。若继续
358
+ // bind 必撞 EADDRINUSE,把真实原因(token 校验失败)误报成端口占用。
359
+ if (handoffErr?.kind === 'http' && handoffErr.status === 401) {
360
+ throw new Error(`handoff: 端口 ${port} 上的旧实例拒绝让渡(HTTP 401:auth token 不匹配)。\n` +
361
+ `新旧实例须共享同一 token(C0DE_AUTH_TOKEN 环境变量或数据目录 auth-token 文件),\n` +
362
+ `请核对配置后重试。`);
363
+ }
364
+ throw err;
303
365
  }
304
366
  for (let i = 0; i < maxAttempts; i++) {
305
367
  const released = await new Promise((resolve) => {
@@ -319,8 +381,10 @@ async function requestHandoffWithRetry(port, maxAttempts = 30, delayMs = 100) {
319
381
  async function startServer(opts = {}) {
320
382
  const port = opts.port ?? 3000;
321
383
  // spec §18.3:新实例从 --handoff-port 拿到旧实例端口,请求优雅退出后绑端口。
384
+ // 握手 token:updater spawn 时经 C0DE_AUTH_TOKEN 环境变量传入,否则读 auth-token 文件。
322
385
  if (opts.handoffPort && !opts.skipHandoff) {
323
- await requestHandoffWithRetry(opts.handoffPort);
386
+ const pendingToken = process.env.C0DE_AUTH_TOKEN ?? readAuthTokenFile(resolveDbDir());
387
+ await requestHandoffWithRetry(opts.handoffPort, pendingToken);
324
388
  }
325
389
  const { ctx, close: closeCtx } = await bootstrapServerContext(opts);
326
390
  const app = createApp(ctx);
@@ -328,10 +392,11 @@ async function startServer(opts = {}) {
328
392
  if (ctx.config.update.enabled)
329
393
  ctx.updateScheduler.start();
330
394
  const server = serve({ fetch: app.fetch, port });
395
+ ctx.port = port;
331
396
  // WebSocket:终端双向流。Hono v2 无原生 WS,用 ws 包直接挂载到 HTTP server。
332
397
  // 匹配 /api/terminal/:id/ws → ptyManager.attachWebSocket
333
398
  const wss = new WebSocketServer({ noServer: true });
334
- const expectedToken = ctx.config.security.authEnabled ? ctx.config.security.token : undefined;
399
+ const expectedToken = ctx.authToken;
335
400
  server.on('upgrade', (req, socket, head) => {
336
401
  const url = new URL(req.url ?? '', `http://${req.headers.host ?? 'localhost'}`);
337
402
  const match = url.pathname.match(/^\/api\/terminal\/([^/]+)\/ws$/);
@@ -339,6 +404,14 @@ async function startServer(opts = {}) {
339
404
  socket.destroy();
340
405
  return;
341
406
  }
407
+ // Origin 校验:浏览器 WS 不受 CORS 约束,必须在服务端显式校验。
408
+ // 非浏览器客户端(无 Origin 头)放行——token 仍是硬门槛。
409
+ const origin = req.headers.origin;
410
+ if (origin && !isAllowedOrigin(origin, ctx.config.security.allowedOrigins)) {
411
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
412
+ socket.destroy();
413
+ return;
414
+ }
342
415
  // 认证:token 通过 query 参数传递(浏览器 WS 无法设置 Authorization header)
343
416
  if (expectedToken) {
344
417
  const token = url.searchParams.get('token');
@@ -362,7 +435,7 @@ async function startServer(opts = {}) {
362
435
  });
363
436
  });
364
437
  let closed = false;
365
- const close = async () => {
438
+ const closeMain = async () => {
366
439
  if (closed)
367
440
  return;
368
441
  closed = true;
@@ -370,6 +443,22 @@ async function startServer(opts = {}) {
370
443
  server.close();
371
444
  await closeCtx();
372
445
  };
373
- return { app, port, close };
446
+ const close = async () => {
447
+ await closeMain();
448
+ await handoffServer?.close();
449
+ };
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 } : {}) };
374
463
  }
375
- export { bootstrapServerContext, buildRegistryFromConfig, buildServerContext, createDevDb, releaseDevDbLock, resolveDbDir, startServer, syncRegistryFromConfig, };
464
+ export { acquireDevDbLock, bootstrapServerContext, buildRegistryFromConfig, buildServerContext, createDevDb, releaseDevDbLock, resolveAuthToken, resolveDbDir, startServer, syncRegistryFromConfig, };
@@ -8,9 +8,9 @@ import type { Config as SharedConfig } from '../shared/types/config.js';
8
8
  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
- import type { PTYManager } from './terminal/pty-manager.js';
12
11
  import type { AgentManager } from './agent-manager.js';
13
12
  import type { PermissionStore } from './permission/store.js';
13
+ import type { PTYManager } from './terminal/pty-manager.js';
14
14
  /** 持有所有服务依赖的不可变上下文(config 字段可变用于 PATCH 更新)。 */
15
15
  type ServerContext = {
16
16
  db: DB;
@@ -28,6 +28,14 @@ type ServerContext = {
28
28
  permissionStore: PermissionStore;
29
29
  /** 全局授权模式(运行时可临时切换):'default' 逐个确认;'auto' 自动放行 ask 工具(YOLO)。启动时取 config.permission.defaultMode,PUT /api/permissions 临时切换不回写 config。 */
30
30
  permissionMode: 'default' | 'auto';
31
+ /**
32
+ * API Bearer token(认证未显式关闭时必存在)。
33
+ * 用户配置 security.token 优先;否则自动生成并持久化到数据目录 auth-token 文件
34
+ * (跨重启/热更新稳定,浏览器首访经 ?token= URL 获取后本地保存)。
35
+ */
36
+ authToken?: string;
37
+ /** 主 HTTP 服务实际绑定端口(startServer 绑定后回填;热更新 spawn 新实例复用)。 */
38
+ port?: number;
31
39
  /** Agent 类型注册表(spec: multi-agent-design)。注入 agent loop 的 runSubAgent。 */
32
40
  agentRegistry: AgentRegistry;
33
41
  /** 工作流注册表(spec: dynamic-workflow-design)。注入 /workflow slash 命令和 workflowz steering。 */
@@ -1,6 +1,14 @@
1
1
  import type { DB } from '../db/client.js';
2
2
  import type { Session, SessionTreeNode } from './types.js';
3
- /** Fork a session at a message index — copies messages 0..index into a new child session. */
3
+ /**
4
+ * fork 分支点 messageIndex 越界(评审 NIT:客户端索引过期/分页 bug)。
5
+ * 与「会话不存在」区分开:消费路由应映射 400 并透出 message,而非 404。
6
+ */
7
+ declare class BranchPointOutOfRangeError extends Error {
8
+ constructor(messageIndex: number);
9
+ }
10
+ /** Fork a session at a message index — copies all entries (messages + tool pairs)
11
+ * up to and including the branch point message, plus latest file snapshots. */
4
12
  declare function forkSession(handle: DB, sessionId: string, messageIndex: number): Promise<Session>;
5
13
  /** Get direct child sessions (branches) of a session. */
6
14
  declare function getBranches(handle: DB, sessionId: string): Promise<Session[]>;
@@ -8,4 +16,4 @@ declare function getBranches(handle: DB, sessionId: string): Promise<Session[]>;
8
16
  * 每层按 metadata.lastOpenedAt 降序(fallback updatedAt、createdAt)。
9
17
  */
10
18
  declare function getTree(handle: DB): Promise<SessionTreeNode[]>;
11
- export { forkSession, getBranches, getTree };
19
+ export { BranchPointOutOfRangeError, forkSession, getBranches, getTree };
@@ -1,42 +1,111 @@
1
1
  import { eq } from 'drizzle-orm';
2
- import { sessions } from '../db/schema.js';
2
+ import { sessionEntries, sessions } from '../db/schema.js';
3
3
  import { generateId } from '../shared/index.js';
4
- import { getMessages, insertEntry } from './message.js';
4
+ import { getEntries, insertEntry } from './message.js';
5
5
  import { createSession, getSession, rowToSession } from './session.js';
6
- /** Fork a session at a message index — copies messages 0..index into a new child session. */
6
+ import { copyFileSnapshots } from './snapshot.js';
7
+ /**
8
+ * fork 分支点 messageIndex 越界(评审 NIT:客户端索引过期/分页 bug)。
9
+ * 与「会话不存在」区分开:消费路由应映射 400 并透出 message,而非 404。
10
+ */
11
+ class BranchPointOutOfRangeError extends Error {
12
+ constructor(messageIndex) {
13
+ super(`Branch point message index ${messageIndex} out of range`);
14
+ this.name = 'BranchPointOutOfRangeError';
15
+ }
16
+ }
17
+ /** 把 SessionEntry 还原为 sessionEntries 原始行(fork 复制用)。 */
18
+ function entryToRow(e, sessionId) {
19
+ const id = generateId();
20
+ // Message 无 _tag(共享类型);其余条目经 _tag 判别。
21
+ if (!('_tag' in e)) {
22
+ return {
23
+ id,
24
+ sessionId,
25
+ tag: 'message',
26
+ role: e.role,
27
+ content: e.content,
28
+ tokenCount: e.tokenCount,
29
+ };
30
+ }
31
+ switch (e._tag) {
32
+ case 'compaction':
33
+ return {
34
+ id,
35
+ sessionId,
36
+ tag: 'compaction',
37
+ content: {
38
+ summary: e.summary,
39
+ originalEntryIds: e.originalEntryIds,
40
+ archiveId: e.archiveId,
41
+ },
42
+ tokenCount: e.tokenCount,
43
+ };
44
+ case 'squash':
45
+ return {
46
+ id,
47
+ sessionId,
48
+ tag: 'squash',
49
+ content: {
50
+ summary: e.summary,
51
+ squashedEntryIds: e.squashedEntryIds,
52
+ archiveId: e.archiveId,
53
+ },
54
+ tokenCount: e.tokenCount,
55
+ };
56
+ case 'branch_summary':
57
+ return {
58
+ id,
59
+ sessionId,
60
+ tag: 'branch_summary',
61
+ content: { summary: e.summary, sourceSessionId: e.sourceSessionId },
62
+ };
63
+ case 'steering':
64
+ return { id, sessionId, tag: 'steering', content: { text: e.content } };
65
+ }
66
+ }
67
+ /** Fork a session at a message index — copies all entries (messages + tool pairs)
68
+ * up to and including the branch point message, plus latest file snapshots. */
7
69
  async function forkSession(handle, sessionId, messageIndex) {
8
70
  const source = await getSession(handle, sessionId);
9
71
  if (!source)
10
72
  throw new Error(`Session not found: ${sessionId}`);
11
- const messages = await getMessages(handle, sessionId);
12
- const toCopy = messages.slice(0, messageIndex + 1);
13
- const forked = await createSession(handle, `Branch of ${source.title}`, source.projectId ?? undefined);
14
- await handle.db
15
- .update(sessions)
16
- .set({ parentId: sessionId, branchPoint: messageIndex })
17
- .where(eq(sessions.id, forked.id));
18
- for (const msg of toCopy) {
19
- await insertEntry(handle, {
73
+ const entries = await getEntries(handle, sessionId);
74
+ const msgEntries = entries.filter((e) => !('_tag' in e));
75
+ const target = msgEntries[messageIndex];
76
+ if (!target) {
77
+ throw new BranchPointOutOfRangeError(messageIndex);
78
+ }
79
+ const targetIdx = entries.findIndex((e) => e.id === target.id);
80
+ const toCopy = entries.slice(0, targetIdx + 1);
81
+ // 多表写入(sessions insert/update + sessionEntries 复制 + 快照复制)包单个事务:
82
+ // 中途失败整体回滚,不残留永久可见的半成品分支(同 compactSession/squashRecent 写法)。
83
+ const updated = await handle.db.transaction(async (tx) => {
84
+ const txHandle = { db: tx, close: handle.close };
85
+ const forked = await createSession(txHandle, `Branch of ${source.title}`, source.projectId ?? undefined);
86
+ await tx
87
+ .update(sessions)
88
+ .set({ parentId: sessionId, branchPoint: messageIndex })
89
+ .where(eq(sessions.id, forked.id));
90
+ for (const e of toCopy) {
91
+ await insertEntry(txHandle, entryToRow(e, forked.id));
92
+ }
93
+ await insertEntry(txHandle, {
20
94
  id: generateId(),
21
95
  sessionId: forked.id,
22
- tag: 'message',
23
- role: msg.role,
24
- content: msg.content,
25
- tokenCount: msg.tokenCount,
96
+ tag: 'branch_summary',
97
+ content: {
98
+ summary: `Branched from session ${sessionId} at message ${messageIndex}`,
99
+ sourceSessionId: sessionId,
100
+ },
26
101
  });
27
- }
28
- await insertEntry(handle, {
29
- id: generateId(),
30
- sessionId: forked.id,
31
- tag: 'branch_summary',
32
- content: {
33
- summary: `Branched from session ${sessionId} at message ${messageIndex}`,
34
- sourceSessionId: sessionId,
35
- },
102
+ // 复制源会话最新文件快照(P1-4:@文件上下文随分支保留)。
103
+ await copyFileSnapshots(txHandle, sessionId, forked.id);
104
+ const created = await getSession(txHandle, forked.id);
105
+ if (!created)
106
+ throw new Error('Forked session not found after creation');
107
+ return created;
36
108
  });
37
- const updated = await getSession(handle, forked.id);
38
- if (!updated)
39
- throw new Error('Forked session not found after creation');
40
109
  return updated;
41
110
  }
42
111
  /** Get direct child sessions (branches) of a session. */
@@ -67,4 +136,4 @@ async function getTree(handle) {
67
136
  }));
68
137
  return build(null);
69
138
  }
70
- export { forkSession, getBranches, getTree };
139
+ export { BranchPointOutOfRangeError, forkSession, getBranches, getTree };
@@ -228,6 +228,7 @@ async function compactSession(handle, sessionId, summarizer, config) {
228
228
  if (config?.preserveSnapshots !== false) {
229
229
  const hotFiles = extractHotFiles(compactMessages);
230
230
  for (const file of hotFiles) {
231
+ // 不传 mtimeMs:由 upsertFileSnapshot 从已有行透传,压缩后热文件不误判过期。
231
232
  const id = await upsertFileSnapshot(txHandle, sessionId, file.path, file.content);
232
233
  fileSnapshotIds.push(id);
233
234
  }
@@ -8,8 +8,9 @@ declare function messageToChatMessage(msg: Message): ChatMessage;
8
8
  declare function entriesToChatMessages(entries: SessionEntry[], snapshots: FileSnapshot[]): ChatMessage[];
9
9
  /** Inject file snapshot block after the first message (preserves cache prefix). */
10
10
  declare function injectSnapshots(messages: ChatMessage[], snapshots: FileSnapshot[]): ChatMessage[];
11
- /** Get the full session context: all entries + file snapshots. */
12
- declare function getSessionContext(handle: DB, sessionId: string): Promise<{
11
+ /** Get the full session context: all entries + file snapshots.
12
+ * cwd 提供时刷新过期快照:文件磁盘 mtime 晚于快照记录 → 重读并升版本(P1-5)。 */
13
+ declare function getSessionContext(handle: DB, sessionId: string, cwd?: string): Promise<{
13
14
  entries: SessionEntry[];
14
15
  snapshots: FileSnapshot[];
15
16
  }>;
@@ -1,5 +1,7 @@
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import { resolve } from 'node:path';
1
3
  import { getEntries } from './message.js';
2
- import { getFileSnapshots } from './snapshot.js';
4
+ import { getFileSnapshots, upsertFileSnapshot } from './snapshot.js';
3
5
  /**
4
6
  * Drop tool messages whose id cannot be paired with an assistant tool_call,
5
7
  * and inject synthetic tool results for orphan tool_calls (assistant 发出了
@@ -162,12 +164,43 @@ function injectSnapshots(messages, snapshots) {
162
164
  return [snapshotMessage];
163
165
  return [first, snapshotMessage, ...messages.slice(1)];
164
166
  }
165
- /** Get the full session context: all entries + file snapshots. */
166
- async function getSessionContext(handle, sessionId) {
167
+ /** Get the full session context: all entries + file snapshots.
168
+ * cwd 提供时刷新过期快照:文件磁盘 mtime 晚于快照记录 → 重读并升版本(P1-5)。 */
169
+ async function getSessionContext(handle, sessionId, cwd) {
170
+ if (cwd)
171
+ await refreshStaleSnapshots(handle, sessionId, cwd);
167
172
  const [entries, snapshots] = await Promise.all([
168
173
  getEntries(handle, sessionId),
169
174
  getFileSnapshots(handle, sessionId),
170
175
  ]);
171
176
  return { entries, snapshots };
172
177
  }
178
+ /** 比对每个文件最新快照与磁盘 mtime,过期则重读并写入新版本。 */
179
+ async function refreshStaleSnapshots(handle, sessionId, cwd) {
180
+ const snaps = await getFileSnapshots(handle, sessionId);
181
+ const latest = new Map();
182
+ for (const s of snaps) {
183
+ const prev = latest.get(s.filePath);
184
+ if (!prev || s.version > prev.version)
185
+ latest.set(s.filePath, s);
186
+ }
187
+ for (const s of latest.values()) {
188
+ const p = resolve(cwd, s.filePath);
189
+ // try 只覆盖 fs 错误(stat/readFile):文件已删除/不可读 → 保留旧快照
190
+ // (读工具会报错提示模型),跳过该文件继续处理其余快照。
191
+ const st = await stat(p).catch(() => null);
192
+ if (!st)
193
+ continue;
194
+ // upsertFileSnapshot 落盘时对 mtimeMs 取整(bigint 列),比对侧同样取整,
195
+ // 避免向下舍入导致"存储值 < 真实值"的永久误判过期。
196
+ if ((s.mtimeMs ?? 0) >= Math.round(st.mtimeMs))
197
+ continue;
198
+ const content = await readFile(p, 'utf-8').catch(() => null);
199
+ if (content === null)
200
+ continue;
201
+ // DB 写入放在 try 之外:upsertFileSnapshot 失败必须向上抛,
202
+ // 否则过期内容被静默注入上下文(评审 NIT:错误吞没范围过大)。
203
+ await upsertFileSnapshot(handle, sessionId, s.filePath, content, st.mtimeMs);
204
+ }
205
+ }
173
206
  export { entriesToChatMessages, getSessionContext, injectSnapshots, messageToChatMessage };
@@ -1,11 +1,17 @@
1
1
  import type { DB } from '../db/client.js';
2
2
  import type { FileSnapshot } from './types.js';
3
- /** Create or update a file snapshot. Always creates a new version. Returns the snapshot id. */
4
- declare function upsertFileSnapshot(handle: DB, sessionId: string, filePath: string, content: string): Promise<string>;
3
+ /** Create or update a file snapshot. Always creates a new version. Returns the snapshot id.
4
+ * 未传 mtimeMs 时从同文件已有最新行透传(而非置空):compaction/squash 等从
5
+ * 工具结果重建快照的调用点拿不到磁盘 mtime,透传旧值可避免每次压缩后热文件
6
+ * 被误判过期而多一轮冗余重读(自愈无害但浪费;旧值 ≤ 内容实际新鲜度,
7
+ * 绝不会掩盖磁盘真实变更)。 */
8
+ declare function upsertFileSnapshot(handle: DB, sessionId: string, filePath: string, content: string, mtimeMs?: number): Promise<string>;
5
9
  /** Get all snapshots for a session. */
6
10
  declare function getFileSnapshots(handle: DB, sessionId: string): Promise<FileSnapshot[]>;
7
11
  /** Get the latest snapshot for a specific file, or null. */
8
12
  declare function getLatestFileSnapshot(handle: DB, sessionId: string, filePath: string): Promise<FileSnapshot | null>;
9
13
  /** Quick check: return cached file content if a snapshot exists, else null. */
10
14
  declare function checkFileSnapshot(handle: DB, sessionId: string, filePath: string): Promise<string | null>;
11
- export { checkFileSnapshot, getFileSnapshots, getLatestFileSnapshot, upsertFileSnapshot };
15
+ /** 把源会话每个文件的最新快照复制到目标会话(fork 分支用,P1-4)。 */
16
+ declare function copyFileSnapshots(handle: DB, fromSessionId: string, toSessionId: string): Promise<void>;
17
+ export { checkFileSnapshot, copyFileSnapshots, getFileSnapshots, getLatestFileSnapshot, upsertFileSnapshot, };