c0de-agent 1.9.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 (65) 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 -7
  16. package/dist/core/loop.js +1 -1
  17. package/dist/core/workflows/discovery.d.ts +1 -1
  18. package/dist/core/workflows/index.d.ts +1 -1
  19. package/dist/db/schema.d.ts +17 -0
  20. package/dist/db/schema.js +3 -1
  21. package/dist/server/agent-manager.d.ts +15 -0
  22. package/dist/server/agent-manager.js +35 -11
  23. package/dist/server/app.js +2 -1
  24. package/dist/server/context.js +2 -0
  25. package/dist/server/middleware/cors.d.ts +11 -2
  26. package/dist/server/middleware/cors.js +15 -12
  27. package/dist/server/routes/chat.js +211 -150
  28. package/dist/server/routes/config.js +7 -3
  29. package/dist/server/routes/session.js +6 -2
  30. package/dist/server/routes/terminal.js +12 -0
  31. package/dist/server/routes/update.d.ts +1 -0
  32. package/dist/server/routes/update.js +11 -4
  33. package/dist/server/routes/workflows.js +2 -2
  34. package/dist/server/server.d.ts +24 -1
  35. package/dist/server/server.js +107 -26
  36. package/dist/server/types.d.ts +9 -1
  37. package/dist/session/branch.d.ts +10 -2
  38. package/dist/session/branch.js +98 -29
  39. package/dist/session/compaction.js +1 -0
  40. package/dist/session/context.d.ts +3 -2
  41. package/dist/session/context.js +36 -3
  42. package/dist/session/snapshot.d.ts +9 -3
  43. package/dist/session/snapshot.js +45 -4
  44. package/dist/session/squash.js +1 -0
  45. package/dist/session/types.d.ts +2 -0
  46. package/dist/shared/types/config.d.ts +7 -6
  47. package/dist/shared/utils/path.d.ts +2 -3
  48. package/dist/shared/utils/path.js +20 -4
  49. package/dist/tools/builtin/todo.js +5 -4
  50. package/dist/tools/index.d.ts +14 -3
  51. package/dist/tools/index.js +36 -14
  52. package/dist/update/index.d.ts +1 -1
  53. package/dist/update/index.js +1 -1
  54. package/dist/update/ipc.d.ts +32 -5
  55. package/dist/update/ipc.js +53 -7
  56. package/dist/update/updater.d.ts +7 -3
  57. package/dist/update/updater.js +14 -6
  58. package/dist/update/version.d.ts +6 -1
  59. package/dist/update/version.js +18 -3
  60. package/drizzle/0005_snapshot_mtime.sql +1 -0
  61. package/drizzle/meta/0005_snapshot.json +855 -0
  62. package/drizzle/meta/_journal.json +7 -0
  63. package/package.json +1 -7
  64. package/dist/mcp/index.d.ts +0 -1
  65. package/dist/mcp/index.js +0 -3
@@ -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, };
@@ -13,10 +13,15 @@ function rowToSnapshot(row) {
13
13
  tokenCount: row.tokenCount ?? 0,
14
14
  version: row.version ?? 1,
15
15
  createdAt,
16
+ ...(row.mtimeMs != null ? { mtimeMs: Number(row.mtimeMs) } : {}),
16
17
  };
17
18
  }
