实战 03:简易 OpenCode——TypeScript 编码 Agent
1、本篇交付物
TypeScript 版采用 Fastify、Zod、SQLite、LangGraph.js 和 Ink/React:Server 负责项目、Session、模型、工具与权限;TUI 只调用 HTTP/SSE。重点不是翻译 Python,而是利用 discriminated union 和 reducer 保证端到端事件一致。
2、Monorepo 结构
packages/
contracts/ # Zod schema,并导出推导类型
server/ # Fastify、repository、runtime、tools
tui/ # Ink React 客户端
sdk/ # 从 OpenAPI 或 contracts 生成/封装客户端
tests/
contracts 不依赖 Server/TUI,防止循环依赖。所有网络输入、数据库 JSON、provider/tool 输出在边界处 parse。
3、事件与 Part 的判别联合
import * as z from "zod";
export const Part = z.discriminatedUnion("type", [
z.object({ id: z.string(), type: z.literal("text"), text: z.string() }),
z.object({ id: z.string(), type: z.literal("reasoning"), text: z.string(), visibility: z.enum(["hidden", "summary"]) }),
z.object({
id: z.string(), type: z.literal("tool"), callId: z.string(), tool: z.string(),
status: z.enum(["pending", "waiting_permission", "running", "completed", "error"]),
input: z.unknown(), outputArtifactId: z.string().optional(), errorCode: z.string().optional(),
}),
]);
export const ServerEvent = z.discriminatedUnion("type", [
z.object({ seq: z.number().int().positive(), type: z.literal("session.updated"), session: Session }),
z.object({ seq: z.number().int().positive(), type: z.literal("message.created"), message: Message }),
z.object({ seq: z.number().int().positive(), type: z.literal("part.updated"), messageId: z.string(), part: Part }),
z.object({ seq: z.number().int().positive(), type: z.literal("permission.requested"), request: PermissionRequest }),
z.object({ seq: z.number().int().positive(), type: z.literal("session.error"), code: z.string(), retryable: z.boolean() }),
]);
TUI 的 switch(event.type) 若漏分支,可用 never 让编译失败。新增协议事件时,Server 与客户端都会被迫更新。
4、Repository 的原子写法
function appendEvent(db: Database, sessionId: string, type: string, payload: unknown) {
return db.transaction(() => {
const row = db.prepare(
"UPDATE sessions SET last_event_seq=last_event_seq+1 WHERE id=? RETURNING last_event_seq"
).get(sessionId) as { last_event_seq: number } | undefined;
if (!row) throw new Error("SESSION_NOT_FOUND");
const event = { sessionId, seq: row.last_event_seq, type, payload, createdAt: new Date().toISOString() };
db.prepare("INSERT INTO events(session_id,seq,type,payload,created_at) VALUES(?,?,?,?,?)")
.run(sessionId, event.seq, type, JSON.stringify(payload), event.createdAt);
return event;
})();
}
业务状态和对应事件必须同事务提交。内存 EventEmitter 只负责唤醒 SSE,丢通知时客户端仍能从表补读。
5、Provider Adapter 与流式工具参数
interface ModelProvider {
stream(input: ModelRequest, signal: AbortSignal): AsyncIterable<ModelChunk>;
}
type ModelChunk =
| { type: "text.delta"; text: string }
| { type: "tool.start"; callId: string; name: string }
| { type: "tool.args.delta"; callId: string; json: string }
| { type: "finish"; reason: "stop" | "tool_calls" | "length"; usage: Usage };
Runtime 为每个 call ID 累积 JSON delta,只有完整结束后才 ToolSchema.parse(JSON.parse(buffer))。不在半个 JSON 时执行工具。Provider 断线时将未完成 Tool Part 标记 error,并允许安全重试整个模型 step。
6、Tool Registry 与运行上下文
type ToolContext = {
sessionId: string; projectId: string;
projectRoot: string; workspaceRoot: string;
mode: "plan" | "build";
signal: AbortSignal;
};
interface Tool<I, O> {
name: string;
description: string;
schema: z.ZodType<I>;
permission: "read" | "edit" | "execute" | "network";
summarize(input: I): string;
execute(input: I, context: ToolContext): Promise<O>;
}
Registry 只向模型暴露当前 mode/agent 允许的工具。Plan mode 不仅权限判 deny,最好根本不提供 edit/execute schema,减少模型误选。
7、安全文件工具
async function resolveWorkspacePath(root: string, relative: string) {
if (path.isAbsolute(relative) || relative.split(/[\\/]/).includes("..")) throw new ToolError("INVALID_PATH");
const base = await fs.realpath(root);
const target = path.resolve(base, relative);
if (!target.startsWith(base + path.sep)) throw new ToolError("PATH_ESCAPE");
const parentReal = await fs.realpath(path.dirname(target));
if (!parentReal.startsWith(base + path.sep) && parentReal !== base) throw new ToolError("SYMLINK_ESCAPE");
return target;
}
read 再限制 size/MIME;grep 用 spawn("rg", args, {shell:false});apply_patch 解析所有路径、校验 before hash、dry-run 后写隔离 worktree;大型 stdout 存 artifact 并返回截断摘要。
8、Permission Broker
调用流程:Tool Part pending → 计算风险和匹配规则 → allow 直接运行;deny 写受控 Tool Result;ask 创建 Permission Request、Part waiting、Session waiting。用户响应:
const PermissionDecision = z.object({
response: z.enum(["allow_once", "allow_rule", "deny"]),
rememberPattern: z.string().max(500).optional(),
});
Server 重新校验 Session owner、pending request、call/input hash 和 workspace snapshot。allow_rule 的 pattern 不能比原请求更宽;例如批准 npm test 不能生成 bash:*。
9、LangGraph.js Agent Loop
State 保存 session/turn、provider messages、pending call、permission 和预算。节点为 build_context → call_model → permission → run_tool → call_model;finish/预算/取消进入 END。checkpointer 支持 Server 重启恢复;tool executions 对 call ID 建唯一约束。
模型 token delta 不必全部写 Graph state,但必须写 Message Part/Event;Graph state 只保留恢复所需游标和稳定消息引用,避免每个 token 产生巨大 checkpoint。
10、Fastify API 与后台 Turn
app.post("/sessions/:id/messages", async (req, reply) => {
const actor = await authenticate(req);
const input = CreateMessage.parse(req.body);
const turn = await sessions.enqueueMessage(actor, req.params.id, input);
void runner.start(turn.id); // 实际生产使用持久队列
return reply.code(202).send({ turnId: turn.id });
});
同一 Session 默认串行;新消息到来时可入队或要求取消当前 turn。生产不能用无持久性的 void 后台任务,应放队列并用唯一 turn ID。
11、Ink TUI
TUI 启动时拉 Session snapshot,随后订阅 SSE。Reducer 按 seq 去重;组件分为消息列表、工具卡、权限对话框、输入框和状态栏。
function reducer(state: UiState, raw: unknown): UiState {
const event = ServerEvent.parse(raw);
if (event.seq <= state.lastSeq) return state;
switch (event.type) {
case "part.updated": return updatePart(state, event);
case "permission.requested": return { ...state, permission: event.request, lastSeq: event.seq };
case "session.updated": return { ...state, session: event.session, lastSeq: event.seq };
case "message.created": return addMessage(state, event);
case "session.error": return { ...state, error: event.code, lastSeq: event.seq };
}
}
快捷键只发 API:Tab 切 Plan/Build;Esc cancel;权限框允许一次/记住/拒绝;/undo 调 Server snapshot API。TUI 绝不直接修改文件。
12、测试
Contracts 做 schema fixture;Repository 测 seq/事务;工具做路径与超时属性测试;Permission 测规则优先级;FakeProvider 跑多轮工具循环;Fastify 注入测试 API;TUI reducer 做乱序/重复事件;E2E 启动 Server 后断开/重连、暂停/批准、重启恢复和 undo。
这一版完成后,已经具备与官方 OpenCode 相似的关键形态:独立 Server、多客户端、Session API、事件流、工具与权限。下一篇切换到 OpenClaw 的单渠道 Gateway。
如果您觉得这篇文章有帮助,请点个赞吧~
评论
请登录后发表评论
去登录