c0de-agent 1.15.0 → 1.15.1

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.
@@ -13,6 +13,28 @@ import { headChars } from '../../shared/utils/string.js';
13
13
  import { apiError } from '../middleware/error.js';
14
14
  import { readJsonObject } from '../util/json-body.js';
15
15
  import { safeResolve } from '../util/safe-path.js';
16
+ /** 二进制嗅探:NUL 字节出现在文件开头 8 KiB 内,或整段不是合法 UTF-8。
17
+ *
18
+ * 读取接口此前一律 `readFile(resolved, 'utf-8')`——任何扩展名都按文本解码,
19
+ * .zip/.woff2/.wasm/.db 之类解码成一片 U+FFFD 后照样进前端编辑器;用户敲一个字
20
+ * 点保存,PUT 就用文本把原文件 truncate 重写一遍,原字节不可恢复(实测 4096B 的
21
+ * zip 被写成 7476B 的 U+FFFD 文本)。这里在字节层把「不是文本」说清楚,别让前端
22
+ * 拿乱码当可编辑内容。
23
+ *
24
+ * 判据与 git 的文本检测同族(NUL 快速通道 + UTF-8 合法性)。合法性必须按**整段**
25
+ * 判定:只看前 8 KiB 会在多字节字符被切断处误报成二进制。
26
+ */
27
+ function looksBinary(buf) {
28
+ if (buf.subarray(0, 8192).includes(0))
29
+ return true;
30
+ try {
31
+ new TextDecoder('utf-8', { fatal: true }).decode(buf);
32
+ return false;
33
+ }
34
+ catch {
35
+ return true;
36
+ }
37
+ }
16
38
  /** 递归搜索时跳过的目录(体积大/为元数据噪音,避免递归进入)。
17
39
  * P3:补齐常见构建/缓存产物目录——此前只跳 .git/node_modules,
18
40
  * 大仓库每击键全量 walk 时 dist/.cache 等目录拖垮延迟。 */
