c0de-agent 1.4.0 → 1.6.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 (44) hide show
  1. package/dist/core/agent.js +4 -0
  2. package/dist/core/config.js +1 -1
  3. package/dist/core/index.d.ts +3 -3
  4. package/dist/core/index.js +1 -1
  5. package/dist/core/loop.js +9 -0
  6. package/dist/core/prompt-registry.d.ts +2 -2
  7. package/dist/core/prompt-registry.js +42 -3
  8. package/dist/core/slash.js +102 -6
  9. package/dist/core/types.d.ts +10 -1
  10. package/dist/core/workflow.d.ts +1 -1
  11. package/dist/core/workflow.js +56 -5
  12. package/dist/core/workflows/discovery.d.ts +23 -2
  13. package/dist/core/workflows/discovery.js +38 -2
  14. package/dist/core/workflows/index.d.ts +3 -2
  15. package/dist/core/workflows/index.js +2 -2
  16. package/dist/core/workflows/registry.d.ts +8 -1
  17. package/dist/core/workflows/registry.js +21 -1
  18. package/dist/core/workflows/runtime.d.ts +3 -0
  19. package/dist/core/workflows/runtime.js +2 -2
  20. package/dist/project/resolve.d.ts +75 -0
  21. package/dist/project/resolve.js +253 -1
  22. package/dist/server/app.js +3 -0
  23. package/dist/server/context.js +2 -1
  24. package/dist/server/dev.js +3 -1
  25. package/dist/server/routes/chat.js +12 -0
  26. package/dist/server/routes/commands.js +1 -0
  27. package/dist/server/routes/files.js +252 -4
  28. package/dist/server/routes/terminal.js +2 -1
  29. package/dist/server/routes/todo.d.ts +4 -0
  30. package/dist/server/routes/todo.js +107 -0
  31. package/dist/server/routes/workflows.js +83 -10
  32. package/dist/server/server.d.ts +8 -1
  33. package/dist/server/server.js +113 -23
  34. package/dist/server/terminal/pty-manager.d.ts +14 -0
  35. package/dist/server/terminal/pty-manager.js +105 -7
  36. package/dist/shared/types/agent.d.ts +9 -0
  37. package/dist/shared/types/config.d.ts +6 -0
  38. package/dist/shared/types/tool.d.ts +13 -0
  39. package/dist/tools/builtin/todo.d.ts +67 -0
  40. package/dist/tools/builtin/todo.js +517 -0
  41. package/dist/tools/index.d.ts +2 -0
  42. package/dist/tools/index.js +3 -0
  43. package/dist/tools/types.d.ts +32 -1
  44. package/package.json +2 -1
@@ -1,5 +1,6 @@
1
1
  import type { AgentDependencies, AgentState, CommandResult } from '../types.js';
2
2
  import type { WorkflowRegistry } from './registry.js';
3
+ import type { WorkflowEntry } from './types.js';
3
4
  /** executeWorkflow 的参数。 */
