! 本篇文章將會介紹 Exit hooks:Agent 做完事自動觸發下一步,期望大家都能讓 agent 的生命周期自己開口說話,把「在外面等它做完」的苦差事交給事件 :D
鐵人賽進入第二週,主題從「會用 agent」升級成「自動化核心」。回顧 W1,generate.sh 的世界觀是「拉」:bash 腳本站在門外,喊一聲 opencode run,然後盯着 exit code 發呆,結束了才驗檔案、判品質、決定要不要重試。這套能用,但有點笨——agent 明明最清楚「我做完了」,卻只能等我們從外面猜。今天就來翻轉這個關係:用 hooks 讓 agent 在生命周期的關鍵時刻主動出聲。做完事、出了錯、要碰工具之前,它都會喊一嗓子,而我們的工作只是決定「聽到之後要做什麼」。這就是事件驅動的自動化骨架。
讀完這篇你會學到:
# hooks 住在 plugin 目錄裡,先看看兩個位置現況
ls .opencode/plugins/ 2>/dev/null # 專案級:只對這個專案生效
ls ~/.config/opencode/plugins/ 2>/dev/null # 全域:所有專案都會載入
先講一個找錯地方的故事。我最直覺的反應是打開 opencode.json,找一個 hooks: {} 之類的欄位——結果官方文件翻半天,設定檔裡根本沒有這種東西。opencode 的 hooks 是 plugin 系統提供的:plugin 是一個 JavaScript/TypeScript 模組,放對目錄就會在 opencode 啟動時自動載入,它回傳一個物件,裡面每個 key 就是一個掛點。
目錄有三個來源:
.opencode/plugins/——專案級,跟著 repo 走~/.config/opencode/plugins/——全域,所有專案共用(我機器上就躺著 herdr 裝的狀態同步 plugin)plugin 陣列裡,啟動時自動安裝plugin 函數會收到一包 context:project、client(opencode SDK client)、$(Bun 的 shell API,可以直接在 JS 裡跑指令)、directory、worktree。最小骨架長這樣:
// .opencode/plugins/hello-hook.js
export const HelloHook = async ({ project, client, $, directory, worktree }) => {
return {
// 所有 hooks 掛點都寫在這個回傳物件裡
}
}
存檔即完成安裝,沒有註冊、沒有重載指令。這種樸素我喜歡:D
所謂「exit hook」,在 opencode 裡的本體就是 session.idle 事件:agent 跑完回應、session 進入閒置的那一瞬間,opencode 會廣播這個事件。透過 event 掛點訂閱,就能在「做完事」的當下接手:
// .opencode/plugins/article-done.js
export const ArticleDonePlugin = async ({ $ }) => {
return {
event: async ({ event }) => {
// agent 做完事:session 進入閒置
if (event.type === "session.idle") {
await $`osascript -e 'display notification "文章生成完成!" with title "鐵人賽"'`
}
// agent 回報的錯誤
if (event.type === "session.error") {
await $`osascript -e 'display notification "有 session 出錯了,快看 log" with title "鐵人賽"'`
}
},
}
}
驗證方式很簡單:
# 隨便派一個小任務,結束的瞬間看右上角
opencode run "用一句話介紹鐵人賽"
# ---- 預期輸出 ----
# macOS 通知中心彈出「鐵人賽:文章生成完成!」
對排程場景有個好消息:opencode run 每次都是全新 process,啟動當下就會載入 plugin 目錄裡的最新版,不存在「改了沒生效」的問題(長駐 process 的例外狀況留到踩坑記錄講)。
小小小測驗:你知道
session.idle不只在你結束對話時發,subagent 的小 session 做完事也會各發一次嗎?派三個 subagent 平行工作的任務,這個 hook 會響好幾次——過濾方法在步驟三。
第一件:完成回報。 macOS 通知只給本機看,耍廢組要的是躺沙發上滑手機也能看到戰報。plugin 跑在 Bun 上,fetch 是原生的,串 Discord webhook 一行搞定:
// .opencode/plugins/article-done.js(節選)
event: async ({ event }) => {
if (event.type !== "session.idle") return
// 只在排程場景反應:generate.sh 匯出 IRONMAN_GEN=1,
// 平常互動使用沒有這個變數,通知就不會亂炸
if (process.env.IRONMAN_GEN !== "1") return
const webhook = process.env.DISCORD_WEBHOOK // 值永遠從環境變數來,不寫死
if (webhook) {
await fetch(webhook, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: "✅ 今日文章生成完成,等 20:00 發佈" }),
})
}
},
兩個細節:過濾用環境變數當開關(generate.sh export 的變數會一路傳進 opencode 再傳進 plugin),避開 subagent 重複觸發的問題;webhook 走 process.env 而不是寫死在檔案裡,plugin 才敢放心進 git。這兩招也正好是這個系列機敏紀律的延伸。
第二件:錯誤告警。 上面的 session.error 分支就是種子——agent 自己喊出錯時立刻推送告警,而不是等 20:00 的發佈腳本爆炸了才發現。怎麼把告警分級、怎麼設重試,第 12 天「錯誤處理」和第 13 天「Discord 告警」會專文展開,這裡先立旗。
第三件:機敏守衛。 還記得 d04 首稿被關進 quarantine 的故事嗎?當時的防線是「事後掃描」:文章寫完了,掃到機敏字串才隔離重生成。hooks 可以把防線推進到「事前擋下」:
// .opencode/plugins/article-done.js(節選)
"tool.execute.before": async (input, output) => {
// agent 的 read 工具想碰 secrets.env?直接擋下
if (input.tool === "read" && String(output.args.filePath ?? "").includes("secrets.env")) {
throw new Error("機敏檔案禁止讀取(secrets.env)")
}
},
在工具執行前攔截,丟出錯誤就等於否決這次呼叫。從此 agent 連「看一眼」secrets.env 的機會都沒有,事後掃描繼續留著當第二道保險——雙保險互為備援,這就是防禦工事的堆法。
Q:在 opencode.json 找半天,就是沒有 hooks 相關的設定欄位?
A:因為沒有這個欄位。hooks 由 plugin 提供,檔案丟進 plugins 目錄就自動載入;只有 npm 形態的 plugin 才需要動 opencode.json(plugin 陣列)。我照著「設定直覺」找了半小時才認命改查 plugin 文件,希望你看到這段就不用繞這圈。
Q:plugin 裡的 console.log 訊息憑空消失?
A:headless run 的 stdout 早就被 agent 輸出淹沒,TUI 裡更是沒地方給它印。官方建議改用 client.app.log() 寫結構化日誌(帶 service、level、message、extra 欄位),會進到 opencode 自己的 log 體系;或者土一點,像本系列直接用 $ append 到檔案,跟 generate.sh 的 log 放一起方便交叉比對。
Q:通知炸個不停,連 subagent 收工都在響?
A:session.idle 是每個 session 各發一次,subagent 的 child session 也算。最簡單的解法就是步驟三的環境變數開關:只有排程腳本明確 export 的場景才動作。真的需要精細分辨,事件 payload 的 event.properties.sessionID 可以拿到觸發事件的 session id,再配合 SDK 查 session 詳細資訊過濾。
Q:改了 plugin 檔案,開著的 opencode 都沒吃到新版?
A:plugin 是在 opencode 啟動時載入的。opencode run 每次都是新 process,永遠吃最新版;但已經開著的 TUI 或長駐 server 要重啟才會重載——排程場景不受影響,互動開發時記得這條就好。
event、tool.execute.before、tool.execute.after、shell.env 都是掛點session.idle:agent 做完事自己出聲,通知、Discord 戰報都在這一刻發生下一篇我們要介紹「為什麼是 launchd 而不是 cron:macOS 排程的正確姿勢」,hooks 負責「做完事喊一聲」,launchd 負責「準時把它叫醒」,W2 自動化核心的兩根支柱即將會師,敬請期待!
參考資料:
有任何疑問但沒有 iT 邦幫忙帳號,或是想匿名提問?
歡迎到 https://dev.benben.me/q/Z5442T 提問或加油打氣,沒意外的話會在完賽之後一起回答 :D