@@ -521,7 +543,12 @@ ${headChars(summary.diff, 8000)}`;
521
543
  : {}),
522
544
  });
523
545
  }
524
- const content = await readFile(resolved, 'utf-8');
546
+ const buf = await readFile(resolved);
547
+ // 二进制不按文本返回:前端拿到的每一段乱码都可能原样写回磁盘。
548
+ if (looksBinary(buf)) {
549
+ return apiError(c, 415, 'BINARY_FILE', '这是二进制文件,不能以文本方式显示或编辑(需要查看请下载/用外部工具打开)');
550
+ }
551
+ const content = buf.toString('utf-8');
525
552
  return c.json({ path, content });
526
553
  }
527
554
  catch {
@@ -11,6 +11,10 @@ type DirEntry = {
11
11
  *
12
12
  * 输入 path 为空时返回 home 目录列表。
13
13
  * `~` 开头自动展开为 home 目录。
14
+ *
15
+ * 读不到(无权访问 / 已被删除 / 不是目录)必须抛出:此前吞成 `[]`,
16
+ * 选择器无法区分「目录是空的」与「目录读不到」,把权限错误渲染成
17
+ * 「(空)」——用户据此认定「里面没有东西」,既不重试也不去修权限。
14
18
  */
15
19
  declare function listDirectories(dirPath: string): Promise<DirEntry[]>;
16
20
  /** 递归搜索目录:从 directory 起收集 type=directory 的相对路径。
@@ -2,21 +2,20 @@ import { readdir } from 'node:fs/promises';
2
2
  import { homedir } from 'node:os';
3
3
  import { relative as relativePath, resolve as resolvePath } from 'node:path';
4
4
  import { Hono } from 'hono';
5
+ import { apiError } from '../middleware/error.js';
5
6
  /**
6
7
  * 列出指定目录下的子目录(仅目录,不含文件)。
7
8
  * 用于项目添加时的路径自动补全——可浏览文件系统任意位置。
8
9
  *
9
10
  * 输入 path 为空时返回 home 目录列表。
10
11
  * `~` 开头自动展开为 home 目录。
12
+ *
13
+ * 读不到(无权访问 / 已被删除 / 不是目录)必须抛出:此前吞成 `[]`,
14
+ * 选择器无法区分「目录是空的」与「目录读不到」,把权限错误渲染成
15
+ * 「(空)」——用户据此认定「里面没有东西」,既不重试也不去修权限。
11
16
  */
12
17
  async function listDirectories(dirPath) {
13
- let entries;
14
- try {
15
- entries = await readdir(dirPath, { withFileTypes: true });
16
- }
17
- catch {
18
- return [];
19
- }
18
+ const entries = await readdir(dirPath, { withFileTypes: true });
20
19
  const dirs = entries
21
20
  .filter((e) => e.isDirectory() && !e.name.startsWith('.'))
22
21
  .map((e) => ({
@@ -90,8 +89,22 @@ function createFilesystemRoute(ctx) {
90
89
  const target = expandPath(rawPath);
91
90
  // 校验:ctx 可用于将来做沙箱限制(当前允许浏览任意路径)
92
91
  void ctx;
93
- const dirs = await listDirectories(target);
94
- return c.json({ path: target, directories: dirs });
92
+ try {
93
+ const dirs = await listDirectories(target);
94
+ return c.json({ path: target, directories: dirs });
95
+ }
96
+ catch (err) {
97
+ // 读不到就说读不到:调用方(目录选择器)据此展示原因与重试,
98
+ // 而不是把失败当成空目录。
99
+ const errno = err?.code;
100
+ if (errno === 'ENOENT' || errno === 'ENOTDIR') {
101
+ return apiError(c, 404, 'PATH_NOT_FOUND', `目录不存在或已被删除:${target}`);
102
+ }
103
+ if (errno === 'EACCES' || errno === 'EPERM') {
104
+ return apiError(c, 403, 'PATH_FORBIDDEN', `没有权限读取该目录:${target}`);
105
+ }
106
+ return apiError(c, 500, 'BROWSE_FAILED', `读取目录失败:${err instanceof Error ? err.message : String(err)}`);
107
+ }
95
108
  });
96
109
  // 递归搜索目录(用于项目路径自动补全——命中深层目录)
97
110
  app.get('/search', async (c) => {
@@ -82,10 +82,15 @@ function createTodoRoute(ctx) {
82
82
  const updatedPhases = todoCtx.todoState.get();
83
83
  // 如果操作成功且 phases 有变化,持久化为 tool 消息
84
84
  if (result._tag === 'success' && body.op !== 'view') {
85
+ // tool_call 与 tool_result 必须共用同一个 id:前端 normalizeParts 按 id 配对,
86
+ // 两个 generateId() 会让它们配不上——时间线上留下一个永远停在「运行中」
87
+ // (⏳ 永不结束)的 todo 块,正文还因拿不到 output 而谎报「Todo list is empty.」,
88
+ // 另加一张孤立的 result 卡。实测从「任务本会话待办」面板点「添加」后必现。
89
+ const toolMessageId = generateId();
85
90
  // 构造 tool_result 消息内容
86
91
  const toolResultContent = {
87
92
  _tag: 'tool_result',
88
- id: generateId(),
93
+ id: toolMessageId,
89
94
  tool: 'todo',
90
95
  output: {
91
96
  _tag: 'success',
@@ -95,7 +100,7 @@ function createTodoRoute(ctx) {
95
100
  };
96
101
  const toolCallContent = {
97
102
  _tag: 'tool_call',
98
- id: generateId(),
103
+ id: toolMessageId,
99
104
  tool: 'todo',
100
105
  input: body,
101
106
  };
@@ -18,6 +18,7 @@ type ToolResult = {
18
18
  } | {
19
19
  _tag: 'error';
20
20
  error: string;
21
+ metadata?: Record<string, unknown>;
21
22
  shakenAt?: number;
22
23
  } | {
23
24
  _tag: 'permission_required';
@@ -111,16 +111,24 @@ export const taskTool = {
111
111
  const label = (i) => inp.tasks[i]?.description ?? inp.tasks[i]?.role ?? 'task';
112
112
  const completed = [];
113
113
  const failures = [];
114
+ // 逐子任务的终态:交给 UI 画工作流图。此前图里所有节点都套用父工具的
115
+ // 状态——批量里失败一个,成功的那几个也一起被画成「失败」,与下方
116
+ // 「Completed: …」的文本自相矛盾(实测 3 个里 2 成 1 败,图上 3 个全红)。
117
+ // 只带下标不带文案:节点标题仍由 UI 从输入派生,避免两处各写一套兜底文案。
118
+ const subtasks = [];
114
119
  results.forEach((res, i) => {
115
120
  if (res._tag === 'error') {
116
121
  failures.push(`[${label(i)}] ${res.error}`);
122
+ subtasks.push({ index: i, status: 'failed' });
117
123
  return;
118
124
  }
119
125
  if (res._tag === 'running') {
120
126
  completed.push(`[${label(i)}] background started (jobId: ${res.jobId})`);
127
+ subtasks.push({ index: i, status: 'running' });
121
128
  return;
122
129
  }
123
130
  completed.push(`[${label(i)}] ${res.output}`);
131
+ subtasks.push({ index: i, status: 'completed' });
124
132
  });
125
133
  if (failures.length > 0) {
126
134
  // 失败不吞掉已完成任务的结果:模型据此只重派失败的子任务
@@ -128,9 +136,10 @@ export const taskTool = {
128
136
  _tag: 'error',
129
137
  error: `Sub-agent failed: ${failures.join('; ')}` +
130
138
  (completed.length > 0 ? `\n\nCompleted:\n${completed.join('\n\n')}` : ''),
139
+ metadata: { subtasks },
131
140
  };
132
141
  }
133
- return { _tag: 'success', output: completed.join('\n\n') };
142
+ return { _tag: 'success', output: completed.join('\n\n'), metadata: { subtasks } };
134
143
  }
135
144
  // 单任务模式
136
145
  const single = inp;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c0de-agent",
3
- "version": "1.15.0",
3
+ "version": "1.15.1",
4
4
  "description": "Open-source AI coding assistant with Browser-Server architecture",
5
5
  "type": "module",
6
6
  "bin": {