4
5
  type ExecuteWorkflowOpts = {
5
6
  registry: WorkflowRegistry;
@@ -7,6 +8,8 @@ type ExecuteWorkflowOpts = {
7
8
  args: string;
8
9
  deps: AgentDependencies;
9
10
  parent: AgentState;
11
+ /** 已解析的 entry(如项目级工作流),优先于 registry.get(name)。 */
12
+ entry?: WorkflowEntry;
10
13
  onProgress?: (message: string, detail?: unknown) => void;
11
14
  };
12
15
  /**
@@ -20,8 +20,8 @@ function createTimeoutPromise(timeoutMs, timeoutSeconds) {
20
20
  * 工作流 return 的 output 作为 text 返回;异常捕获为 error。
21
21
  */
22
22
  async function executeWorkflow(opts) {
23
- const { registry, name, args, deps, parent, onProgress } = opts;
24
- const entry = registry.get(name);
23
+ const { registry, name, args, deps, parent, onProgress, entry: resolvedEntry } = opts;
24
+ const entry = resolvedEntry ?? registry.get(name);
25
25
  if (!entry) {
26
26
  const available = registry
27
27
  .list()
@@ -5,4 +5,79 @@ export type ResolvedProject = {
5
5
  gitRemote: string | null;
6
6
  gitBranch: string | null;
7
7
  };
8
+ /** 文件 git 状态分类(用于文件树高亮)。按「最值得注意」优先级排列。 */
9
+ export type GitStatusCode = 'modified' | 'staged' | 'untracked' | 'conflict' | 'deleted' | 'ignored';
10
+ /** 目录聚合时的优先级(越大越优先展示)。ignored 最低:被忽略目录下若有真实变更仍显示变更态。 */
11
+ export declare const GIT_STATUS_PRIORITY: Record<GitStatusCode, number>;
12
+ export declare function getGitStatus(cwd: string): Record<string, GitStatusCode> | null;
13
+ /** 检查给定路径中哪些被 gitignore 规则覆盖。
14
+ *
15
+ * 用 git check-ignore 只查询传入的路径(不递归),返回被忽略路径的集合。
16
+ * 只检查当前展开目录的直接子项(通常 10-50 个),不递归进 node_modules 等。
17
+ * 非 git 仓库或无忽略文件时返回空集。
18
+ *
19
+ * 注意:git check-ignore 在「无路径被忽略」或「非 git 仓库」时退出码为 1,
20
+ * 这两种情况都返回空集。 */
21
+ export declare function checkIgnored(cwd: string, paths: string[]): Set<string>;
22
+ /** 追加条目到 .gitignore(去重,文件不存在则创建)。 */
23
+ export declare function appendToGitignore(cwd: string, patterns: string[]): void;
24
+ /**
25
+ * 取当前分支名(非 git 仓库返回 null)。
26
+ */
27
+ export declare function getGitBranch(cwd: string): string | null;
28
+ /** 最后一次提交摘要(供分支名 hover tooltip)。 */
29
+ export interface GitLastCommit {
30
+ subject: string;
31
+ hash: string;
32
+ author: string;
33
+ date: string;
34
+ }
35
+ /**
36
+ * 取最后一次提交信息(subject/hash/author/相对时间)。
37
+ * 非 git 仓库或无提交(如全新仓库)返回 null。
38
+ */
39
+ export declare function getGitLastCommit(cwd: string): GitLastCommit | null;
40
+ /**
41
+ * 取工作区变更摘要(供 LLM 生成 commit message)。
42
+ * 包含 staged + unstaged diff(相对 HEAD)和 untracked 文件名列表。
43
+ * 非 git 仓库或无变更返回 null。
44
+ */
45
+ export declare function getGitDiffSummary(cwd: string): {
46
+ diff: string;
47
+ fileCount: number;
48
+ } | null;
49
+ /**
50
+ * 执行 git add -A + git commit。成功返回 commit 短 hash,失败返回 error 信息。
51
+ */
52
+ export declare function performGitCommit(cwd: string, message: string): {
53
+ hash: string;
54
+ } | {
55
+ error: string;
56
+ };
57
+ /** 分支信息。 */
58
+ export interface GitBranchInfo {
59
+ name: string;
60
+ current: boolean;
61
+ lastSubject: string | null;
62
+ }
63
+ /**
64
+ * 列出本地分支及当前分支标记(非 git 仓库返回 null)。
65
+ */
66
+ export declare function listGitBranches(cwd: string): GitBranchInfo[] | null;
67
+ /**
68
+ * 切换到指定分支(git checkout)。成功返回分支名,失败返回 error。
69
+ */
70
+ export declare function checkoutGitBranch(cwd: string, branch: string): {
71
+ branch: string;
72
+ } | {
73
+ error: string;
74
+ };
75
+ /**
76
+ * 创建并切换到新分支。成功返回分支名,失败返回 error。
77
+ */
78
+ export declare function createGitBranch(cwd: string, name: string): {
79
+ branch: string;
80
+ } | {
81
+ error: string;
82
+ };
8
83
  export declare function resolveProject(directory: string): ResolvedProject;
@@ -1,7 +1,88 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { createHash } from 'node:crypto';
3
- import { existsSync, statSync } from 'node:fs';
3
+ import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
4
4
  import { join, resolve } from 'node:path';
5
+ /** 目录聚合时的优先级(越大越优先展示)。ignored 最低:被忽略目录下若有真实变更仍显示变更态。 */
6
+ export const GIT_STATUS_PRIORITY = {
7
+ conflict: 5,
8
+ untracked: 4,
9
+ modified: 3,
10
+ staged: 2,
11
+ deleted: 1,
12
+ ignored: 0,
13
+ };
14
+ /** porcelain XY → 单一分类。 */
15
+ function classifyStatus(xy) {
16
+ const x = xy[0];
17
+ const y = xy[1];
18
+ if (x === '!' && y === '!')
19
+ return 'ignored'; // git 忽略项
20
+ // 合并冲突:任意 U,或双方同时增/删
21
+ if (x === 'U' || y === 'U' || (x === 'A' && y === 'A') || (x === 'D' && y === 'D'))
22
+ return 'conflict';
23
+ if (x === '?' && y === '?')
24
+ return 'untracked';
25
+ if (x === 'D' || y === 'D')
26
+ return 'deleted';
27
+ if (x !== ' ' && x !== '?')
28
+ return 'staged'; // 已暂存(新增/修改/重命名/复制)
29
+ if (y !== ' ' && y !== '?')
30
+ return 'modified'; // 工作区变更未暂存
31
+ return 'modified';
32
+ }
33
+ /**
34
+ * 取工作区 git status(porcelain v1, NUL 分隔),返回 path → 状态分类 的映射。
35
+ * path 为相对 cwd 的 POSIX 路径;非 git 仓库或失败返回 null。
36
+ */
37
+ /** spawnSync 的 stdout/stderr maxBuffer 默认 1MB,大仓库可能超出。
38
+ * 10MB 足以覆盖大量 untracked 文件场景(此前 50MB 是为 --ignored 的 PGLite 数万文件预留,
39
+ * 去掉 --ignored 后不再需要)。 */
40
+ const GIT_MAX_BUFFER = 10 * 1024 * 1024; // 10MB
41
+ export function getGitStatus(cwd) {
42
+ let result;
43
+ try {
44
+ result = spawnSync('git', ['status', '--porcelain', '-z', '--untracked-files=all'], {
45
+ cwd,
46
+ encoding: 'utf-8',
47
+ stdio: ['ignore', 'pipe', 'ignore'],
48
+ maxBuffer: GIT_MAX_BUFFER,
49
+ });
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ if (result.error || result.status !== 0)
55
+ return null;
56
+ const raw = result.stdout ?? '';
57
+ const tokens = raw.split('\0');
58
+ const map = {};
59
+ let i = 0;
60
+ while (i < tokens.length) {
61
+ const token = tokens[i];
62
+ if (token === '')
63
+ break; // 末尾空 token
64
+ const xy = token.slice(0, 2);
65
+ const code = classifyStatus(xy);
66
+ const isRename = xy[0] === 'R' || xy[0] === 'C';
67
+ // 重命名/复制:"XY oldpath\0newpath",状态挂在 newpath
68
+ if (isRename && i + 1 < tokens.length) {
69
+ const newPath = tokens[i + 1];
70
+ map[normalizePath(newPath)] = code;
71
+ i += 2;
72
+ }
73
+ else {
74
+ const rest = token.slice(3); // 跳过 "XY "
75
+ if (rest)
76
+ map[normalizePath(rest)] = code;
77
+ i += 1;
78
+ }
79
+ }
80
+ return map;
81
+ }
82
+ /** 路径统一为 POSIX 相对(保留目录尾斜杠由调用方无需)。 */
83
+ function normalizePath(p) {
84
+ return p.replace(/\\/g, '/');
85
+ }
5
86
  /** 运行 git 命令,失败返回空字符串(不抛错)。 */
6
87
  function git(args, cwd) {
7
88
  try {
@@ -9,6 +90,7 @@ function git(args, cwd) {
9
90
  cwd,
10
91
  encoding: 'utf-8',
11
92
  stdio: ['ignore', 'pipe', 'ignore'],
93
+ maxBuffer: GIT_MAX_BUFFER,
12
94
  });
13
95
  if (result.status !== 0 || result.error)
14
96
  return '';
@@ -18,6 +100,53 @@ function git(args, cwd) {
18
100
  return '';
19
101
  }
20
102
  }
103
+ /** 检查给定路径中哪些被 gitignore 规则覆盖。
104
+ *
105
+ * 用 git check-ignore 只查询传入的路径(不递归),返回被忽略路径的集合。
106
+ * 只检查当前展开目录的直接子项(通常 10-50 个),不递归进 node_modules 等。
107
+ * 非 git 仓库或无忽略文件时返回空集。
108
+ *
109
+ * 注意:git check-ignore 在「无路径被忽略」或「非 git 仓库」时退出码为 1,
110
+ * 这两种情况都返回空集。 */
111
+ export function checkIgnored(cwd, paths) {
112
+ if (paths.length === 0)
113
+ return new Set();
114
+ try {
115
+ const result = spawnSync('git', ['check-ignore', ...paths], {
116
+ cwd,
117
+ encoding: 'utf-8',
118
+ stdio: ['ignore', 'pipe', 'ignore'],
119
+ maxBuffer: GIT_MAX_BUFFER,
120
+ });
121
+ if (result.status !== 0 || result.error)
122
+ return new Set();
123
+ const raw = result.stdout ?? '';
124
+ return new Set(raw.split('\n').filter(Boolean).map(normalizePath));
125
+ }
126
+ catch {
127
+ return new Set();
128
+ }
129
+ }
130
+ /** 追加条目到 .gitignore(去重,文件不存在则创建)。 */
131
+ export function appendToGitignore(cwd, patterns) {
132
+ const gitignorePath = join(cwd, '.gitignore');
133
+ let existing = '';
134
+ try {
135
+ existing = readFileSync(gitignorePath, 'utf-8');
136
+ }
137
+ catch {
138
+ // 文件不存在,视为空
139
+ }
140
+ const existingLines = new Set(existing
141
+ .split('\n')
142
+ .map((l) => l.trim())
143
+ .filter(Boolean));
144
+ const toAppend = patterns.map((p) => p.trim()).filter((p) => p && !existingLines.has(p));
145
+ if (toAppend.length === 0)
146
+ return;
147
+ const prefix = existing && !existing.endsWith('\n') ? '\n' : '';
148
+ writeFileSync(gitignorePath, `${existing}${prefix}${toAppend.join('\n')}\n`);
149
+ }
21
150
  /** 从 directory 向上查找 .git,返回仓库根;非 git 返回 null。 */
22
151
  function findGitRoot(directory) {
23
152
  let current = resolve(directory);
@@ -51,6 +180,129 @@ function firstRemoteUrl(cwd) {
51
180
  return '';
52
181
  return git(['remote', 'get-url', first], cwd);
53
182
  }
183
+ /**
184
+ * 取当前分支名(非 git 仓库返回 null)。
185
+ */
186
+ export function getGitBranch(cwd) {
187
+ const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
188
+ return branch || null;
189
+ }
190
+ /**
191
+ * 取最后一次提交信息(subject/hash/author/相对时间)。
192
+ * 非 git 仓库或无提交(如全新仓库)返回 null。
193
+ */
194
+ export function getGitLastCommit(cwd) {
195
+ const line = git(['log', '-1', '--format=%s%x1f%h%x1f%an%x1f%cr'], cwd);
196
+ if (!line)
197
+ return null;
198
+ const [subject, hash, author, date] = line.split('\x1f');
199
+ if (!subject)
200
+ return null;
201
+ return {
202
+ subject: subject.trim(),
203
+ hash: (hash ?? '').trim(),
204
+ author: (author ?? '').trim(),
205
+ date: (date ?? '').trim(),
206
+ };
207
+ }
208
+ /**
209
+ * 取工作区变更摘要(供 LLM 生成 commit message)。
210
+ * 包含 staged + unstaged diff(相对 HEAD)和 untracked 文件名列表。
211
+ * 非 git 仓库或无变更返回 null。
212
+ */
213
+ export function getGitDiffSummary(cwd) {
214
+ const diff = git(['diff', 'HEAD'], cwd);
215
+ const untracked = git(['ls-files', '--others', '--exclude-standard'], cwd);
216
+ const parts = [];
217
+ if (diff)
218
+ parts.push(diff);
219
+ if (untracked) {
220
+ const files = untracked
221
+ .split('\n')
222
+ .map((s) => s.trim())
223
+ .filter(Boolean);
224
+ if (files.length > 0) {
225
+ parts.push(`Untracked files:\n${files.map((f) => ` ${f}`).join('\n')}`);
226
+ }
227
+ }
228
+ const combined = parts.join('\n\n');
229
+ if (!combined.trim())
230
+ return null;
231
+ const status = getGitStatus(cwd);
232
+ const fileCount = status ? Object.values(status).filter((c) => c !== 'ignored').length : 0;
233
+ return { diff: combined, fileCount };
234
+ }
235
+ /**
236
+ * 执行 git add -A + git commit。成功返回 commit 短 hash,失败返回 error 信息。
237
+ */
238
+ export function performGitCommit(cwd, message) {
239
+ git(['add', '-A'], cwd);
240
+ const result = spawnSync('git', ['commit', '-m', message], {
241
+ cwd,
242
+ encoding: 'utf-8',
243
+ stdio: ['ignore', 'pipe', 'pipe'],
244
+ maxBuffer: GIT_MAX_BUFFER,
245
+ });
246
+ if (result.status !== 0) {
247
+ const stderr = (result.stderr ?? '').trim();
248
+ return { error: stderr || `git commit failed (exit ${result.status})` };
249
+ }
250
+ const hash = git(['rev-parse', '--short', 'HEAD'], cwd);
251
+ return { hash: hash || 'unknown' };
252
+ }
253
+ /**
254
+ * 列出本地分支及当前分支标记(非 git 仓库返回 null)。
255
+ */
256
+ export function listGitBranches(cwd) {
257
+ // --format 自定义输出:每行 `current<TAB>refname<TAB>subject`
258
+ const raw = git(['branch', '--format=%(HEAD)%09%(refname:short)%09%(contents:subject)'], cwd);
259
+ if (!raw)
260
+ return null;
261
+ return raw
262
+ .split('\n')
263
+ .filter((l) => l.trim())
264
+ .map((line) => {
265
+ const [head, name, subject] = line.split('\t');
266
+ return {
267
+ name: (name ?? '').trim(),
268
+ current: (head ?? '').trim() === '*',
269
+ lastSubject: (subject ?? '').trim() || null,
270
+ };
271
+ })
272
+ .filter((b) => b.name);
273
+ }
274
+ /**
275
+ * 切换到指定分支(git checkout)。成功返回分支名,失败返回 error。
276
+ */
277
+ export function checkoutGitBranch(cwd, branch) {
278
+ const result = spawnSync('git', ['checkout', branch], {
279
+ cwd,
280
+ encoding: 'utf-8',
281
+ stdio: ['ignore', 'pipe', 'pipe'],
282
+ maxBuffer: GIT_MAX_BUFFER,
283
+ });
284
+ if (result.status !== 0) {
285
+ const stderr = (result.stderr ?? '').trim();
286
+ return { error: stderr || `git checkout failed (exit ${result.status})` };
287
+ }
288
+ return { branch };
289
+ }
290
+ /**
291
+ * 创建并切换到新分支。成功返回分支名,失败返回 error。
292
+ */
293
+ export function createGitBranch(cwd, name) {
294
+ const result = spawnSync('git', ['checkout', '-b', name], {
295
+ cwd,
296
+ encoding: 'utf-8',
297
+ stdio: ['ignore', 'pipe', 'pipe'],
298
+ maxBuffer: GIT_MAX_BUFFER,
299
+ });
300
+ if (result.status !== 0) {
301
+ const stderr = (result.stderr ?? '').trim();
302
+ return { error: stderr || `git checkout -b failed (exit ${result.status})` };
303
+ }
304
+ return { branch: name };
305
+ }
54
306
  function hash16(input) {
55
307
  return createHash('sha256').update(input).digest('hex').slice(0, 16);
56
308
  }
@@ -19,6 +19,7 @@ import { createProjectRoute } from './routes/project.js';
19
19
  import { createProviderRoute } from './routes/provider.js';
20
20
  import { createSessionRoute } from './routes/session.js';
21
21
  import { createTerminalRoute } from './routes/terminal.js';
22
+ import { createTodoRoute } from './routes/todo.js';
22
23
  import { createToolRoute } from './routes/tool.js';
23
24
  import { createUpdateRoute } from './routes/update.js';
24
25
  import { createWorkflowsRoute } from './routes/workflows.js';
@@ -42,6 +43,7 @@ function createApp(ctx) {
42
43
  app.route('/api/chat', createChatRoute(ctx));
43
44
  app.route('/api/commands', createCommandsRoute(ctx));
44
45
  app.route('/api/tools', createToolRoute(ctx));
46
+ app.route('/api/todo', createTodoRoute(ctx));
45
47
  app.route('/api/update', createUpdateRoute(ctx));
46
48
  app.route('/api/config', createConfigRoute(ctx));
47
49
  app.route('/api/permissions', createPermissionsRoute(ctx));
@@ -63,6 +65,7 @@ function createApp(ctx) {
63
65
  '/api/chat',
64
66
  '/api/commands',
65
67
  '/api/tools',
68
+ '/api/todo',
66
69
  '/api/update',
67
70
  '/api/config',
68
71
  '/api/permissions',
@@ -22,6 +22,7 @@ function createServerContext(opts) {
22
22
  reg.register(def);
23
23
  return reg;
24
24
  })();
25
+ // 工作流注册表:测试工厂只含内置(项目级 discovery 由 buildServerContext 负责)。
25
26
  let _workflowRegistry;
26
27
  return {
27
28
  db: opts.db,
@@ -35,7 +36,7 @@ function createServerContext(opts) {
35
36
  permissionStore: createPermissionStore(),
36
37
  permissionMode: config.permission.defaultMode,
37
38
  agentRegistry,
38
- // 工作流注册表:惰性初始化,只含内置(项目级 discovery 由 bootstrap 或 API 触发热加载)。
39
+ // 工作流注册表:惰性初始化,只含内置(测试工厂;生产路径走 buildServerContext)。
39
40
  get workflowRegistry() {
40
41
  if (!_workflowRegistry) {
41
42
  _workflowRegistry = createWorkflowRegistry();
@@ -1,7 +1,7 @@
1
1
  // src/server/dev.ts
2
2
  import { Readable } from 'node:stream';
3
3
  import { createApp } from './app.js';
4
- import { buildServerContext, createDevDb } from './server.js';
4
+ import { buildServerContext, createDevDb, releaseDevDbLock, resolveDbDir } from './server.js';
5
5
  /**
6
6
  * 开发环境入口:初始化并返回 Hono app。
7
7
  * 供 vite dev server 中间件复用,使前后端共享同一端口。
@@ -88,6 +88,8 @@ async function closeDevApp() {
88
88
  await db.close();
89
89
  delete g[DEV_DB_KEY];
90
90
  }
91
+ // Release cross-process lock so the next dev server can start cleanly.
92
+ releaseDevDbLock(resolveDbDir());
91
93
  }
92
94
  /**
93
95
  * 把 Node 请求桥接到 Hono app 并流式回写响应(支持 SSE)。
@@ -131,6 +131,7 @@ function createChatRoute(ctx) {
131
131
  error: { _tag: 'unexpected', message: String(e) },
132
132
  }),
133
133
  });
134
+ await stream.writeSSE({ event: 'done', data: JSON.stringify({ _tag: 'done' }) });
134
135
  }
135
136
  });
136
137
  }
@@ -274,8 +275,14 @@ function createChatRoute(ctx) {
274
275
  stream.onAbort(() => {
275
276
  ctx.agentManager.abort(sessionId);
276
277
  });
278
+ // 跟踪 done 事件是否已发送:agentLoop 正常完成时 yield done;
279
+ // 但 error/abort/max_turns 等路径不 yield done,需在 finally 补发。
280
+ // 否则前端 isStreaming 永远不会变 false,按钮卡在「终止」态。
281
+ let doneSent = false;
277
282
  try {
278
283
  for await (const event of runAgent(state, userContent, deps)) {
284
+ if (event._tag === 'done')
285
+ doneSent = true;
279
286
  await stream.writeSSE({
280
287
  event: event._tag,
281
288
  data: JSON.stringify(event),
@@ -300,6 +307,11 @@ function createChatRoute(ctx) {
300
307
  });
301
308
  }
302
309
  finally {
310
+ // agentLoop 的 error/abort/max_turns 路径不 yield done,在此补发。
311
+ // 正常完成路径已在循环中 yield done,doneSent=true 时跳过避免重复。
312
+ if (!doneSent) {
313
+ await stream.writeSSE({ event: 'done', data: JSON.stringify({ _tag: 'done' }) });
314
+ }
303
315
  // 无论正常完成、错误还是 abort,只要服务还活着就标记 completed。
304
316
  // 只有服务崩溃/重启才会留下 status='running' → 下次加载检测为 interrupted。
305
317
  await updateSessionLastRun(ctx.db, sessionId, {
@@ -9,6 +9,7 @@ function createCommandsRoute(_ctx) {
9
9
  name: cmd.name,
10
10
  description: cmd.description,
11
11
  ...(cmd.argsHint ? { argsHint: cmd.argsHint } : {}),
12
+ ...(cmd.subcommands ? { subcommands: cmd.subcommands } : {}),
12
13
  }));
13
14
  return c.json({ commands });
14
15
  });