自己 build 一個 harness 的 14 步 roadmap
參考實作:/Users/freddyluo/freddy/toy/opencode(由該 repo 的 claude session 1c34c4ba 盤出脊椎與步驟)。
學習原則依據:我的學習引擎:先看完成品,再反推做法、學習效率取決於反饋迴圈多短、臨摹加疊圖比對是跨領域通用的入門路線、容錯率高的沙盒是高頻迭代的前提。
操作紀律
- 前 7 步一行理論都不讀 — opencode 的設計原因幾乎都寫在註解裡(
max-steps.ts為什麼不 throw、fetch.ts為什麼不能 lazy build),撞過之後才讀 - 每步都要做疊圖 — 只確認「我的有跑出東西」是放寬對答案標準;要跟 opencode 跑同一輸入比回應形狀與事件序列
- step 02 就導入
packages/http-recorder— 不要等迴圈變成分鐘級才補 - step 09 當「奪取主導權」檢查點 — 開始對 opencode 的設計有意見時,就可以刻意偏離參考實作
三個被修正的假設
- 是 SSE 但不是原生
EventSource:client 用fetch+response.body.getReader()自己切\n\n(packages/client/src/promise/generated/client.ts:368),因為原生 EventSource 不能帶Authorizationheader - state 不是直接寫表,是事件溯源:durable event → projector → SQLite;
Session.prompt()只發session.inbox.enqueued - 沒有記憶體裡的 tool loop:
AGENTS.md明寫每個 step 是一次llm.stream,從重載的歷史繼續
環境:機器上沒裝 opencode 執行檔,都用 bun run dev <subcommand>;測試不能從 repo root 跑(do-not-run-tests-from-root 守衛)。
脊椎地圖
| 檔案 | 負責什麼 |
|---|---|
packages/ai/ + README | provider 中立的模型呼叫層,LLM.generate / LLM.stream |
packages/cli/src/index.ts | CLI 入口(動態 import 壓低啟動成本) |
packages/server/src/process.ts | server 入口、綁 port、dispatch 就緒閘門 |
packages/protocol/src/groups/session.ts:320 | POST /api/session/:id/prompt 的契約 |
packages/server/src/handlers/session.ts:279 | prompt handler(很薄,只翻譯錯誤) |
packages/core/src/session/prompt.ts + inbox.ts | 「收件」:提示變成持久事實,不呼叫模型 |
packages/core/src/session/projector.ts | 事件 → SQLite 投影(:62 花費累加) |
packages/core/src/session/runner/step.ts | 一個 step = 一次 llm.stream,harness 的心臟 |
packages/core/src/session/runner/llm.ts | step 外層:重載歷史、重試、壓縮、發事件 |
packages/core/src/tool/plugin/ | 15 個內建工具,read.ts 最單純、shell.ts 最完整 |
packages/core/src/tool/runtime.ts | 工具 → JSON Schema、execute() |
packages/core/src/permission.ts | 權限閘門 permission.assert({...}) |
packages/server/src/handlers/event.ts + event-feed.ts | SSE 端點(34 行)+ 每訂閱者 4096 的 dropping queue |
packages/core/src/session/runner/max-steps.ts | 步數上限策略(一段 prompt,不是 throw) |
進度
01 · 一次呼叫,印出一句話
- 目標:跑一支腳本,送死的 prompt 給 Gemini,印出回覆文字
- 參考:
packages/ai/README.md開頭 25 行(OpenAI.configure→Google.configure) - 驗收:
bun run src/01.ts5 秒內印出一行非空字串 - 疊圖:無(目的是先有一個會動的東西)
- 之後才讀:無,先不要讀
02 · 換成串流
- 目標:同一個 prompt,文字逐塊印出來
- 參考:
packages/ai/README.md第 27 行,LLM.stream取代generate - 驗收:終端機看得到字一段一段出現,不是停 3 秒整段跳出
- 疊圖:
bun run dev問同一句,肉眼比對吐字節奏 - 同步導入
packages/http-recorder(爆點 1) - 之後才讀:streaming 協定,只讀到「為什麼不是一次回」
03 · 把串流拆成事件
- 目標:不印文字,改印每個事件的
type - 參考:
packages/core/src/session/runner/publish-llm-event.ts - 驗收:輸出
start / text-delta × N / finish,finish 裡找得到 token 用量 - 疊圖:
curl -Nopencode 的/api/event,你的序列應是它的子集 - 之後才讀:provider 間事件形狀差異(為什麼
packages/ai要做這層抽象)
04 · 多輪對話
- 目標:CLI 讀 stdin,維護 messages 陣列
- 參考:
packages/core/src/session/runner/to-llm-message.ts - 驗收:連問兩句,第二句能引用第一句內容
- 疊圖:無/之後才讀:無(還太早)
05 · 訊息落地 SQLite
- 目標:關掉重開,
--session <id>能接上上次對話 - 參考:
packages/core/src/session/sql.ts、store.ts(先不要抄事件溯源,直接 insert) - 驗收:關掉重開問「我剛剛說什麼」還答得出來
- 疊圖:
sqlite3開~/.local/share/opencode/opencode-local.db,比對session/session_message欄位差異 - 之後才讀:無(事件溯源留到 step 11)
06 · 包成 HTTP server
- 目標:
POST /prompt收提示,GET /event用 SSE 推回覆 - 參考:
packages/server/src/handlers/event.ts(34 行)、event-feed.ts:33的frame() - 驗收:一邊
curl -N localhost:PORT/event,一邊curl -XPOST .../prompt -d '{"text":"hi"}',前者即時吐data: {...} - 疊圖:對 opencode 跑一樣兩條 curl,比對幀格式
- 寫 20 行 SSE 斷言器(爆點 3)
- 之後才讀:SSE 規格(
data:/:註解幀 / 雙換行邊界)
07 · 心跳與重連
- 目標:server 每 15 秒推
: heartbeat;客戶端 45 秒沒收到位元組就重連 - 參考:
handlers/event.ts:21、packages/client/src/solid/connection.ts:86(看門狗)、同檔:218(頁面生命週期四事件) - 驗收:
curl -N掛著每 15 秒一行心跳;killserver 後客戶端 1 秒內印出重連 - 疊圖:對 opencode 的
/api/event掛 60 秒,數心跳數量 - 之後才讀:半開連線、為什麼鎖螢幕不觸發
pagehide
08 · 第一個工具(唯讀)
- 目標:給模型
read工具,它呼叫 → 你執行 → 餵回去 → 它用檔案內容回答 - 參考:
packages/core/src/tool/plugin/read.ts、tool/runtime.ts:21的definition() - 驗收:問「package.json 裡的 version 是多少」答案正確
- 疊圖:
bun run dev問同一句,比對 tool call 參數形狀 - 之後才讀:tool calling 的 wire format 與各家差異
09 · Tool loop 與終止
- 目標:模型可連續呼叫工具直到完成;有步數上限且上限到了不崩
- 參考:
packages/core/src/session/runner/max-steps.ts(不是 throw,是塞一段 prompt 叫模型收尾——最值得抄的設計決定) - 驗收:需讀三個檔的問題連續呼叫三次;上限設 2 時回「達到上限」摘要而非當掉
- 疊圖:比對你的上限訊息和
MAX_STEPS_PROMPT的措辭 - 寫假 provider 回死序列,loop 測試不碰網路、< 1 秒(爆點 2;參考
packages/simulation+OPENCODE_SIMULATE) - 之後才讀:agent loop 終止條件、為什麼不用硬性 throw
10 · 權限閘門
- 目標:加
shell工具,執行前問 y/n;拒絕時模型收到「被拒絕」而不是錯誤堆疊 - 參考:
packages/core/src/tool/plugin/shell.ts:114、permission.ts:64的DeclinedError - 驗收:問「現在幾點」它想跑
date,按 n 後它改口說沒權限 - 疊圖:opencode 按 Tab 切
planagent 問同一句,看它怎麼被擋 - 之後才讀:capability 模型、為什麼
planagent 用權限規則而不是換 system prompt
11 · 拆開「收件」與「執行」
- 目標:
POST /prompt在 50ms 內回 200,模型執行在背景跑,結果走 SSE - 參考:
packages/core/src/session/prompt.ts、inbox.ts、projector.ts;AGENTS.md的 Session Core 那節 - 驗收:
curl -w '%{time_total}' -XPOST .../prompt< 0.1s,而/event持續吐 - 疊圖:對 opencode 同樣測一次,時間應同一量級
- 固定輸入 → 快照 durable event 序列(爆點 4:狀態對不對不要看表,看產生它的事件流)
- 之後才讀:事件溯源、CQRS、為什麼可重來的執行要跟不可重來的收件分家
12 · 重啟續跑
- 目標:跑到一半
kill -9,重啟後那個 session 自己接著跑完 - 參考:
packages/core/src/session/execution/restart.ts、packages/server/src/process.ts:233的installRestartContinuity - 驗收:20 秒任務在 10 秒時
kill -9,重啟後 SSE 繼續吐剩下內容 - 疊圖:對 opencode 做一樣的事
- kill -9 測試腳本化(spawn → prompt → sleep 10 && kill -9 → 重啟 → 斷言補完)
- 之後才讀:write-ahead claim、at-least-once 與 exactly-once 的差別
13 · 上下文組裝(選修)
- 目標:system prompt + 專案說明 + 歷史,組出完整請求
- 參考:
packages/core/src/session/runner/prompt/system.txt、context.ts、system-prompt.ts - 驗收:印出實際送出的 request body,逐段指得出每塊從哪來
- 疊圖:
bun run dev debug config對照 - 之後才讀:context window 管理、壓縮(
session/compaction.ts)
14 · 花費記錄(選修)
- 目標:每個 step 結束累加成本,顯示在狀態列
- 參考:
packages/core/src/session/usage.ts:22的calculateCost、projector.ts:62的累加 SQL - 驗收:兩輪對話後數字遞增,且手算對得上
- 疊圖:同一組對話比對你的數字和 opencode 的
- 之後才讀:階梯定價、為什麼 reasoning token 按 output 計價
實作記錄
step 04 第一版(2026-10-22)
走 provider 持有狀態 那條路:store: true + previous_interaction_id,不自己送歷史。readline 讀 stdin,每行觸發一次 interaction。
import { GoogleGenAI } from "@google/genai";
import readline from "node:readline";
const apiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY;
if (!apiKey) throw new Error("GOOGLE_GENERATIVE_AI_API_KEY is not set (see .env.example)");
const ai = new GoogleGenAI({ apiKey });
async function main() {
const rl = readline.createInterface({
input: process.stdin,
terminal: false,
});
const state = { text: "", steps: 0, usage: undefined as unknown };
let id: string;
rl.on("line", async (line) => {
const stream = await ai.interactions.create({
model: "gemini-3.5-flash-lite",
input: line,
stream: true,
store: true,
previous_interaction_id: id,
});
for await (const event of stream) {
if (event.event_type === "step.start") state.steps++;
if (event.event_type === "step.delta" && event.delta.type === "text") {
state.text += event.delta.text;
process.stdout.write(event.delta.text);
}
if (event.event_type === "interaction.completed") {
state.usage = event.interaction.usage;
id = event.interaction.id;
}
}
console.log(`###steps=${state.steps} text=${state.text.length} chars usage=`, state.usage);
});
}
main();step 04 第二版(2026-10-22)— 拿掉 store: true
const stream = await ai.interactions.create({
model: "gemini-3.5-flash-lite",
input: line,
stream: true,
- store: true,
previous_interaction_id: id,
});實測:拿掉 store: true 之後,記憶上次對話的功能仍然正常。 所以 previous_interaction_id 的串接不需要顯式開 store —— server 本來就留著那筆 interaction(可能 store 預設為 true,或 chaining 走的是另一條保存路徑)。待確認。
step 04 第三版(2026-10-22)— 註解掉 previous_interaction_id(對照實驗)
const stream = await ai.interactions.create({
model: "gemini-3.5-flash-lite",
input: line,
stream: true,
- previous_interaction_id: id,
+ // previous_interaction_id: id,
});實測:這樣就真的沒有記憶了。
備註:
- 這一版和第二版構成一組乾淨的對照實驗,變因只有
previous_interaction_id一個 → 記憶完全由它提供,與store無關。 - 證實了「模型本身沒有記憶」:同一個 process、同一個
aiinstance、歷史都還在state裡,但不傳previous_interaction_id就問不出上一句。記憶不在連線裡,也不在 client 裡,而是每次請求明確指定的那個 id。 - 這是 step 04 真正要撞到的那面牆:接下來要決定記憶歸誰 —— provider(
previous_interaction_id)還是自己的 harness(每輪重送完整歷史)。
step 04 收尾(2026-10-22)— 三個發現
state跨輪累積的污染:state宣告在rl.on("line")外面 → 第二輪的state.text是「第一輪 + 第二輪」,push 進歷史的 assistant 訊息互相包含,token 越滾越大。判準:變數的作用域要等於它的生命週期 ——messages跨輪所以在外面,state單輪所以在 callback 裡用const重新建立(比「每輪開頭手動歸零」好,不用靠記性)。console.log({ event })是 step 03 留下的 debug,會淹掉串流手感。- 內部格式 = provider 格式:
Message/Content直接用了 Gemini 的user_input/model_output。現階段可接受,但 opencode 刻意拆兩層(內部格式 +runner/to-llm-message.ts轉譯),回報會在 step 09(工具結果進歷史)和 step 13(壓縮歷史)兌現。
step 05(2026-10-23)— SQLite 落地
做法超出 roadmap 要求:TypeORM entity + repository、--session CLI 參數、bun/node 的 sqlite driver 分流(better-sqlite3 在 Bun 下會 panic,所以 Bun 走 bun:sqlite)。兩張表 session-message / session-inbox。
做對的:index 用顯式序號排序而非 createdAt、MAX(index)+1 包在 transaction 裡、state 已搬進 callback。
寫入時機的結論:每次都立刻寫,不要等 close
close不是可靠事件(kill -9、斷電、未捕捉 exception 都不會給你 shutdown hook)- step 12(kill -9 後續跑)直接依賴這點,資料只在記憶體就沒東西可接
- step 06 之後 SSE/其他 process 要讀同一個 session,DB 才是共用交會點
- 「批次寫比較快」是遠端 DB 的思維;SQLite 一次 insert 微秒級,而每輪之間隔著 13–44 秒的 API 等待
opencode 的證據:連 session_inbox(還沒處理的輸入)都進 DB,不放記憶體。
兩張表的分工(查證後更正)
| 表 | 存什麼 | 誰的內容 | 生命週期 |
|---|---|---|---|
session_message | 對話歷史 | 使用者 和 模型 | 永久 |
session_inbox | 待處理佇列 | 只有輸入側 | 被取用就 DELETE |
session_message.type是"user"/"assistant"(packages/schema/src/session-message.ts:81,215)session_inbox的delivery是"steer" | "queue"(packages/schema/src/session-inbox.ts:11),是取用時機的模式,不是「已處理」狀態;處理完是直接刪列- 模型輸出從不進 inbox:inbox 的 item 只有
user/synthetic/compaction/move - 更正:
session_pending表在現在的 opencode 裡沒有任何程式讀寫(SessionPendingTable只宣告在sql.ts:100),是被session_inbox取代的舊表
一句話
inbox 是「待辦」,message 是「已發生」。 使用者輸入經過兩張表(先排隊、取用時刪掉並寫進 message);模型輸出只經過一張,因為它是執行結果不是待執行工作。
step 05 的併發 bug(2026-10-23)
rl.on("line", async handler) —— readline 不 await listener,handler 碰到第一個 await 就 return Promise,readline 不理它,下一行一到就再呼叫一次。所以兩次 handler 是交錯執行的。最容易踩到的不是打字快,是貼上多行(stdin 收到 "a\nb\n",兩個 line 事件連發)。
單人、單 process、單 session 就會發生 —— 併發不需要第二個人,只要有 await 和會重複觸發的事件源。
實測重現(不碰 API,另開暫存 db 跑 repository):
[first] inbox row inserted pk=5b826d6c…
[second] inbox row inserted pk=a776d921…
[second] deleteRowWhenModelReply -> ok
[first] deleteRowWhenModelReply -> null
!! first threw: TypeError - null is not an object (evaluating 'userInput.sessionId')
兩個設計選擇把無害的併發放大成資料互相破壞:
insertRow裡呼叫deleteOldMessageWhenInsert(sessionId)—— 刪的是「這個 session 的全部」而不是「我自己的」,所以 B 把 A 的列刪了。沒這行的話兩列並存、各刪自己的 pk,不會有事。return lastMessage as SessionInbox——getOne()的型別是SessionInbox | null,cast 把 null 從型別抹掉,TS 不警告、執行時照樣炸。cast 沒有讓資料變非 null,只是讓編譯器閉嘴(跟前面as unknown是同一個教訓的兩端:一個太緊禁止存取,一個太鬆隱藏 null)。
已修:handler 序列化(三個修法中的第 3 個,根治)
+ // readline does not await the listener, so a second line arriving mid-stream would run a
+ // second handler concurrently. Chaining on one promise keeps the turns strictly ordered.
+ let queue: Promise<void> = Promise.resolve();
+
+ rl.on("line", (line) => {
+ queue = queue.then(() => handleLine(line)).catch((error) => console.error(error));
+ });
+
+ async function handleLine(line: string) {
- rl.on("line", async (line) => {
const state = { text: "", steps: 0, usage: undefined as unknown };
...
- });
+ }選第 3 個而不只是修前兩個的理由:交錯執行還有第二個 bug —— messages 陣列順序會錯亂成 [user1, user2, model2, model1](第二句比第一句早回時),送給模型的歷史問答對不上。序列化一次關掉整類問題。
驗證(同樣不碰 API):
[first] inbox inserted
[first] deleted -> ok
[second] inbox inserted
[second] deleted -> ok
done, no crash
這是 opencode 那套的縮小版:inbox.ts:59-63 用 KeyedMutex 鎖住同一 session 的所有 inbox 操作(serialized(sessionID, effect)),單一執行者依序取用佇列。差別只是它的鎖是 per-session。而且序列化之後 inbox 表才真的有意義 —— 第二句進來時第一句還沒處理完,它會真的在 inbox 裡排著。
還沒修(降級為非緊急)
insertRow裡的deleteOldMessageWhenInsert還在(序列化之後不會再造成資料破壞,但語意上仍讓 inbox 無法排隊)deleteRowWhenModelReply的as SessionInbox還在;更好的方向是根本不要從 inbox 讀回字串(line和sessionId在 closure 裡都有),順序也該改成「先寫 message 再刪 inbox」,否則兩步之間崩潰等於訊息消失
step 05 其他已修(2026-10-23)
getSessionId的catch吃掉自己丟的錯誤:驗證的throw寫在try裡面,所以精確訊息永遠被換成誤導的「please provide session ID」。已把驗證搬到 try 外面。hasSession用inbox || message的 OR:崩潰後若只剩 inbox 列,會判定 session 存在但還原出空歷史,孤兒列永遠不被重播。已改成只看session-message(durable history 才是能否續接的唯一依據)。使用者的判斷:進 session 就自動送出上次未完成的輸入不合理,意圖可能早就變了 —— 所以 inbox 不重播是刻意的。
一個討論中被使用者推翻的建議
我原本建議「--session 給了不存在的 id 就尊重它、用它建新 session」。使用者反對且正確:多租戶 + 雲端 DB 下直接相信傳入的 session id 等於讓人猜別人的 session,不該信。保留下來的只有「別無聲」—— 現在只有 close 時才印 session: …,應該在開頭就印或在 id 不存在時明說「開新的」。