18
- /** Create or update a file snapshot. Always creates a new version. Returns the snapshot id. */
19
- async function upsertFileSnapshot(handle, sessionId, filePath, content) {
19
+ /** Create or update a file snapshot. Always creates a new version. Returns the snapshot id.
20
+ * 未传 mtimeMs 时从同文件已有最新行透传(而非置空):compaction/squash 等从
21
+ * 工具结果重建快照的调用点拿不到磁盘 mtime,透传旧值可避免每次压缩后热文件
22
+ * 被误判过期而多一轮冗余重读(自愈无害但浪费;旧值 ≤ 内容实际新鲜度,
23
+ * 绝不会掩盖磁盘真实变更)。 */
24
+ async function upsertFileSnapshot(handle, sessionId, filePath, content, mtimeMs) {
20
25
  const contentHash = createHash('sha256').update(content).digest('hex');
21
26
  const tokenCount = estimateTokens(content);
22
27
  const [existing] = await handle.db
@@ -26,9 +31,20 @@ async function upsertFileSnapshot(handle, sessionId, filePath, content) {
26
31
  .orderBy(desc(fileSnapshots.version))
27
32
  .limit(1);
28
33
  const version = (existing?.version ?? 0) + 1;
34
+ // mtimeMs 是浮点(stat 返回微秒精度),bigint 列不接受小数;取整存储。
35
+ // 调用方未显式提供时继承已有行的 mtimeMs(见函数头注释)。
36
+ const effectiveMtimeMs = mtimeMs ?? (existing?.mtimeMs != null ? Number(existing.mtimeMs) : null);
29
37
  const [row] = await handle.db
30
38
  .insert(fileSnapshots)
31
- .values({ sessionId, filePath, content, contentHash, tokenCount, version })
39
+ .values({
40
+ sessionId,
41
+ filePath,
42
+ content,
43
+ contentHash,
44
+ tokenCount,
45
+ version,
46
+ ...(effectiveMtimeMs != null ? { mtimeMs: Math.round(effectiveMtimeMs) } : {}),
47
+ })
32
48
  .returning();
33
49
  if (!row)
34
50
  throw new Error('Failed to insert file snapshot');
@@ -57,4 +73,29 @@ async function checkFileSnapshot(handle, sessionId, filePath) {
57
73
  const snapshot = await getLatestFileSnapshot(handle, sessionId, filePath);
58
74
  return snapshot?.content ?? null;
59
75
  }
60
- export { checkFileSnapshot, getFileSnapshots, getLatestFileSnapshot, upsertFileSnapshot };
76
+ /** 把源会话每个文件的最新快照复制到目标会话(fork 分支用,P1-4)。 */
77
+ async function copyFileSnapshots(handle, fromSessionId, toSessionId) {
78
+ const rows = await handle.db
79
+ .select()
80
+ .from(fileSnapshots)
81
+ .where(eq(fileSnapshots.sessionId, fromSessionId));
82
+ const latest = new Map();
83
+ for (const r of rows) {
84
+ const prev = latest.get(r.filePath);
85
+ if (!prev || (r.version ?? 0) > (prev.version ?? 0))
86
+ latest.set(r.filePath, r);
87
+ }
88
+ for (const r of latest.values()) {
89
+ await handle.db.insert(fileSnapshots).values({
90
+ sessionId: toSessionId,
91
+ entryId: r.entryId,
92
+ filePath: r.filePath,
93
+ content: r.content,
94
+ contentHash: r.contentHash,
95
+ tokenCount: r.tokenCount,
96
+ version: r.version,
97
+ mtimeMs: r.mtimeMs,
98
+ });
99
+ }
100
+ }
101
+ export { checkFileSnapshot, copyFileSnapshots, getFileSnapshots, getLatestFileSnapshot, upsertFileSnapshot, };
@@ -62,6 +62,7 @@ ${history}`;
62
62
  if (cfg.preserveFileSnapshots) {
63
63
  const hotFiles = extractHotFiles(toSquash);
64
64
  for (const file of hotFiles) {
65
+ // 不传 mtimeMs:由 upsertFileSnapshot 从已有行透传,压缩后热文件不误判过期。
65
66
  const id = await upsertFileSnapshot(txHandle, sessionId, file.path, file.content);
66
67
  fileSnapshotIds.push(id);
67
68
  }
@@ -117,6 +117,8 @@ type FileSnapshot = {
117
117
  contentHash: string;
118
118
  tokenCount: number;
119
119
  version: number;
120
+ /** 快照创建时文件磁盘 mtime(毫秒);旧行无此字段(迁移前写入) */
121
+ mtimeMs?: number;
120
122
  createdAt: number;
121
123
  };
122
124
  export type { ArchiveRef, BranchSummaryEntry, CompactionArchive, CompactionConfig, CompactionEntry, CompactionResult, FileSnapshot, HotFile, MessageInput, SessionEntry, SessionTreeNode, SquashConfig, SquashEntry, SteeringEntry, Summarizer, };
@@ -64,16 +64,17 @@ type UpdateConfig = {
64
64
  intervalMs: number;
65
65
  /** 启动后首次检查的延迟(毫秒),默认 10 秒。 */
66
66
  initialDelayMs: number;
67
- /** 发现新版本后自动应用(无感知),false 则仅前端提示手动确认(默认 false)。 */
68
- autoApply: boolean;
69
67
  };
70
- /** Server security configuration (spec §24.2). */
68
+ /** Server security configuration (spec §24.2)。 */
71
69
  type SecurityConfig = {
72
- /** 启用 Bearer token 认证(远程访问场景)。本地开发默认关闭。 */
70
+ /**
71
+ * 是否启用 Bearer token 认证(默认 true)。
72
+ * 关闭为显式选择(可信网络/无人值守场景),会取消一切 API 鉴权。
73
+ */
73
74
  authEnabled: boolean;
74
- /** Bearer token;authEnabled 为 true 时必填。 */
75
+ /** Bearer token;未提供时服务端自动生成并持久化到全局数据目录(跨重启/热更新稳定)。 */
75
76
  token?: string;
76
- /** 额外允许的 CORS origin(本地回环始终允许)。 */
77
+ /** 额外允许的 CORS origin(本地回环始终允许;局域网/远程访问需显式添加)。 */
77
78
  allowedOrigins: string[];
78
79
  };
79
80
  /** Global application configuration. */
@@ -5,11 +5,10 @@
5
5
  * - `resolve(root, requestPath)` 解析后,若结果不在 root 子树内则返回 null。
6
6
  * - 判定逻辑:`relative(root, resolved)` 以 `..` 开头即越界;
7
7
  * 再用 `resolve(root, rel) === resolved` 兜底,防止相对段恰好名为 root 的边角情况。
8
+ * - symlink 防逃逸(P2-5):root 与目标(或其最近已存在祖先)经 realpath 解析后,
9
+ * 目标必须仍落在 root 实路径内;否则返回 null。
8
10
  *
9
11
  * 返回解析后的绝对路径;越界返回 null。
10
- *
11
- * 注意:本函数基于 `relative`,不解析符号链接(symlink)。若 root 内存在指向
12
- * root 外的符号链接,本函数无法阻止经由该链接的逃逸——symlink 逃逸不在本次范围内。
13
12
  */
14
13
  declare function safeResolve(root: string, requestPath: string): string | null;
15
14
  export { safeResolve };
@@ -1,4 +1,5 @@
1
- import { relative, resolve } from 'node:path';
1
+ import { existsSync, realpathSync } from 'node:fs';
2
+ import { dirname, relative, resolve, sep } from 'node:path';
2
3
  /**
3
4
  * 安全路径解析:确保 requestPath 解析后落在 root 内,防止路径穿越
4
5
  * (如 `../etc/passwd`、绝对路径 `/etc/passwd`)。
@@ -6,11 +7,10 @@ import { relative, resolve } from 'node:path';
6
7
  * - `resolve(root, requestPath)` 解析后,若结果不在 root 子树内则返回 null。
7
8
  * - 判定逻辑:`relative(root, resolved)` 以 `..` 开头即越界;
8
9
  * 再用 `resolve(root, rel) === resolved` 兜底,防止相对段恰好名为 root 的边角情况。
10
+ * - symlink 防逃逸(P2-5):root 与目标(或其最近已存在祖先)经 realpath 解析后,
11
+ * 目标必须仍落在 root 实路径内;否则返回 null。
9
12
  *
10
13
  * 返回解析后的绝对路径;越界返回 null。
11
- *
12
- * 注意:本函数基于 `relative`,不解析符号链接(symlink)。若 root 内存在指向
13
- * root 外的符号链接,本函数无法阻止经由该链接的逃逸——symlink 逃逸不在本次范围内。
14
14
  */
15
15
  function safeResolve(root, requestPath) {
16
16
  const resolved = resolve(root, requestPath);
@@ -18,6 +18,22 @@ function safeResolve(root, requestPath) {
18
18
  if (rel.startsWith('..') || resolve(root, rel) !== resolved) {
19
19
  return null;
20
20
  }
21
+ try {
22
+ const rootReal = realpathSync(root);
23
+ let probe = resolved;
24
+ while (!existsSync(probe)) {
25
+ const parent = dirname(probe);
26
+ if (parent === probe)
27
+ break;
28
+ probe = parent;
29
+ }
30
+ const probeReal = realpathSync(probe);
31
+ if (probeReal !== rootReal && !probeReal.startsWith(rootReal + sep))
32
+ return null;
33
+ }
34
+ catch {
35
+ // realpath 失败(root 不存在等):保持原有 relative 判定结果
36
+ }
21
37
  return resolved;
22
38
  }
23
39
  export { safeResolve };
@@ -421,16 +421,17 @@ export function getLatestTodoPhasesFromMessages(messages) {
421
421
  continue;
422
422
  for (let j = msg.content.length - 1; j >= 0; j--) {
423
423
  const part = msg.content[j];
424
- if (!part || part._tag !== 'tool_result')
424
+ if (part?._tag !== 'tool_result')
425
425
  continue;
426
426
  if (part.tool !== 'todo')
427
427
  continue;
428
428
  const output = part.output;
429
- if (!output || output._tag !== 'success')
429
+ if (output?._tag !== 'success')
430
430
  continue;
431
431
  const metadata = output.metadata;
432
- if (metadata && Array.isArray(metadata.phases)) {
433
- return clonePhases(metadata.phases);
432
+ const phases = metadata?.phases;
433
+ if (Array.isArray(phases)) {
434
+ return clonePhases(phases);
434
435
  }
435
436
  }
436
437
  }
@@ -6,11 +6,11 @@ export { dapTools } from './builtin/dap.js';
6
6
  export { editTool } from './builtin/edit.js';
7
7
  export { globTool, globToRegex } from './builtin/glob.js';
8
8
  export { grepTool } from './builtin/grep.js';
9
+ export { kanbanTool } from './builtin/kanban.js';
9
10
  export { readTool } from './builtin/read.js';
10
11
  export { createDefaultURLRegistry, createFileResolver, createSkillResolver, } from './builtin/resolvers.js';
11
- export { kanbanTool } from './builtin/kanban.js';
12
- export { todoTool } from './builtin/todo.js';
13
12
  export type { TodoInput, TodoItem, TodoPhase, TodoStatus } from './builtin/todo.js';
13
+ export { todoTool } from './builtin/todo.js';
14
14
  export { writeTool } from './builtin/write.js';
15
15
  export { yieldTool } from './builtin/yield.js';
16
16
  export { executeTool } from './executor.js';
@@ -23,10 +23,21 @@ export { formatForLLM, resolveProvider, runWebSearch } from './websearch/index.j
23
23
  export type { WebSearchProvider, WebSearchProviderId, WebSearchResponse, WebSearchSource, } from './websearch/types.js';
24
24
  export { createWebSearchTool } from './websearch/websearch.js';
25
25
  import type { Config } from '../shared/types/config.js';
26
+ import type { ToolRegistry } from './types.js';
26
27
  /**
27
28
  * Create a registry pre-loaded with all builtin tools:
28
29
  * read, write, edit, glob, grep, bash, task, websearch, and the debug_* set.
29
30
  *
31
+ * config.tools.disabled 中的工具**不注册**——registry 层过滤保证两个入口
32
+ * (CLI print / Web chat / /api/tools 列表)统一生效,禁用的工具对 agent 完全不可见。
30
33
  * @param config 可选配置;websearch 工具按 config.websearch 构造。省略时用 DEFAULT_CONFIG。
31
34
  */
32
- export declare function createDefaultRegistry(config?: Config): import("./types.js").ToolRegistry;
35
+ export declare function createDefaultRegistry(config?: Config): ToolRegistry;
36
+ /**
37
+ * 解析本轮启用的工具名列表(P1-1 修复:config.tools.enabled/disabled 双入口统一生效)。
38
+ *
39
+ * - explicit(前端显式选择)非 undefined → 以其为准,但过滤掉 disabled 与未注册名。
40
+ * - 否则 enabled 非空 → enabled ∩ registered;enabled 空 → 全部 registered(空=全部)。
41
+ * - disabled 恒过滤(registry 已排除,此处兜底)。
42
+ */
43
+ export declare function resolveEnabledToolNames(registry: ToolRegistry, config: Pick<Config, 'tools'>, explicit?: string[]): string[];
@@ -4,10 +4,10 @@ export { dapTools } from './builtin/dap.js';
4
4
  export { editTool } from './builtin/edit.js';
5
5
  export { globTool, globToRegex } from './builtin/glob.js';
6
6
  export { grepTool } from './builtin/grep.js';
7
+ export { kanbanTool } from './builtin/kanban.js';
7
8
  export { readTool } from './builtin/read.js';
8
9
  // ── Builtin tools ───────────────────────────────────────────
9
10
  export { createDefaultURLRegistry, createFileResolver, createSkillResolver, } from './builtin/resolvers.js';
10
- export { kanbanTool } from './builtin/kanban.js';
11
11
  export { todoTool } from './builtin/todo.js';
12
12
  export { writeTool } from './builtin/write.js';
13
13
  export { yieldTool } from './builtin/yield.js';
@@ -33,28 +33,50 @@ import { taskTool } from './builtin/task.js';
33
33
  import { todoTool } from './builtin/todo.js';
34
34
  import { writeTool } from './builtin/write.js';
35
35
  import { yieldTool } from './builtin/yield.js';
36
- import { createToolRegistry, registerTool } from './registry.js';
36
+ import { createToolRegistry, listTools, registerTool } from './registry.js';
37
37
  import { createWebSearchTool } from './websearch/websearch.js';
38
38
  /**
39
39
  * Create a registry pre-loaded with all builtin tools:
40
40
  * read, write, edit, glob, grep, bash, task, websearch, and the debug_* set.
41
41
  *
42
+ * config.tools.disabled 中的工具**不注册**——registry 层过滤保证两个入口
43
+ * (CLI print / Web chat / /api/tools 列表)统一生效,禁用的工具对 agent 完全不可见。
42
44
  * @param config 可选配置;websearch 工具按 config.websearch 构造。省略时用 DEFAULT_CONFIG。
43
45
  */
44
46
  export function createDefaultRegistry(config = DEFAULT_CONFIG) {
45
47
  const reg = createToolRegistry();
46
- registerTool(reg, readTool);
47
- registerTool(reg, writeTool);
48
- registerTool(reg, editTool);
49
- registerTool(reg, globTool);
50
- registerTool(reg, grepTool);
51
- registerTool(reg, bashTool);
52
- registerTool(reg, kanbanTool);
53
- registerTool(reg, taskTool);
54
- registerTool(reg, todoTool);
55
- registerTool(reg, yieldTool);
56
- registerTool(reg, createWebSearchTool(config.websearch));
48
+ const disabled = new Set(config.tools?.disabled ?? []);
49
+ const register = (tool) => {
50
+ if (!disabled.has(tool.name))
51
+ registerTool(reg, tool);
52
+ };
53
+ register(readTool);
54
+ register(writeTool);
55
+ register(editTool);
56
+ register(globTool);
57
+ register(grepTool);
58
+ register(bashTool);
59
+ register(kanbanTool);
60
+ register(taskTool);
61
+ register(todoTool);
62
+ register(yieldTool);
63
+ register(createWebSearchTool(config.websearch));
57
64
  for (const tool of dapTools)
58
- registerTool(reg, tool);
65
+ register(tool);
59
66
  return reg;
60
67
  }
68
+ /**
69
+ * 解析本轮启用的工具名列表(P1-1 修复:config.tools.enabled/disabled 双入口统一生效)。
70
+ *
71
+ * - explicit(前端显式选择)非 undefined → 以其为准,但过滤掉 disabled 与未注册名。
72
+ * - 否则 enabled 非空 → enabled ∩ registered;enabled 空 → 全部 registered(空=全部)。
73
+ * - disabled 恒过滤(registry 已排除,此处兜底)。
74
+ */
75
+ export function resolveEnabledToolNames(registry, config, explicit) {
76
+ const all = listTools(registry).map((t) => t.name);
77
+ const disabled = new Set(config.tools?.disabled ?? []);
78
+ const base = explicit ?? (config.tools?.enabled && config.tools.enabled.length > 0
79
+ ? config.tools.enabled
80
+ : all);
81
+ return base.filter((n) => all.includes(n) && !disabled.has(n));
82
+ }
@@ -7,4 +7,4 @@ export { orderSessionsByParent, restoreSessions, serializeSessions } from './sna
7
7
  export type { HotUpdateOptions, HotUpdateResult, SpawnFn } from './updater.js';
8
8
  export { cleanupSnapshot, performHotUpdate } from './updater.js';
9
9
  export type { UpdateCheckResult } from './version.js';
10
- export { checkForUpdate, compareSemver } from './version.js';
10
+ export { checkForUpdate, compareSemver, getCurrentVersion } from './version.js';
@@ -3,4 +3,4 @@ export { createHandoffServer, requestHandoff } from './ipc.js';
3
3
  export { createUpdateScheduler } from './scheduler.js';
4
4
  export { orderSessionsByParent, restoreSessions, serializeSessions } from './snapshot.js';
5
5
  export { cleanupSnapshot, performHotUpdate } from './updater.js';
6
- export { checkForUpdate, compareSemver } from './version.js';
6
+ export { checkForUpdate, compareSemver, getCurrentVersion } from './version.js';
@@ -7,9 +7,36 @@ type HandoffServer = {
7
7
  port: number;
8
8
  close: () => Promise<void>;
9
9
  };
10
- /** 旧实例启动时创建 handoff 端点。收到 POST /handoff 即触发 onHandoff(序列化 + 退出)。 */
11
- declare function createHandoffServer(onHandoff: () => Promise<void>): Promise<HandoffServer>;
12
- /** 新实例请求旧实例让出端口(graceful shutdown)。失败抛错。 */
13
- declare function requestHandoff(port: number, host?: string): Promise<void>;
14
- export type { HandoffServer };
10
+ type HandoffOptions = {
11
+ /**
12
+ * 期望的 Bearer token。设置后 /handoff 必须携带匹配的 Authorization 头,
13
+ * 否则 401——防止任意本地进程用 POST /handoff 杀掉服务。
14
+ * 旧实例与新实例读同一 auth-token 文件(或同一 config token),天然匹配。
15
+ */
16
+ expectedToken?: string;
17
+ /**
18
+ * 响应发出后退出进程(旧实例完整让渡:主服务与资源已在 onHandoff 中关闭)。
19
+ * 退出延迟 250ms 保证 HTTP 响应刷出;不设时仅响应不退出(测试用)。
20
+ */
21
+ exitAfterResponse?: boolean;
22
+ };
23
+ /**
24
+ * requestHandoff 抛出的可区分错误(调用方据此分流处理):
25
+ * - kind='connect':连接层失败(ECONNREFUSED 等)——旧实例未启 handoff 端点,
26
+ * 调用方应直接尝试绑端口;
27
+ * - kind='http':收到 HTTP 响应但非 2xx(status 为状态码)——如 401 token
28
+ * 不匹配、500 onHandoff 失败,旧实例存在但拒绝/未能让渡。
29
+ */
30
+ type HandoffError = Error & {
31
+ kind: 'connect' | 'http';
32
+ status?: number;
33
+ };
34
+ /**
35
+ * 旧实例启动时创建 handoff 端点。收到 POST /handoff 即触发 onHandoff
36
+ * (关闭主服务与资源),响应 200;exitAfterResponse 时随后退出进程。
37
+ */
38
+ declare function createHandoffServer(onHandoff: () => Promise<void>, opts?: HandoffOptions): Promise<HandoffServer>;
39
+ /** 新实例请求旧实例让出端口(graceful shutdown)。失败抛可区分的 HandoffError。 */
40
+ declare function requestHandoff(port: number, host?: string, token?: string): Promise<void>;
41
+ export type { HandoffError, HandoffOptions, HandoffServer };
15
42
  export { createHandoffServer, requestHandoff };
@@ -1,17 +1,45 @@
1
+ import { timingSafeEqual } from 'node:crypto';
1
2
  import { createServer } from 'node:http';
2
- /** 旧实例启动时创建 handoff 端点。收到 POST /handoff 即触发 onHandoff(序列化 + 退出)。 */
3
- function createHandoffServer(onHandoff) {
3
+ /**
4
+ * 恒时比较 Authorization 头与期望值:字符串直接比较可被时序攻击逐字符探测。
5
+ * 长度不等直接 false——timingSafeEqual 要求等长 buffer,先比较长度本身
6
+ * 不泄露内容信息。
7
+ */
8
+ function authHeaderMatches(actual, expected) {
9
+ if (typeof actual !== 'string' || actual.length !== expected.length)
10
+ return false;
11
+ return timingSafeEqual(Buffer.from(actual), Buffer.from(expected));
12
+ }
13
+ /**
14
+ * 旧实例启动时创建 handoff 端点。收到 POST /handoff 即触发 onHandoff
15
+ * (关闭主服务与资源),响应 200;exitAfterResponse 时随后退出进程。
16
+ */
17
+ function createHandoffServer(onHandoff, opts = {}) {
4
18
  return new Promise((resolve, reject) => {
19
+ const expected = opts.expectedToken && opts.expectedToken.length > 0
20
+ ? `Bearer ${opts.expectedToken}`
21
+ : '';
5
22
  const handler = async (req, res) => {
6
23
  if (req.method === 'POST' && req.url === '/handoff') {
24
+ if (expected && !authHeaderMatches(req.headers.authorization, expected)) {
25
+ res.writeHead(401, { 'content-type': 'application/json' });
26
+ res.end(JSON.stringify({ ok: false, error: 'unauthorized' }));
27
+ return;
28
+ }
7
29
  try {
8
30
  await onHandoff();
9
31
  res.writeHead(200, { 'content-type': 'application/json' });
10
32
  res.end(JSON.stringify({ ok: true }));
33
+ // 仅 200 才调度退出:500 时 exit(0) 会掩盖让渡失败——保留进程供
34
+ // 诊断/重试,由下方错误日志暴露 onHandoff 失败原因。
35
+ if (opts.exitAfterResponse) {
36
+ setTimeout(() => process.exit(0), 250);
37
+ }
11
38
  }
12
39
  catch (error) {
13
40
  res.writeHead(500, { 'content-type': 'application/json' });
14
41
  res.end(JSON.stringify({ ok: false, error: String(error) }));
42
+ console.error('[handoff] onHandoff failed, keeping process alive:', error);
15
43
  }
16
44
  return;
17
45
  }
@@ -32,10 +60,28 @@ function createHandoffServer(onHandoff) {
32
60
  });
33
61
  });
34
62
  }
35
- /** 新实例请求旧实例让出端口(graceful shutdown)。失败抛错。 */
36
- async function requestHandoff(port, host = '127.0.0.1') {
37
- const res = await fetch(`http://${host}:${port}/handoff`, { method: 'POST' });
38
- if (!res.ok)
39
- throw new Error(`handoff failed: HTTP ${res.status}`);
63
+ /** 新实例请求旧实例让出端口(graceful shutdown)。失败抛可区分的 HandoffError。 */
64
+ async function requestHandoff(port, host = '127.0.0.1', token) {
65
+ let res;
66
+ try {
67
+ res = await fetch(`http://${host}:${port}/handoff`, {
68
+ method: 'POST',
69
+ ...(token ? { headers: { Authorization: `Bearer ${token}` } } : {}),
70
+ });
71
+ }
72
+ catch (err) {
73
+ // 连接层失败(ECONNREFUSED 等):旧实例无 handoff 端点。抛 kind='connect'
74
+ // 让调用方(requestHandoffWithRetry)区分处理——直接尝试绑端口。
75
+ const error = new Error(`handoff connect failed: ${err instanceof Error ? err.message : String(err)}`);
76
+ error.kind = 'connect';
77
+ throw error;
78
+ }
79
+ if (!res.ok) {
80
+ // 收到 HTTP 响应但非 2xx(如 401 token 不匹配、500 onHandoff 失败)。
81
+ const error = new Error(`handoff failed: HTTP ${res.status}`);
82
+ error.kind = 'http';
83
+ error.status = res.status;
84
+ throw error;
85
+ }
40
86
  }
41
87
  export { createHandoffServer, requestHandoff };
@@ -16,8 +16,9 @@ type HotUpdateResult = {
16
16
  * 启动新实例的函数签名。
17
17
  * @param snapshotPath 快照文件路径(旧实例已写入)
18
18
  * @param argv 传给新进程的完整参数(含 restore flag + 可选 handoff flag)
19
+ * @param opts 热更新选项(authToken 等经环境变量传递给新实例)
19
20
  */
20
- type SpawnFn = (snapshotPath: string, argv: string[]) => Promise<void>;
21
+ type SpawnFn = (snapshotPath: string, argv: string[], opts?: HotUpdateOptions) => Promise<void>;
21
22
  type HotUpdateOptions = {
22
23
  /** 自更新安装函数;默认 `npm install -g <pkg>`。 */
23
24
  installFn?: (pkg: string) => Promise<void>;
@@ -30,6 +31,10 @@ type HotUpdateOptions = {
30
31
  restoreFlag?: string;
31
32
  /** 旧实例 handoff 端口;提供时新实例启动后请求旧实例 graceful shutdown。 */
32
33
  handoffPort?: number;
34
+ /** 旧实例主服务端口;新实例 --port 复用(保证接管同一地址)。 */
35
+ port?: number;
36
+ /** 传给新实例的认证 token(经环境变量 C0DE_AUTH_TOKEN),保证新旧实例握手同 token。 */
37
+ authToken?: string;
33
38
  };
34
39
  /**
35
40
  * 执行热更新(spec §18.1-18.2 流程):
@@ -38,8 +43,7 @@ type HotUpdateOptions = {
38
43
  * 3. detached 启动新版本实例,传入快照路径(新实例 restore + 端口接管)
39
44
  *
40
45
  * 注:旧实例的 graceful shutdown / 端口让渡由新实例启动后通过 IPC 协调
41
- * (见 ipc.ts),本函数只负责"序列化 + 安装 + 接力"。若提供 handoffPort,
42
- * argv 会带上 --handoff-port,新实例据此 requestHandoff 通知旧实例退出。
46
+ * (见 ipc.ts),本函数只负责"序列化 + 安装 + 接力"。
43
47
  */
44
48
  declare function performHotUpdate(snapshot: SessionSnapshot, opts?: HotUpdateOptions): Promise<HotUpdateResult>;
45
49
  /** 清理快照临时目录(热更新完成或放弃后调用)。 */
@@ -16,13 +16,17 @@ function defaultInstall(pkg) {
16
16
  });
17
17
  });
18
18
  }
19
- /** 默认启动新实例:detached spawn,argv 全部透传给新进程。 */
20
- function defaultSpawn(_snapshotPath, argv) {
19
+ /** 默认启动新实例:detached spawn CLI bin,argv = serve <restore> <handoff>。
20
+ * 必须以 `serve` 子命令开头(CLI dispatch 按首参选命令);
21
+ * --port 复用旧实例端口,--handoff-port 让新实例启动后请求旧实例退出。
22
+ * 认证 token 经环境变量传递(不落 argv,避免 ps 泄漏)。 */
23
+ function defaultSpawn(_snapshotPath, argv, opts) {
21
24
  return new Promise((resolve, reject) => {
22
25
  try {
23
26
  const child = spawn(process.argv0, [process.argv[1] ?? '', ...argv], {
24
27
  detached: true,
25
28
  stdio: 'ignore',
29
+ ...(opts?.authToken ? { env: { ...process.env, C0DE_AUTH_TOKEN: opts.authToken } } : {}),
26
30
  });
27
31
  child.unref();
28
32
  resolve();
@@ -39,8 +43,7 @@ function defaultSpawn(_snapshotPath, argv) {
39
43
  * 3. detached 启动新版本实例,传入快照路径(新实例 restore + 端口接管)
40
44
  *
41
45
  * 注:旧实例的 graceful shutdown / 端口让渡由新实例启动后通过 IPC 协调
42
- * (见 ipc.ts),本函数只负责"序列化 + 安装 + 接力"。若提供 handoffPort,
43
- * argv 会带上 --handoff-port,新实例据此 requestHandoff 通知旧实例退出。
46
+ * (见 ipc.ts),本函数只负责"序列化 + 安装 + 接力"。
44
47
  */
45
48
  async function performHotUpdate(snapshot, opts = {}) {
46
49
  const pkg = opts.packageName ?? DEFAULT_PACKAGE;
@@ -59,13 +62,18 @@ async function performHotUpdate(snapshot, opts = {}) {
59
62
  };
60
63
  }
61
64
  const restoreFlag = opts.restoreFlag ?? '--restore';
62
- const argv = [restoreFlag, snapshotPath];
65
+ const argv = [
66
+ 'serve',
67
+ restoreFlag,
68
+ snapshotPath,
69
+ ...(opts.port !== undefined ? ['--port', String(opts.port)] : []),
70
+ ];
63
71
  if (opts.handoffPort !== undefined) {
64
72
  argv.push('--handoff-port', String(opts.handoffPort));
65
73
  }
66
74
  const spawnNew = opts.spawnNewInstanceFn ?? defaultSpawn;
67
75
  try {
68
- await spawnNew(snapshotPath, argv);
76
+ await spawnNew(snapshotPath, argv, opts);
69
77
  }
70
78
  catch (error) {
71
79
  return {
@@ -10,9 +10,14 @@ type CheckOptions = {
10
10
  currentVersion?: string;
11
11
  registryUrl?: string;
12
12
  };
13
+ /**
14
+ * 读取当前安装版本(package.json 的 version 字段)。
15
+ * src/dev 与 dist 两种布局下 `../../package.json` 都指向包根;读取失败回退常量。
16
+ */
17
+ declare function getCurrentVersion(): string;
13
18
  /** 语义化版本比较:a < b → -1,a == b → 0,a > b → 1。忽略前导 v 和 prerelease。 */
14
19
  declare function compareSemver(a: string, b: string): number;
15
20
  /** 查询 npm registry 判断是否有新版本。网络/解析失败时返回 hasUpdate:false,绝不抛错。 */
16
21
  declare function checkForUpdate(opts?: CheckOptions): Promise<UpdateCheckResult>;
17
22
  export type { CheckOptions, UpdateCheckResult };
18
- export { checkForUpdate, compareSemver };
23
+ export { checkForUpdate, compareSemver, getCurrentVersion };