[优化建议] OpenHorse v0.1.10 综合代码体检报告
基于 main 分支最新提交 98a386f(v0.1.10)做的静态代码体检报告。
共识别 20 项优化点,按【严重度 × 模块】整理,每项均附 file:line 定位、问题描述、风险说明与可落地的修复代码示例。
目的:帮助维护者在 v0.1.11 / v0.2 周期内分流到独立 PR 实现。本 issue 仅作建议,欢迎指正与拒绝。
目录
一、问题分级总览
| # |
模块 |
简述 |
严重度 |
类型 |
| 2.1 |
core/brain |
submitTask 触发 fire-and-forget dispatch |
Critical |
稳定性 |
| 2.2 |
framework |
query 工具执行无 try/catch,单次工具异常即崩溃整个对话 |
High |
稳定性 |
| 2.3 |
harness |
catch (error: any) 假定 error.message 存在 |
High |
稳定性 |
| 2.4 |
core/agent |
params / TaskResult.data / triggerSkill 大量 any |
High |
类型安全 |
| 3.1 |
harness/safety |
auditLog 无上限,长会话内存泄漏 |
Medium |
性能 |
| 3.2 |
framework |
工具执行未透传 abortSignal,用户取消后仍跑 |
Medium |
UX/稳定 |
| 3.3 |
memory/vector |
upsert 删旧向量后插入,失败留下孤儿记录 |
Medium |
数据一致 |
| 3.4 |
memory/vector |
hashProject 仅取末两段路径,跨用户同名项目冲突 |
Medium |
数据正确 |
| 3.5 |
memory/embed |
embedBatch 无 AbortSignal,单次慢调用阻塞整批 |
Medium |
性能 |
| 3.6 |
services/mcp |
SSE/WebSocket 重连固定 3s 间隔,无指数退避 |
Medium |
鲁棒性 |
| 3.7 |
tools/web |
web_fetch 无 SSRF 拦截 + 无 Content-Length 上限 |
Medium |
安全 |
| 3.8 |
memory/storage |
searchMemories 与 getMemoriesByType 每次全量读盘 |
Medium |
性能 |
| 3.9 |
tools/index |
exec_command stdout/stderr 截断逻辑不一致 |
Medium |
正确性 |
| 3.10 |
ui/cmd-panel |
\x1b[J 清屏过激,会清除面板下方非自身内容 |
Medium |
UX |
| 3.11 |
ui |
无 SIGWINCH 监听 + chalk 颜色未走 NO_COLOR 通道 |
Medium |
UX/无障碍 |
| 4.1 |
repo |
缺 CONTRIBUTING.md、issue/PR 模板、.eslintrc |
Low |
社区基建 |
| 4.2 |
package.json |
react / react-compiler-runtime / react-reconciler / bidi-js 已声明但未使用 |
Low |
依赖卫生 |
| 4.3 |
tests |
91 个 TS 源文件对 26 个测试文件,UI 层与异常路径覆盖缺失 |
Low |
测试 |
| 4.4 |
tools/index |
单文件 1132 行,13+ 工具内联,缺乏参数校验抽象 |
Low |
可维护 |
| 4.5 |
memory/storage |
MEMORY.md 超过 25KB 后静默丢条目 |
Low |
UX |
二、Critical / High(强烈建议优先合入)
2.1 [Critical] Brain.submitTask 触发 fire-and-forget dispatch
-
位置:src/core/brain.ts:36-39
-
现状:
submitTask(task: Task): void {
this.taskQueue.push(task);
this.dispatch(); // 未 await,不消费 Promise
}
private async dispatch(): Promise<void> {
// ... 内部 try/catch 仅吞掉 error,无外部感知
}
-
问题:dispatch() 返回 Promise<void>,调用方既不 await 也不 .catch。一旦内部 await agent.execute(task) 抛出 unhandled rejection(虽然 dispatch 自身有 try/catch,但任何在 try 之外抛出的异常 — 例如 findBestAgent 中的逻辑 bug — 都将变成 Node 全局未处理拒绝),taskQueue 状态可能停留在中间态,且无任何事件透传给调用方。
-
建议修复:
submitTask(task: Task): void {
this.taskQueue.push(task);
this.dispatch().catch(err => {
task.status = 'failed';
this.emit('dispatch-error', { taskId: task.id, error: err });
});
}
并在类构造前 extends EventEmitter(目前 Brain 未继承 EventEmitter,可考虑加入以统一事件模型)。
-
影响范围:任何调用 submitTask 的上层(如 multi-agent 路由器)。
2.2 [High] query 中工具执行无 try/catch,单次失败崩溃整个对话
-
位置:src/framework/query.ts:171-189
-
现状:
if (perm.behavior === 'deny') { ... }
else if (perm.behavior === 'ask' && params.permissionMode === 'default') { ... }
else {
result = await toolExecutor(tc.function.name, args); // 未捕获
}
-
问题:工具内部 throw(网络异常、spawn ENOENT、JSON 解析失败等)会直接冒泡到 query() 的外层 generator,导致整轮对话终止,用户看到的是 unhandled rejection。strategyTracker 也不会记录这次失败。
-
建议修复:
try {
result = await toolExecutor(tc.function.name, args);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
result = JSON.stringify({
success: false,
error: `Tool ${tc.function.name} threw: ${msg}`,
});
}
另外,abortSignal 应透传给 toolExecutor(见 3.2)。
-
影响范围:所有依赖 query() 主循环的 chat handler。
2.3 [High] harness.ts 中 catch (error: any) 假定 error.message 存在
-
位置:src/harness/harness.ts:264-270
-
现状:
} catch (error: any) {
taskResult = {
success: false,
error: error.message ?? 'Execution failed',
duration: Date.now() - context.startedAt,
};
}
-
问题:agent.execute() 抛出的不一定是 Error,有可能是字符串、对象或 undefined(setTimeout/Promise.reject 等场景),此时 error.message 为 undefined,虽然 ?? 兜底但丢失了原始信息。
-
建议修复:
} catch (error) {
const msg = error instanceof Error
? error.message
: typeof error === 'string'
? error
: JSON.stringify(error);
taskResult = {
success: false,
error: msg || 'Execution failed',
duration: Date.now() - context.startedAt,
};
}
-
影响范围:所有 Harness 包装的 agent 执行错误上报。
2.4 [High] agent.ts 中 any 滥用,失去 TypeScript 类型保护
-
位置:src/core/agent.ts:26, 69-79, 90-95, 98
-
现状:
params?: Record<string, any>; // L26
registerSkill(name: string, handler: (...args: any[]) => any): void; // L69
async triggerSkill(name: string, params?: any): Promise<any[]>; // L76
data?: any; // L92
getStatus(): any { ... } // brain.ts:98
-
问题:strict: true 已开启,但核心类型完全失控:TaskResult.data 在 harness.ts:196,208 处被强制 as Record<string, number>,任何上游不一致都是运行时崩溃。
-
建议修复:引入鉴别联合 + 泛型。
// core/agent.ts
export interface SkillContext<P = Record<string, unknown>> {
name: string;
params?: P;
}
export type TaskResultData =
| { kind: 'file'; content: string }
| { kind: 'command'; stdout: string; stderr?: string; exitCode: number }
| { kind: 'metrics'; steps?: number; metrics?: Record<string, number> }
| { kind: 'generic'; value: unknown };
export interface TaskResult {
success: boolean;
data?: TaskResultData;
error?: string;
duration?: number;
}
registerSkill<P = unknown, R = unknown>(
name: string,
handler: (ctx: SkillContext<P>) => Promise<R>,
): void { ... }
并把 brain.ts:98 的 getStatus(): any 改成显式 BrainStatus 接口。
-
影响范围:所有继承 BaseAgent 的子类、Harness、Brain;改动是兼容性的,但需要按模块逐步引入。建议作为单独 feat(typing): tighten core agent types 专项 PR。
三、Medium(架构 / 性能 / 安全)
3.1 SafetyChecker.auditLog 无上限,长会话内存泄漏
3.2 工具执行未透传 abortSignal,用户取消后仍跑
-
位置:src/framework/query.ts:43, 102-109, 185-189
-
现状:QueryParams.abortSignal 仅在循环头 check,工具一旦开始 await 就忽略它。
-
建议修复:扩展 toolExecutor 签名:
toolExecutor: (
name: string,
args: Record<string, unknown>,
abortSignal?: AbortSignal,
) => Promise<string>;
// 调用处:
result = await toolExecutor(tc.function.name, args, abortSignal);
并在 exec_command / web_fetch / embed 等长耗时工具内部监听 abortSignal.aborted 主动 abort(child.kill() / fetch({signal}))。
3.3 VectorStore.upsert 非事务,失败留下孤儿记录
-
位置:src/memory/vector-store.ts:118-160
-
现状:memories 表先 INSERT OR REPLACE,然后才 embed()。若 embedding 服务超时(embeddings.ts:83 默认 30s),memory_vectors 与 vec_memories 未写入,但 memories 已更新,造成索引漂移。
-
建议修复:使用 better-sqlite3 的 transaction;同时 embed 失败时回滚 memories 表或保留旧 vector。
async upsert(entry: MemoryEntry, projectPath?: string): Promise<void> {
const projectHash = projectPath ? this.hashProject(projectPath) : 'global';
let vector: number[] | null = null;
if (this.initialized) {
try {
vector = await this.embeddingService.embed(entry.content);
} catch (err: any) {
console.warn(`[VectorStore] embed failed, skip vector write: ${err.message}`);
}
}
const tx = this.db.transaction((v: number[] | null) => {
this.db.prepare(`INSERT OR REPLACE INTO memories (...) VALUES (...)`).run(...);
if (v) {
this.db.prepare('DELETE FROM memory_vectors WHERE memory_id = ?').run(entry.name);
this.db.prepare('DELETE FROM vec_memories WHERE rowid IN (SELECT vector_rowid FROM memory_vectors WHERE memory_id = ?)').run(entry.name);
const r = this.db.prepare(`INSERT INTO vec_memories (embedding) VALUES (?)`).run(JSON.stringify(v));
this.db.prepare('INSERT INTO memory_vectors (memory_id, vector_rowid) VALUES (?, ?)').run(entry.name, r.lastInsertRowid);
}
});
tx(vector);
}
顺便补一个组合索引:
CREATE INDEX IF NOT EXISTS idx_memories_project_type ON memories(project, type);
3.4 hashProject 仅取末两段路径,跨用户同名项目冲突
-
位置:src/memory/vector-store.ts:295-299
-
现状:
private hashProject(projectPath: string): string {
const hash = projectPath.split('/').slice(-2).join('/');
return hash; // /home/alice/code 与 /home/bob/code 冲突
}
-
建议修复:使用稳定哈希,与 src/memory/storage.ts 中 getProjectHash() 保持一致(假设其使用 SHA-256):
import { createHash } from 'crypto';
private hashProject(projectPath: string): string {
return createHash('sha256').update(projectPath).digest('hex').slice(0, 16);
}
注意:迁移期需要一次性 UPDATE memories SET project = new_hash WHERE project = old_hash,可写在初始化时的数据迁移函数中。
3.5 embedBatch 无 AbortSignal,单次慢调用阻塞整批
-
位置:src/memory/embeddings.ts:59-71
-
现状:Promise.all(batch.map(t => this.embed(t))),任一调用卡到 30s 超时,整批等待。无并发熔断。
-
建议修复:支持 abort + 单调用更短超时 + Promise.allSettled:
async embedBatch(texts: string[], opts: { signal?: AbortSignal; perCallTimeoutMs?: number } = {}): Promise<number[][]> {
const batchSize = 10;
const results: number[][] = [];
for (let i = 0; i < texts.length; i += batchSize) {
if (opts.signal?.aborted) throw new DOMException('Aborted', 'AbortError');
const batch = texts.slice(i, i + batchSize);
const settled = await Promise.allSettled(
batch.map(t => this.embed(t /* 把 signal/timeout 透传到 axios */)),
);
for (const s of settled) {
results.push(s.status === 'fulfilled' ? s.value : new Array(this.dimension).fill(0));
}
}
return results;
}
3.6 SSE / WebSocket 重连固定 3s,无指数退避
-
位置:src/services/mcp/transports.ts:161-178(SSE) 与 247-264(WebSocket)
-
现状:两个 attemptReconnect 都用 interval = config.reconnectInterval || 3000 的等间隔重试,5 次 = 15s 即放弃。
-
建议修复:
private attemptReconnect(): void {
const maxAttempts = this.config.maxReconnectAttempts || 5;
const baseInterval = this.config.reconnectInterval || 1000;
if (this.reconnectAttempts >= maxAttempts) {
this.emitError(new Error('Max reconnect attempts reached'));
return;
}
this.reconnectAttempts++;
// 指数退避 + 抖动,封顶 30s
const delay = Math.min(
baseInterval * Math.pow(2, this.reconnectAttempts - 1) + Math.floor(Math.random() * 250),
30_000,
);
this.emit('reconnecting', this.reconnectAttempts);
setTimeout(() => {
this.connect().catch(err => this.emitError(err));
}, delay);
}
考虑把这段抽到 BaseTransport 中复用。
3.7 web_fetch 无 SSRF 拦截,且未限制响应体大小
3.8 searchMemories / getMemoriesByType 每次全量读盘
-
位置:src/memory/storage.ts:288-307
-
现状:loadAllMemories 每次调用都重新 readdir + readFile 全部 .md 文件,作 in-memory filter。对大型记忆库随条目数线性退化,且每次 memory_recall fallback 都触发。
-
建议修复:走 SQLite 索引:
// 利用现有 vector-store 表(其 textSearch 已有 SQL 路径)
// 不必重写,直接代理到 VectorStore.search() / textSearch()。
export function searchMemories(query: string, projectPath?: string): MemoryEntry[] {
const store = getVectorStore();
return store.search(query, 50, projectPath)
.then(results => results.map(toMemoryEntry)); // 注意改成 async
}
迁移注意:searchMemories 当前是同步函数,改 async 会传染调用方。可保留旧路径作 fallback,在初始化时给 sqlite 表做一次全量 reindex。
3.9 exec_command stdout/stderr 截断逻辑不一致
-
位置:src/tools/index.ts:741-823
-
现状:
// stdout 用 totalBytes 累计
totalBytes += chunk.length;
if (totalBytes > maxBytes) { stdoutTruncated = true; ... }
// stderr 用 stderrData.length(独立计数)
if (!stderrTruncated && stderrData.length < maxBytes) {
if (stderrData.length + chunk.length > maxBytes) { ... }
}
两条路径的"是否达到上限"判断口径不同 — stdout 用累计 totalBytes(包含 stdout 全部历史),stderr 用 stderrData.length(只自己)。当 stdout 已经 truncate 后,stderr 仍可独自再写 50KB,两个流加起来最多 100KB,与单一 50KB 上限的语义不符。
-
建议修复:统一在外层维护单一 totalBytes:
let totalBytes = 0;
const appendBounded = (target: 'stdout' | 'stderr', chunk: string) => {
if (totalBytes >= maxBytes) {
if (target === 'stdout') stdoutTruncated = true;
else stderrTruncated = true;
return;
}
const remain = maxBytes - totalBytes;
const take = chunk.slice(0, remain);
totalBytes += take.length;
if (target === 'stdout') stdoutData += take; else stderrData += take;
if (chunk.length > take.length) {
if (target === 'stdout') stdoutTruncated = true;
else stderrTruncated = true;
}
};
child.stdout.on('data', d => appendBounded('stdout', d.toString()));
child.stderr.on('data', d => appendBounded('stderr', d.toString()));
3.10 命令面板 \x1b[J 清屏过激,清除下方非自身内容
-
位置:src/ui/command-panel.ts:211-225, 227-243
-
现状:render() 与 clearPanel() 都用 \x1b[s 存光标 → \x1b[J(清光标到屏幕底部所有内容) → \x1b[u 复位。任何在面板下方已经存在的输出(例如刚刚 stream 出来的助手回复尾部)都会被擦除。已知的 commit 9a9a5f6 修复了光标越界但没解决"过度清屏"问题。
-
建议修复:精确按 lastPanelLines.length 行覆盖,而不是 \x1b[J:
function clearPanel(): void {
const height = lastPanelLines.length;
if (height === 0) return;
process.stdout.write('\x1b[s');
for (let i = 0; i < height; i++) {
process.stdout.write('\n\x1b[2K'); // 下移一行 + 清当前行
}
process.stdout.write('\x1b[u'); // 复位光标
lastPanelLines = [];
panelHeight = 0;
}
// render() 同样不要使用 \x1b[J,改成"先 clearPanel(),再按行写入"。
额外建议:面板渲染换用 readline.cursorTo() 与 readline.clearScreenDown() 的 stream 版本,避免直接写 ANSI 序列(更易跨终端兼容)。
3.11 无 SIGWINCH 监听 + 颜色未走 NO_COLOR 通道
-
位置:src/ui/command-panel.ts:15-18, 169
-
现状:terminalWidth = process.stdout.columns || 80 只在 render 当下取一次;终端 resize 后面板会错位。SELECTED = chalk.bgHex(...) 硬编码颜色,未尊重 NO_COLOR / FORCE_COLOR=0 / 非 TTY 输出。
-
建议修复:
import { isatty } from 'tty';
const useColor = process.env.NO_COLOR !== '1' && isatty(1);
const SELECTED = useColor ? chalk.bgHex('#1E293B').hex('#E2E8F0') : (s: string) => `[${s}]`;
// 在 cli 启动处:
process.stdout.on('resize', () => {
if (isPanelVisible()) {
// 重新渲染(隐藏再显示)
const f = (currentFilter as string);
hideCommandPanel();
showCommandPanel(f);
}
});
四、Low(工程化 / 文档 / 依赖)
4.1 缺 CONTRIBUTING.md、issue/PR 模板与 lint 配置
- 现状:仓库根目录无
.github/,无 CONTRIBUTING.md、无 .eslintrc.* / .prettierrc.* 文件;package.json 的 lint / format 脚本依赖外部默认配置,易跑出不可复现的结果。
- 建议修复:新建以下文件骨架(可作为单独 PR):
.github/ISSUE_TEMPLATE/bug_report.md(参考 GitHub 官方模板)
.github/ISSUE_TEMPLATE/feature_request.md
.github/PULL_REQUEST_TEMPLATE.md
CONTRIBUTING.md(包含本地开发、跑测试、提交格式、PR 检查项)
.eslintrc.cjs(继承 @typescript-eslint/recommended + prettier)
.prettierrc(2 空格、单引号、行宽 100,与现有代码风格对齐)
4.2 package.json 中含未使用的 React 系列依赖
-
位置:package.json:dependencies
-
现状:react@^19.2.6 / react-compiler-runtime@^1.0.0 / react-reconciler@^0.33.0 / bidi-js@^1.0.3 在 src/ 中 0 处 from 'react' 引用(grep -rE "from ['\"]react" src/ 为空)。
-
风险:增加安装体积(React 19 全家桶约 5+ MB)、提升潜在 CVE 暴露面、误导贡献者以为存在 React UI。
-
建议修复:
执行前用 depcheck 二次验证,避免误删动态 require。
4.3 测试覆盖偏低,UI 与异常路径无回归保护
- 位置:
src/ 91 个 TS 文件 vs tests/ 26 个测试文件
- 问题:UI 渲染层(
command-panel.ts、markdown.ts、box.ts)、query 主循环、MCP 重连、exec_command 截断 — 这些近期都修过 bug,但都没有对应单测。
- 建议修复:
- 给
command-panel.ts 加 snapshot test(mock stdout,断言写入序列)
- 给
query() 加单测:工具抛错、abortSignal 中途触发、strategyExhausted 路径
- 给
exec_command 加单测:stdout/stderr 双截断、超时、ENOENT
- 给
SseTransport.attemptReconnect 加 fake-timer 测指数退避
- 目标:覆盖率 v0.1.11 提到 60%+,v0.2 提到 75%+
4.4 src/tools/index.ts 1132 行,13+ 工具内联
-
位置:src/tools/index.ts
-
建议:按工具族拆分,每个文件单独导出,在 tools/index.ts 中只做聚合 + 注册。同时抽出通用参数校验:
// tools/_validate.ts
export function requireString(args: Record<string, unknown>, key: string, tool: string): string {
const v = args[key];
if (typeof v !== 'string' || !v) {
throw new ToolArgError(`${tool} requires string parameter "${key}"`);
}
return v;
}
再在每个工具的 execute 入口使用,统一错误消息格式。
4.5 MEMORY.md 超过 25KB 后静默丢条目
-
位置:src/memory/storage.ts:265-278
-
现状:
if (content.length > MAX_ENTRYPOINT_BYTES) {
while (content.length > MAX_ENTRYPOINT_BYTES && lines.length > 10) {
lines.pop(); // 静默丢失尾部条目
}
}
注意:content 在 while 中未重新计算,这是一个 bug — lines.pop() 不会改变 content 长度,循环条件永远成立,会把 lines 一直 pop 到 10 行。
-
建议修复:
let joined = lines.join('\n');
while (joined.length > MAX_ENTRYPOINT_BYTES && lines.length > 10) {
lines.pop();
joined = lines.join('\n');
}
if (lines[lines.length - 1] !== '> WARNING: MEMORY.md truncated. Some entries omitted.') {
lines.push('', '> WARNING: MEMORY.md truncated. Some entries omitted.');
}
atomicWriteFileSync(getEntrypointPath(projectPath), lines.join('\n'));
这条其实属于 bug,可单独提一个 fix-pr。
五、关于实施节奏的建议
| 阶段 |
推荐合入项 |
| v0.1.11 hotfix 周期 |
2.1 / 2.2 / 2.3 / 3.3 / 3.9 / 4.5(全是稳定性 bug) |
| v0.1.12 quality 专项 |
2.4(typing)、3.1 / 3.2 / 3.6 |
| v0.2.0 security 强化 |
3.7 / 3.4 / 3.11、配合补充 bash flag 拦截能力 |
| v0.2.x 工程化 |
4.1 / 4.2 / 4.3 / 4.4 / 3.10 |
建议每个分组拆 1 个独立 PR,避免单 PR 体积过大、review 成本高。优先 hotfix 部分(均为现网可触发问题),其余按 roadmap 排期。
六、贡献意愿
如果维护者认可上述改动方向,我愿意按以下范围认领 PR:
请在评论中告知偏好(全部认领 / 拒绝某些 / 自己来),我会按你给出的拆分粒度依次提交 PR。
审计方法说明:基于 commit 98a386f 静态阅读,未做动态 profiling 或运行时验证,部分性能结论(如 3.5、3.8)需在真实负载下复测。若某条与你掌握的设计意图不符,请直接 close,会再讨论。
整体而言,OpenHorse 在 v0.1.10 已经具备非常完整的 agent 框架雏形 — Harness 治理、MCP、memory、多 agent 路由的分层都很清晰;以上建议主要面向鲁棒性、类型严格度与社区基建三方面提升,期待在 v0.2 看到这些方向的迭代。
[优化建议] OpenHorse v0.1.10 综合代码体检报告
目录
一、问题分级总览
submitTask触发 fire-and-forgetdispatchquery工具执行无 try/catch,单次工具异常即崩溃整个对话catch (error: any)假定error.message存在params/TaskResult.data/triggerSkill大量anyauditLog无上限,长会话内存泄漏abortSignal,用户取消后仍跑upsert删旧向量后插入,失败留下孤儿记录hashProject仅取末两段路径,跨用户同名项目冲突embedBatch无AbortSignal,单次慢调用阻塞整批web_fetch无 SSRF 拦截 + 无Content-Length上限searchMemories与getMemoriesByType每次全量读盘exec_commandstdout/stderr 截断逻辑不一致\x1b[J清屏过激,会清除面板下方非自身内容SIGWINCH监听 + chalk 颜色未走NO_COLOR通道CONTRIBUTING.md、issue/PR 模板、.eslintrcreact/react-compiler-runtime/react-reconciler/bidi-js已声明但未使用二、Critical / High(强烈建议优先合入)
2.1 [Critical]
Brain.submitTask触发 fire-and-forgetdispatch位置:
src/core/brain.ts:36-39现状:
问题:
dispatch()返回Promise<void>,调用方既不await也不.catch。一旦内部await agent.execute(task)抛出 unhandled rejection(虽然dispatch自身有 try/catch,但任何在try之外抛出的异常 — 例如findBestAgent中的逻辑 bug — 都将变成 Node 全局未处理拒绝),taskQueue状态可能停留在中间态,且无任何事件透传给调用方。建议修复:
并在类构造前
extends EventEmitter(目前Brain未继承 EventEmitter,可考虑加入以统一事件模型)。影响范围:任何调用
submitTask的上层(如 multi-agent 路由器)。2.2 [High]
query中工具执行无 try/catch,单次失败崩溃整个对话位置:
src/framework/query.ts:171-189现状:
问题:工具内部 throw(网络异常、
spawn ENOENT、JSON 解析失败等)会直接冒泡到query()的外层 generator,导致整轮对话终止,用户看到的是 unhandled rejection。strategyTracker也不会记录这次失败。建议修复:
另外,
abortSignal应透传给toolExecutor(见 3.2)。影响范围:所有依赖
query()主循环的 chat handler。2.3 [High]
harness.ts中catch (error: any)假定error.message存在位置:
src/harness/harness.ts:264-270现状:
问题:
agent.execute()抛出的不一定是 Error,有可能是字符串、对象或undefined(setTimeout/Promise.reject 等场景),此时error.message为undefined,虽然??兜底但丢失了原始信息。建议修复:
影响范围:所有 Harness 包装的 agent 执行错误上报。
2.4 [High]
agent.ts中any滥用,失去 TypeScript 类型保护位置:
src/core/agent.ts:26, 69-79, 90-95, 98现状:
问题:
strict: true已开启,但核心类型完全失控:TaskResult.data在harness.ts:196,208处被强制as Record<string, number>,任何上游不一致都是运行时崩溃。建议修复:引入鉴别联合 + 泛型。
并把
brain.ts:98的getStatus(): any改成显式BrainStatus接口。影响范围:所有继承
BaseAgent的子类、Harness、Brain;改动是兼容性的,但需要按模块逐步引入。建议作为单独feat(typing): tighten core agent types专项 PR。三、Medium(架构 / 性能 / 安全)
3.1
SafetyChecker.auditLog无上限,长会话内存泄漏位置:
src/harness/safety.ts:94, 241-252现状:
建议修复:
3.2 工具执行未透传
abortSignal,用户取消后仍跑位置:
src/framework/query.ts:43, 102-109, 185-189现状:
QueryParams.abortSignal仅在循环头 check,工具一旦开始await就忽略它。建议修复:扩展
toolExecutor签名:并在
exec_command/web_fetch/embed等长耗时工具内部监听abortSignal.aborted主动 abort(child.kill()/fetch({signal}))。3.3
VectorStore.upsert非事务,失败留下孤儿记录位置:
src/memory/vector-store.ts:118-160现状:
memories表先 INSERT OR REPLACE,然后才embed()。若 embedding 服务超时(embeddings.ts:83默认 30s),memory_vectors与vec_memories未写入,但memories已更新,造成索引漂移。建议修复:使用
better-sqlite3的 transaction;同时 embed 失败时回滚 memories 表或保留旧 vector。顺便补一个组合索引:
3.4
hashProject仅取末两段路径,跨用户同名项目冲突位置:
src/memory/vector-store.ts:295-299现状:
建议修复:使用稳定哈希,与
src/memory/storage.ts中getProjectHash()保持一致(假设其使用 SHA-256):注意:迁移期需要一次性
UPDATE memories SET project = new_hash WHERE project = old_hash,可写在初始化时的数据迁移函数中。3.5
embedBatch无AbortSignal,单次慢调用阻塞整批位置:
src/memory/embeddings.ts:59-71现状:
Promise.all(batch.map(t => this.embed(t))),任一调用卡到 30s 超时,整批等待。无并发熔断。建议修复:支持 abort + 单调用更短超时 +
Promise.allSettled:3.6 SSE / WebSocket 重连固定 3s,无指数退避
位置:
src/services/mcp/transports.ts:161-178(SSE) 与247-264(WebSocket)现状:两个
attemptReconnect都用interval = config.reconnectInterval || 3000的等间隔重试,5 次 = 15s 即放弃。建议修复:
考虑把这段抽到
BaseTransport中复用。3.7
web_fetch无 SSRF 拦截,且未限制响应体大小位置:
src/tools/web.ts:128-192, 254-262现状:
protocol.startsWith('http'),未拒绝http://127.0.0.1/http://169.254.169.254(云元数据)/http://[::1]。await response.text()未读Content-Length,响应体可能数 GB,只在拿到完整 text 之后才截断到 100KB,期间已经占满内存。建议修复:
3.8
searchMemories/getMemoriesByType每次全量读盘位置:
src/memory/storage.ts:288-307现状:
loadAllMemories每次调用都重新readdir+readFile全部.md文件,作 in-memoryfilter。对大型记忆库随条目数线性退化,且每次memory_recallfallback 都触发。建议修复:走 SQLite 索引:
迁移注意:
searchMemories当前是同步函数,改 async 会传染调用方。可保留旧路径作 fallback,在初始化时给 sqlite 表做一次全量 reindex。3.9
exec_commandstdout/stderr 截断逻辑不一致位置:
src/tools/index.ts:741-823现状:
两条路径的"是否达到上限"判断口径不同 — stdout 用累计 totalBytes(包含 stdout 全部历史),stderr 用 stderrData.length(只自己)。当 stdout 已经 truncate 后,stderr 仍可独自再写 50KB,两个流加起来最多 100KB,与单一 50KB 上限的语义不符。
建议修复:统一在外层维护单一
totalBytes:3.10 命令面板
\x1b[J清屏过激,清除下方非自身内容位置:
src/ui/command-panel.ts:211-225, 227-243现状:
render()与clearPanel()都用\x1b[s存光标 →\x1b[J(清光标到屏幕底部所有内容) →\x1b[u复位。任何在面板下方已经存在的输出(例如刚刚 stream 出来的助手回复尾部)都会被擦除。已知的 commit9a9a5f6修复了光标越界但没解决"过度清屏"问题。建议修复:精确按
lastPanelLines.length行覆盖,而不是\x1b[J:额外建议:面板渲染换用
readline.cursorTo()与readline.clearScreenDown()的 stream 版本,避免直接写 ANSI 序列(更易跨终端兼容)。3.11 无
SIGWINCH监听 + 颜色未走NO_COLOR通道位置:
src/ui/command-panel.ts:15-18, 169现状:
terminalWidth = process.stdout.columns || 80只在 render 当下取一次;终端 resize 后面板会错位。SELECTED = chalk.bgHex(...)硬编码颜色,未尊重NO_COLOR/FORCE_COLOR=0/ 非 TTY 输出。建议修复:
四、Low(工程化 / 文档 / 依赖)
4.1 缺
CONTRIBUTING.md、issue/PR 模板与 lint 配置.github/,无CONTRIBUTING.md、无.eslintrc.*/.prettierrc.*文件;package.json的lint/format脚本依赖外部默认配置,易跑出不可复现的结果。.github/ISSUE_TEMPLATE/bug_report.md(参考 GitHub 官方模板).github/ISSUE_TEMPLATE/feature_request.md.github/PULL_REQUEST_TEMPLATE.mdCONTRIBUTING.md(包含本地开发、跑测试、提交格式、PR 检查项).eslintrc.cjs(继承@typescript-eslint/recommended+prettier).prettierrc(2 空格、单引号、行宽 100,与现有代码风格对齐)4.2
package.json中含未使用的 React 系列依赖位置:
package.json:dependencies现状:
react@^19.2.6/react-compiler-runtime@^1.0.0/react-reconciler@^0.33.0/bidi-js@^1.0.3在src/中 0 处from 'react'引用(grep -rE "from ['\"]react" src/为空)。风险:增加安装体积(React 19 全家桶约 5+ MB)、提升潜在 CVE 暴露面、误导贡献者以为存在 React UI。
建议修复:
执行前用
depcheck二次验证,避免误删动态 require。4.3 测试覆盖偏低,UI 与异常路径无回归保护
src/91 个 TS 文件 vstests/26 个测试文件command-panel.ts、markdown.ts、box.ts)、query主循环、MCP 重连、exec_command截断 — 这些近期都修过 bug,但都没有对应单测。command-panel.ts加 snapshot test(mock stdout,断言写入序列)query()加单测:工具抛错、abortSignal 中途触发、strategyExhausted路径exec_command加单测:stdout/stderr 双截断、超时、ENOENTSseTransport.attemptReconnect加 fake-timer 测指数退避4.4
src/tools/index.ts1132 行,13+ 工具内联位置:
src/tools/index.ts建议:按工具族拆分,每个文件单独导出,在
tools/index.ts中只做聚合 + 注册。同时抽出通用参数校验:再在每个工具的 execute 入口使用,统一错误消息格式。
4.5
MEMORY.md超过 25KB 后静默丢条目位置:
src/memory/storage.ts:265-278现状:
注意:
content在while中未重新计算,这是一个 bug —lines.pop()不会改变content长度,循环条件永远成立,会把lines一直 pop 到 10 行。建议修复:
这条其实属于 bug,可单独提一个 fix-pr。
五、关于实施节奏的建议
建议每个分组拆 1 个独立 PR,避免单 PR 体积过大、review 成本高。优先 hotfix 部分(均为现网可触发问题),其余按 roadmap 排期。
六、贡献意愿
如果维护者认可上述改动方向,我愿意按以下范围认领 PR:
.github/模板与CONTRIBUTING.md骨架请在评论中告知偏好(全部认领 / 拒绝某些 / 自己来),我会按你给出的拆分粒度依次提交 PR。