c0de-agent 1.5.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.
- package/dist/core/agent.js +4 -0
- package/dist/core/config.js +1 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/core/loop.js +9 -0
- package/dist/core/prompt-registry.d.ts +2 -2
- package/dist/core/prompt-registry.js +42 -3
- package/dist/core/slash.js +40 -6
- package/dist/core/types.d.ts +10 -1
- package/dist/core/workflow.d.ts +1 -1
- package/dist/core/workflow.js +54 -6
- package/dist/core/workflows/runtime.d.ts +3 -0
- package/dist/core/workflows/runtime.js +2 -2
- package/dist/project/resolve.d.ts +75 -0
- package/dist/project/resolve.js +253 -1
- package/dist/server/app.js +3 -0
- package/dist/server/context.js +2 -1
- package/dist/server/dev.js +3 -1
- package/dist/server/routes/chat.js +12 -0
- package/dist/server/routes/commands.js +1 -0
- package/dist/server/routes/files.js +252 -4
- package/dist/server/routes/terminal.js +2 -1
- package/dist/server/routes/todo.d.ts +4 -0
- package/dist/server/routes/todo.js +107 -0
- package/dist/server/routes/workflows.js +51 -11
- package/dist/server/server.d.ts +8 -1
- package/dist/server/server.js +113 -23
- package/dist/server/terminal/pty-manager.d.ts +14 -0
- package/dist/server/terminal/pty-manager.js +105 -7
- package/dist/shared/types/agent.d.ts +9 -0
- package/dist/shared/types/config.d.ts +6 -0
- package/dist/shared/types/tool.d.ts +13 -0
- package/dist/tools/builtin/todo.d.ts +67 -0
- package/dist/tools/builtin/todo.js +517 -0
- package/dist/tools/index.d.ts +2 -0
- package/dist/tools/index.js +3 -0
- package/dist/tools/types.d.ts +32 -1
- package/package.json +2 -1
package/dist/project/resolve.js
CHANGED
|
@@ -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
|
}
|
package/dist/server/app.js
CHANGED
|
@@ -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',
|
package/dist/server/context.js
CHANGED
|
@@ -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
|
-
//
|
|
39
|
+
// 工作流注册表:惰性初始化,只含内置(测试工厂;生产路径走 buildServerContext)。
|
|
39
40
|
get workflowRegistry() {
|
|
40
41
|
if (!_workflowRegistry) {
|
|
41
42
|
_workflowRegistry = createWorkflowRegistry();
|
package/dist/server/dev.js
CHANGED
|
@@ -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, {
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
1
|
+
import { access, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { dirname, join, relative } from 'node:path';
|
|
3
3
|
import { Hono } from 'hono';
|
|
4
|
+
import trash from 'trash';
|
|
5
|
+
import { createSummarizer } from '../../core/compact.js';
|
|
4
6
|
import { getProject } from '../../project/project.js';
|
|
7
|
+
import { appendToGitignore, checkIgnored, checkoutGitBranch, createGitBranch, getGitBranch, getGitDiffSummary, getGitLastCommit, getGitStatus, listGitBranches, performGitCommit, } from '../../project/resolve.js';
|
|
5
8
|
import { apiError } from '../middleware/error.js';
|
|
6
9
|
import { safeResolve } from '../util/safe-path.js';
|
|
10
|
+
/** 递归搜索时跳过的目录(体积大/为元数据噪音,避免递归进入)。 */
|
|
11
|
+
const SEARCH_SKIP_DIRS = new Set(['.git', 'node_modules']);
|
|
7
12
|
/** 递归收集文件列表(用于搜索)。 */
|
|
8
13
|
async function collectFiles(dir, basePath, maxDepth = 5) {
|
|
9
14
|
if (maxDepth < 0)
|
|
@@ -17,7 +22,7 @@ async function collectFiles(dir, basePath, maxDepth = 5) {
|
|
|
17
22
|
return [];
|
|
18
23
|
}
|
|
19
24
|
for (const entry of entries) {
|
|
20
|
-
if (entry.
|
|
25
|
+
if (entry.isDirectory() && SEARCH_SKIP_DIRS.has(entry.name))
|
|
21
26
|
continue;
|
|
22
27
|
const fullPath = join(dir, entry.name);
|
|
23
28
|
const relPath = relative(basePath, fullPath);
|
|
@@ -60,6 +65,211 @@ function contentTypeFor(name) {
|
|
|
60
65
|
}
|
|
61
66
|
function createFilesRoute(ctx) {
|
|
62
67
|
const app = new Hono();
|
|
68
|
+
// git 状态:返回 path → 状态分类 的映射(非 git 返回空对象)
|
|
69
|
+
app.get('/git-status', async (c) => {
|
|
70
|
+
const projectId = c.req.query('projectId');
|
|
71
|
+
let root = ctx.cwd;
|
|
72
|
+
if (projectId) {
|
|
73
|
+
const project = await getProject(ctx.db, projectId);
|
|
74
|
+
if (!project) {
|
|
75
|
+
return apiError(c, 404, 'NOT_FOUND', 'Project not found');
|
|
76
|
+
}
|
|
77
|
+
root = project.worktree;
|
|
78
|
+
}
|
|
79
|
+
return c.json(getGitStatus(root) ?? {});
|
|
80
|
+
});
|
|
81
|
+
// 当前分支名(非 git 仓库返回 null)
|
|
82
|
+
app.get('/git-branch', async (c) => {
|
|
83
|
+
const projectId = c.req.query('projectId');
|
|
84
|
+
let root = ctx.cwd;
|
|
85
|
+
if (projectId) {
|
|
86
|
+
const project = await getProject(ctx.db, projectId);
|
|
87
|
+
if (!project) {
|
|
88
|
+
return apiError(c, 404, 'NOT_FOUND', 'Project not found');
|
|
89
|
+
}
|
|
90
|
+
root = project.worktree;
|
|
91
|
+
}
|
|
92
|
+
return c.json({ branch: getGitBranch(root) });
|
|
93
|
+
});
|
|
94
|
+
// 最后一次提交信息(供分支名 hover tooltip)。非 git 仓库或无提交返回 commit null。
|
|
95
|
+
app.get('/git-last-commit', async (c) => {
|
|
96
|
+
const projectId = c.req.query('projectId');
|
|
97
|
+
let root = ctx.cwd;
|
|
98
|
+
if (projectId) {
|
|
99
|
+
const project = await getProject(ctx.db, projectId);
|
|
100
|
+
if (!project) {
|
|
101
|
+
return apiError(c, 404, 'NOT_FOUND', 'Project not found');
|
|
102
|
+
}
|
|
103
|
+
root = project.worktree;
|
|
104
|
+
}
|
|
105
|
+
return c.json({ commit: getGitLastCommit(root) });
|
|
106
|
+
});
|
|
107
|
+
// 一键提交:用 LLM 生成 commit message + 检查可疑文件,支持 force/append-ignore 模式
|
|
108
|
+
app.post('/git-commit', async (c) => {
|
|
109
|
+
const projectId = c.req.query('projectId');
|
|
110
|
+
let root = ctx.cwd;
|
|
111
|
+
if (projectId) {
|
|
112
|
+
const project = await getProject(ctx.db, projectId);
|
|
113
|
+
if (!project) {
|
|
114
|
+
return apiError(c, 404, 'NOT_FOUND', 'Project not found');
|
|
115
|
+
}
|
|
116
|
+
root = project.worktree;
|
|
117
|
+
}
|
|
118
|
+
const summary = getGitDiffSummary(root);
|
|
119
|
+
if (!summary) {
|
|
120
|
+
return apiError(c, 400, 'NO_CHANGES', 'No changes to commit');
|
|
121
|
+
}
|
|
122
|
+
// 可选 body:mode / message / suggestions
|
|
123
|
+
const body = await c.req
|
|
124
|
+
.json()
|
|
125
|
+
.catch(() => ({}));
|
|
126
|
+
// --- mode: force — 跳过检查,用传入 message 直接提交 ---
|
|
127
|
+
if (body.mode === 'force') {
|
|
128
|
+
if (!body.message) {
|
|
129
|
+
return apiError(c, 400, 'MISSING_MESSAGE', 'mode=force requires a message');
|
|
130
|
+
}
|
|
131
|
+
const result = performGitCommit(root, body.message);
|
|
132
|
+
if ('error' in result) {
|
|
133
|
+
return apiError(c, 500, 'COMMIT_FAILED', result.error);
|
|
134
|
+
}
|
|
135
|
+
return c.json({
|
|
136
|
+
committed: true,
|
|
137
|
+
message: body.message,
|
|
138
|
+
hash: result.hash,
|
|
139
|
+
fileCount: summary.fileCount,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
// --- mode: append-ignore — 追加 .gitignore 后提交 ---
|
|
143
|
+
if (body.mode === 'append-ignore') {
|
|
144
|
+
if (!body.message) {
|
|
145
|
+
return apiError(c, 400, 'MISSING_MESSAGE', 'mode=append-ignore requires a message');
|
|
146
|
+
}
|
|
147
|
+
if (!body.suggestions || body.suggestions.length === 0) {
|
|
148
|
+
return apiError(c, 400, 'MISSING_SUGGESTIONS', 'mode=append-ignore requires suggestions');
|
|
149
|
+
}
|
|
150
|
+
appendToGitignore(root, body.suggestions);
|
|
151
|
+
const result = performGitCommit(root, body.message);
|
|
152
|
+
if ('error' in result) {
|
|
153
|
+
return apiError(c, 500, 'COMMIT_FAILED', result.error);
|
|
154
|
+
}
|
|
155
|
+
return c.json({
|
|
156
|
+
committed: true,
|
|
157
|
+
message: body.message,
|
|
158
|
+
hash: result.hash,
|
|
159
|
+
fileCount: summary.fileCount,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
// --- 默认模式:LLM 生成 message + 检查可疑文件 ---
|
|
163
|
+
const cm = ctx.config.commitModel;
|
|
164
|
+
const provider = cm?.provider ?? ctx.config.defaultProvider;
|
|
165
|
+
const model = cm?.model ?? ctx.config.defaultModel;
|
|
166
|
+
const prompt = `Based on the following git diff, generate a concise commit message in conventional-commits format (e.g. "feat: add login page").
|
|
167
|
+
|
|
168
|
+
ALSO review the changed/new files: are any of them files that SHOULD be in .gitignore but are currently missing? (e.g. secrets, .env, build output, dependencies, temp files, large binaries)
|
|
169
|
+
|
|
170
|
+
Reply as JSON ONLY:
|
|
171
|
+
{"message": "<commit message>", "ignoreSuggestions": ["<path>", ...]}
|
|
172
|
+
|
|
173
|
+
If no files need ignoring, return an empty array for ignoreSuggestions.
|
|
174
|
+
|
|
175
|
+
${summary.diff.slice(0, 8000)}`;
|
|
176
|
+
let raw;
|
|
177
|
+
try {
|
|
178
|
+
const summarizer = createSummarizer(ctx.llmRegistry, provider, model, { maxTokens: 400 });
|
|
179
|
+
raw = (await summarizer(prompt)).trim();
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
return apiError(c, 502, 'LLM_ERROR', `Failed to generate commit message: ${String(err)}`);
|
|
183
|
+
}
|
|
184
|
+
// LLM 返回可能含 markdown 代码块包裹,去掉
|
|
185
|
+
raw = raw
|
|
186
|
+
.replace(/^```[a-z]*\n?/m, '')
|
|
187
|
+
.replace(/\n?```$/m, '')
|
|
188
|
+
.trim();
|
|
189
|
+
// JSON 解析(fail-closed:无法解析 → 报错阻断,不提交)
|
|
190
|
+
let parsed;
|
|
191
|
+
try {
|
|
192
|
+
parsed = JSON.parse(raw);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return apiError(c, 502, 'CHECK_PARSE_ERROR', 'Commit ignore check failed: LLM returned unparseable response');
|
|
196
|
+
}
|
|
197
|
+
const message = (parsed.message ?? '').trim();
|
|
198
|
+
if (!message) {
|
|
199
|
+
return apiError(c, 502, 'EMPTY_MESSAGE', 'LLM returned empty commit message');
|
|
200
|
+
}
|
|
201
|
+
const suggestions = Array.isArray(parsed.ignoreSuggestions) ? parsed.ignoreSuggestions : [];
|
|
202
|
+
// LLM 检测到可疑文件 → 阻断提交,返回供前端审查
|
|
203
|
+
if (suggestions.length > 0) {
|
|
204
|
+
return c.json({ needsReview: true, message, suggestions });
|
|
205
|
+
}
|
|
206
|
+
// 无可疑文件 → 直接提交
|
|
207
|
+
const result = performGitCommit(root, message);
|
|
208
|
+
if ('error' in result) {
|
|
209
|
+
return apiError(c, 500, 'COMMIT_FAILED', result.error);
|
|
210
|
+
}
|
|
211
|
+
return c.json({
|
|
212
|
+
committed: true,
|
|
213
|
+
message,
|
|
214
|
+
hash: result.hash,
|
|
215
|
+
fileCount: summary.fileCount,
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
// 列出本地分支(非 git 仓库返回空数组)
|
|
219
|
+
app.get('/git-branches', async (c) => {
|
|
220
|
+
const projectId = c.req.query('projectId');
|
|
221
|
+
let root = ctx.cwd;
|
|
222
|
+
if (projectId) {
|
|
223
|
+
const project = await getProject(ctx.db, projectId);
|
|
224
|
+
if (!project) {
|
|
225
|
+
return apiError(c, 404, 'NOT_FOUND', 'Project not found');
|
|
226
|
+
}
|
|
227
|
+
root = project.worktree;
|
|
228
|
+
}
|
|
229
|
+
return c.json({ branches: listGitBranches(root) ?? [] });
|
|
230
|
+
});
|
|
231
|
+
// 切换分支(git checkout)
|
|
232
|
+
app.post('/git-checkout', async (c) => {
|
|
233
|
+
const projectId = c.req.query('projectId');
|
|
234
|
+
let root = ctx.cwd;
|
|
235
|
+
if (projectId) {
|
|
236
|
+
const project = await getProject(ctx.db, projectId);
|
|
237
|
+
if (!project) {
|
|
238
|
+
return apiError(c, 404, 'NOT_FOUND', 'Project not found');
|
|
239
|
+
}
|
|
240
|
+
root = project.worktree;
|
|
241
|
+
}
|
|
242
|
+
const body = await c.req.json().catch(() => ({}));
|
|
243
|
+
const branch = body.branch;
|
|
244
|
+
if (!branch)
|
|
245
|
+
return apiError(c, 400, 'BAD_REQUEST', 'branch is required');
|
|
246
|
+
const result = checkoutGitBranch(root, branch);
|
|
247
|
+
if ('error' in result) {
|
|
248
|
+
return apiError(c, 500, 'CHECKOUT_FAILED', result.error);
|
|
249
|
+
}
|
|
250
|
+
return c.json({ branch: result.branch });
|
|
251
|
+
});
|
|
252
|
+
// 创建并切换到新分支(git checkout -b)
|
|
253
|
+
app.post('/git-branch-create', async (c) => {
|
|
254
|
+
const projectId = c.req.query('projectId');
|
|
255
|
+
let root = ctx.cwd;
|
|
256
|
+
if (projectId) {
|
|
257
|
+
const project = await getProject(ctx.db, projectId);
|
|
258
|
+
if (!project) {
|
|
259
|
+
return apiError(c, 404, 'NOT_FOUND', 'Project not found');
|
|
260
|
+
}
|
|
261
|
+
root = project.worktree;
|
|
262
|
+
}
|
|
263
|
+
const body = await c.req.json().catch(() => ({}));
|
|
264
|
+
const name = body.name;
|
|
265
|
+
if (!name)
|
|
266
|
+
return apiError(c, 400, 'BAD_REQUEST', 'name is required');
|
|
267
|
+
const result = createGitBranch(root, name);
|
|
268
|
+
if ('error' in result) {
|
|
269
|
+
return apiError(c, 500, 'BRANCH_CREATE_FAILED', result.error);
|
|
270
|
+
}
|
|
271
|
+
return c.json({ branch: result.branch });
|
|
272
|
+
});
|
|
63
273
|
// 列出目录
|
|
64
274
|
// projectId 指定时按对应项目 worktree 列出,否则回退 ctx.cwd(向后兼容)。
|
|
65
275
|
app.get('/', async (c) => {
|
|
@@ -79,8 +289,7 @@ function createFilesRoute(ctx) {
|
|
|
79
289
|
}
|
|
80
290
|
try {
|
|
81
291
|
const entries = await readdir(resolved, { withFileTypes: true });
|
|
82
|
-
const
|
|
83
|
-
.filter((e) => !e.name.startsWith('.'))
|
|
292
|
+
const sorted = entries
|
|
84
293
|
.map((e) => ({
|
|
85
294
|
name: e.name,
|
|
86
295
|
type: (e.isDirectory() ? 'directory' : 'file'),
|
|
@@ -90,6 +299,14 @@ function createFilesRoute(ctx) {
|
|
|
90
299
|
return a.type === 'directory' ? -1 : 1;
|
|
91
300
|
return a.name.localeCompare(b.name);
|
|
92
301
|
});
|
|
302
|
+
// git check-ignore:只检查当前目录直接子项,标记被忽略的文件/目录(灰显用)
|
|
303
|
+
const prefix = queryPath === '.' ? '' : `${queryPath}/`;
|
|
304
|
+
const checkPaths = sorted.map((e) => `${prefix}${e.name}`);
|
|
305
|
+
const ignoredSet = checkIgnored(root, checkPaths);
|
|
306
|
+
const result = sorted.map((e) => ({
|
|
307
|
+
...e,
|
|
308
|
+
...(ignoredSet.has(`${prefix}${e.name}`) ? { ignored: true } : {}),
|
|
309
|
+
}));
|
|
93
310
|
return c.json(result);
|
|
94
311
|
}
|
|
95
312
|
catch {
|
|
@@ -178,6 +395,37 @@ function createFilesRoute(ctx) {
|
|
|
178
395
|
return apiError(c, 500, 'WRITE_ERROR', `Failed to write file: ${String(err)}`);
|
|
179
396
|
}
|
|
180
397
|
});
|
|
398
|
+
// 删除文件/目录(移入系统回收站)
|
|
399
|
+
// projectId 指定时按对应项目 worktree 解析,否则回退 ctx.cwd(向后兼容)。
|
|
400
|
+
app.delete('/*', async (c) => {
|
|
401
|
+
const path = c.req.path.replace(/^\/api\/files\//, '').replace(/^\//, '');
|
|
402
|
+
const projectId = c.req.query('projectId');
|
|
403
|
+
let root = ctx.cwd;
|
|
404
|
+
if (projectId) {
|
|
405
|
+
const project = await getProject(ctx.db, projectId);
|
|
406
|
+
if (!project) {
|
|
407
|
+
return apiError(c, 404, 'NOT_FOUND', 'Project not found');
|
|
408
|
+
}
|
|
409
|
+
root = project.worktree;
|
|
410
|
+
}
|
|
411
|
+
const resolved = safeResolve(root, path);
|
|
412
|
+
if (!resolved) {
|
|
413
|
+
return apiError(c, 403, 'FORBIDDEN', 'Path outside workspace');
|
|
414
|
+
}
|
|
415
|
+
try {
|
|
416
|
+
await access(resolved);
|
|
417
|
+
}
|
|
418
|
+
catch {
|
|
419
|
+
return apiError(c, 404, 'NOT_FOUND', 'File not found');
|
|
420
|
+
}
|
|
421
|
+
try {
|
|
422
|
+
await trash(resolved);
|
|
423
|
+
return c.json({ path, trashed: true });
|
|
424
|
+
}
|
|
425
|
+
catch (err) {
|
|
426
|
+
return apiError(c, 500, 'DELETE_ERROR', `Failed to delete file: ${String(err)}`);
|
|
427
|
+
}
|
|
428
|
+
});
|
|
181
429
|
return app;
|
|
182
430
|
}
|
|
183
431
|
export { createFilesRoute };
|