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
@@ -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, };
@@ -50,31 +50,39 @@ async function squashRecent(handle, sessionId, count, summarizer, config) {
50
50
  ${history}`;
51
51
  const summary = await summarizer(prompt);
52
52
  const squashEntryId = generateId();
53
- const archiveId = cfg.archiveOriginal
54
- ? await archiveOriginalEntries(handle, sessionId, toSquash, 'squash', summary, squashEntryId)
55
- : generateId();
56
- const fileSnapshotIds = [];
57
- if (cfg.preserveFileSnapshots) {
58
- const hotFiles = extractHotFiles(toSquash);
59
- for (const file of hotFiles) {
60
- const id = await upsertFileSnapshot(handle, sessionId, file.path, file.content);
61
- fileSnapshotIds.push(id);
53
+ // Atomic rewrite: archive originals → upsert snapshots → delete originals →
54
+ // insert summary. If any step throws, the whole transaction rolls back and
55
+ // the original message history is left intact. (Mirrors compactSession.)
56
+ const { archiveId, fileSnapshotIds } = await handle.db.transaction(async (tx) => {
57
+ const txHandle = { db: tx, close: handle.close };
58
+ const archiveId = cfg.archiveOriginal
59
+ ? await archiveOriginalEntries(txHandle, sessionId, toSquash, 'squash', summary, squashEntryId)
60
+ : generateId();
61
+ const fileSnapshotIds = [];
62
+ if (cfg.preserveFileSnapshots) {
63
+ const hotFiles = extractHotFiles(toSquash);
64
+ for (const file of hotFiles) {
65
+ // 不传 mtimeMs:由 upsertFileSnapshot 从已有行透传,压缩后热文件不误判过期。
66
+ const id = await upsertFileSnapshot(txHandle, sessionId, file.path, file.content);
67
+ fileSnapshotIds.push(id);
68
+ }
62
69
  }
63
- }
64
- await deleteEntriesByIds(handle, toSquash.map((m) => m.id));
65
- await insertEntry(handle, {
66
- id: squashEntryId,
67
- sessionId,
68
- tag: 'squash',
69
- content: {
70
- summary,
71
- squashedEntryIds: toSquash.map((m) => m.id),
72
- archiveId,
73
- },
74
- tokenCount: estimateTokens(summary),
75
- // Position at the first squashed message's timestamp so it sorts between
76
- // the prefix and the kept tail under createdAt-ascending order.
77
- createdAt: toSquash[0] ? new Date(toSquash[0].createdAt) : new Date(),
70
+ await deleteEntriesByIds(txHandle, toSquash.map((m) => m.id));
71
+ await insertEntry(txHandle, {
72
+ id: squashEntryId,
73
+ sessionId,
74
+ tag: 'squash',
75
+ content: {
76
+ summary,
77
+ squashedEntryIds: toSquash.map((m) => m.id),
78
+ archiveId,
79
+ },
80
+ tokenCount: estimateTokens(summary),
81
+ // Position at the first squashed message's timestamp so it sorts between
82
+ // the prefix and the kept tail under createdAt-ascending order.
83
+ createdAt: toSquash[0] ? new Date(toSquash[0].createdAt) : new Date(),
84
+ });
85
+ return { archiveId, fileSnapshotIds };
78
86
  });
79
87
  return {
80
88
  compacted: true,
@@ -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, };
@@ -184,6 +184,17 @@ type AgentEvent = {
184
184
  archiveId?: string;
185
185
  compactedCount: number;
186
186
  keptCount: number;
187
+ }
188
+ /** Tag-based todo 操作成功后发射,前端据此刷新 TodoPanel。 */
189
+ | {
190
+ _tag: 'todo_update';
191
+ phases: {
192
+ name: string;
193
+ tasks: {
194
+ content: string;
195
+ status: string;
196
+ }[];
197
+ }[];
187
198
  } | {
188
199
  _tag: 'done';
189
200
  };
@@ -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 };
@@ -47,6 +47,11 @@ export declare function nextActionableTask(phases: readonly TodoPhase[]): TodoIt
47
47
  * `descriptions`. Normalize-then-equal first, with a substring fallback
48
48
  * in either direction (≥6 char overlap on the contained side). */
49
49
  export declare function todoMatchesAnyDescription(content: string, descriptions: readonly string[]): boolean;
50
+ /** Apply a single todo op to existing phases. Returns new phases + errors. */
51
+ declare function applyParams(phases: TodoPhase[], params: TodoInput): {
52
+ phases: TodoPhase[];
53
+ errors: string[];
54
+ };
50
55
  /** Render todo phases as a Markdown checklist suitable for editing/copying. */
51
56
  export declare function phasesToMarkdown(phases: TodoPhase[]): string;
52
57
  /** Parse a Markdown checklist back into todo phases. */
@@ -54,6 +59,7 @@ export declare function markdownToPhases(md: string): {
54
59
  phases: TodoPhase[];
55
60
  errors: string[];
56
61
  };
62
+ export declare function formatSummary(phases: TodoPhase[], errors: string[], readOnly?: boolean): string;
57
63
  /** Extract the latest todo phases from stored messages (tool results).
58
64
  * Scans backwards for the most recent `todo` tool result with phases metadata. */
59
65
  export declare function getLatestTodoPhasesFromMessages(messages: {
@@ -65,3 +71,4 @@ export declare function getLatestTodoPhasesFromMessages(messages: {
65
71
  * State is held in-memory via ctx.todoState hook (dependency-reversal). */
66
72
  export declare const todoTool: ToolDef;
67
73
  export type { TodoInput, TodoItem, TodoPhase, TodoStatus };
74
+ export { applyParams };
@@ -293,10 +293,9 @@ export function phasesToMarkdown(phases) {
293
293
  if (phases.length === 0)
294
294
  return '# Todos\n';
295
295
  const out = [];
296
- for (let i = 0; i < phases.length; i++) {
296
+ for (const [i, phase] of phases.entries()) {
297
297
  if (i > 0)
298
298
  out.push('');
299
- const phase = phases[i];
300
299
  out.push(`# ${phase.name}`);
301
300
  for (const task of phase.tasks) {
302
301
  out.push(`- [${STATUS_TO_MARKER[task.status]}] ${task.content}`);
@@ -320,14 +319,13 @@ export function markdownToPhases(md) {
320
319
  const phases = [];
321
320
  let currentPhase;
322
321
  const lines = md.split(/\r?\n/);
323
- for (let lineNum = 0; lineNum < lines.length; lineNum++) {
324
- const raw = lines[lineNum];
322
+ for (const [lineNum, raw] of lines.entries()) {
325
323
  const trimmed = raw.trim();
326
324
  if (!trimmed)
327
325
  continue;
328
326
  const headingMatch = /^#{1,6}\s+(.+?)\s*$/.exec(trimmed);
329
327
  if (headingMatch) {
330
- currentPhase = { name: headingMatch[1].trim(), tasks: [] };
328
+ currentPhase = { name: (headingMatch[1] ?? '').trim(), tasks: [] };
331
329
  phases.push(currentPhase);
332
330
  continue;
333
331
  }
@@ -343,7 +341,7 @@ export function markdownToPhases(md) {
343
341
  errors.push(`Line ${lineNum + 1}: unknown status marker "[${marker}]" (use [ ], [x], [/], [-])`);
344
342
  continue;
345
343
  }
346
- currentPhase.tasks.push({ content: taskMatch[2].trim(), status });
344
+ currentPhase.tasks.push({ content: (taskMatch[2] ?? '').trim(), status });
347
345
  continue;
348
346
  }
349
347
  errors.push(`Line ${lineNum + 1}: unrecognized syntax "${trimmed}"`);
@@ -354,7 +352,7 @@ export function markdownToPhases(md) {
354
352
  // =============================================================================
355
353
  // Summary formatter
356
354
  // =============================================================================
357
- function formatSummary(phases, errors, readOnly = false) {
355
+ export function formatSummary(phases, errors, readOnly = false) {
358
356
  const tasks = phases.flatMap((phase) => phase.tasks);
359
357
  if (tasks.length === 0) {
360
358
  if (errors.length > 0)
@@ -362,16 +360,20 @@ function formatSummary(phases, errors, readOnly = false) {
362
360
  return readOnly ? 'Todo list is empty.' : 'Todo list cleared.';
363
361
  }
364
362
  const remainingByPhase = phases
365
- .map((phase) => ({
363
+ .map((phase, pi) => ({
366
364
  name: phase.name,
367
- tasks: phase.tasks.filter((task) => task.status === 'pending' || task.status === 'in_progress'),
365
+ tasks: phase.tasks
366
+ .map((task, ti) => ({ task, seq: `${pi + 1}-${ti + 1}` }))
367
+ .filter(({ task }) => task.status === 'pending' || task.status === 'in_progress'),
368
368
  }))
369
369
  .filter((phase) => phase.tasks.length > 0);
370
- const remainingTasks = remainingByPhase.flatMap((phase) => phase.tasks.map((task) => ({ ...task, phase: phase.name })));
370
+ const remainingTasks = remainingByPhase.flatMap((phase) => phase.tasks.map(({ task, seq }) => ({ ...task, seq, phase: phase.name })));
371
371
  let currentIdx = phases.findIndex((phase) => phase.tasks.some((task) => task.status === 'pending' || task.status === 'in_progress'));
372
372
  if (currentIdx === -1)
373
373
  currentIdx = phases.length - 1;
374
374
  const current = phases[currentIdx];
375
+ if (!current)
376
+ return errors.length > 0 ? `Errors: ${errors.join('; ')}` : 'Todo list cleared.';
375
377
  const done = current.tasks.filter((task) => task.status === 'completed' || task.status === 'abandoned').length;
376
378
  const lines = [];
377
379
  if (errors.length > 0)
@@ -382,7 +384,7 @@ function formatSummary(phases, errors, readOnly = false) {
382
384
  else {
383
385
  lines.push(`Remaining items (${remainingTasks.length}):`);
384
386
  for (const task of remainingTasks) {
385
- lines.push(` - ${task.content} [${task.status}] (${task.phase})`);
387
+ lines.push(` - ${task.seq}: ${task.content} [${task.status}] (${task.phase})`);
386
388
  }
387
389
  }
388
390
  const closedAll = tasks.filter((task) => task.status === 'completed' || task.status === 'abandoned').length;
@@ -392,16 +394,17 @@ function formatSummary(phases, errors, readOnly = false) {
392
394
  lines.push(`Active phase ${currentIdx + 1}/${phases.length} "${current.name}" (${done}/${current.tasks.length})${workedAhead
393
395
  ? ' — earliest phase with open tasks; the in-progress pointer auto-advances to the earliest open task on each completion, so it can sit behind out-of-order work (nothing was un-completed).'
394
396
  : '.'}`);
395
- for (const phase of phases) {
397
+ for (const [pi, phase] of phases.entries()) {
396
398
  lines.push(` ${phase.name}:`);
397
- for (const task of phase.tasks) {
399
+ for (const [ti, task] of phase.tasks.entries()) {
400
+ const seq = `${pi + 1}-${ti + 1}`;
398
401
  const checkbox = task.status === 'completed' ? '[X]' : '[ ]';
399
402
  const tag = task.status === 'in_progress'
400
403
  ? ' (in progress)'
401
404
  : task.status === 'abandoned'
402
405
  ? ' (dropped)'
403
406
  : '';
404
- lines.push(` - ${checkbox} ${task.content}${tag}`);
407
+ lines.push(` - ${checkbox} ${seq}: ${task.content}${tag}`);
405
408
  }
406
409
  }
407
410
  return lines.join('\n');
@@ -414,20 +417,21 @@ function formatSummary(phases, errors, readOnly = false) {
414
417
  export function getLatestTodoPhasesFromMessages(messages) {
415
418
  for (let i = messages.length - 1; i >= 0; i--) {
416
419
  const msg = messages[i];
417
- if (msg.role !== 'tool')
420
+ if (msg?.role !== 'tool')
418
421
  continue;
419
422
  for (let j = msg.content.length - 1; j >= 0; j--) {
420
423
  const part = msg.content[j];
421
- if (!part || part._tag !== 'tool_result')
424
+ if (part?._tag !== 'tool_result')
422
425
  continue;
423
426
  if (part.tool !== 'todo')
424
427
  continue;
425
428
  const output = part.output;
426
- if (!output || output._tag !== 'success')
429
+ if (output?._tag !== 'success')
427
430
  continue;
428
431
  const metadata = output.metadata;
429
- if (metadata && Array.isArray(metadata.phases)) {
430
- return clonePhases(metadata.phases);
432
+ const phases = metadata?.phases;
433
+ if (Array.isArray(phases)) {
434
+ return clonePhases(phases);
431
435
  }
432
436
  }
433
437
  }
@@ -463,7 +467,10 @@ const todoParameters = {
463
467
  },
464
468
  },
465
469
  task: { type: 'string', description: 'Task content (for start/done/drop/rm)' },
466
- phase: { type: 'string', description: 'Phase name (for done/drop/rm/append, or init flat mode)' },
470
+ phase: {
471
+ type: 'string',
472
+ description: 'Phase name (for done/drop/rm/append, or init flat mode)',
473
+ },
467
474
  items: {
468
475
  type: 'array',
469
476
  items: { type: 'string' },
@@ -515,3 +522,4 @@ export const todoTool = {
515
522
  return { _tag: 'success', output, metadata };
516
523
  },
517
524
  };
525
+ export { applyParams };
@@ -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 };