自己 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 不能帶 Authorization header
  • 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/ + READMEprovider 中立的模型呼叫層,LLM.generate / LLM.stream
packages/cli/src/index.tsCLI 入口(動態 import 壓低啟動成本)
packages/server/src/process.tsserver 入口、綁 port、dispatch 就緒閘門
packages/protocol/src/groups/session.ts:320POST /api/session/:id/prompt 的契約
packages/server/src/handlers/session.ts:279prompt 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.tsstep 外層:重載歷史、重試、壓縮、發事件
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.tsSSE 端點(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.ts 5 秒內印出一行非空字串
  • 疊圖:無(目的是先有一個會動的東西)
  • 之後才讀:無,先不要讀

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 -N opencode 的 /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 秒一行心跳;kill server 後客戶端 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 切 plan agent 問同一句,看它怎麼被擋
  • 之後才讀:capability 模型、為什麼 plan agent 用權限規則而不是換 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、同一個 ai instance、歷史都還在 state 裡,但不傳 previous_interaction_id 就問不出上一句。記憶不在連線裡,也不在 client 裡,而是每次請求明確指定的那個 id。
  • 這是 step 04 真正要撞到的那面牆:接下來要決定記憶歸誰 —— provider(previous_interaction_id)還是自己的 harness(每輪重送完整歷史)。

step 04 收尾(2026-10-22)— 三個發現

  1. state 跨輪累積的污染:state 宣告在 rl.on("line") 外面 → 第二輪的 state.text 是「第一輪 + 第二輪」,push 進歷史的 assistant 訊息互相包含,token 越滾越大。判準:變數的作用域要等於它的生命週期 —— messages 跨輪所以在外面,state 單輪所以在 callback 裡用 const 重新建立(比「每輪開頭手動歸零」好,不用靠記性)。
  2. console.log({ event }) 是 step 03 留下的 debug,會淹掉串流手感。
  3. 內部格式 = 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 不存在時明說「開新的」。