iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0

! 本篇文章將會介紹 Exit hooks:Agent 做完事自動觸發下一步,期望大家都能讓 agent 的生命周期自己開口說話,把「在外面等它做完」的苦差事交給事件 :D

鐵人賽進入第二週,主題從「會用 agent」升級成「自動化核心」。回顧 W1,generate.sh 的世界觀是「拉」:bash 腳本站在門外,喊一聲 opencode run,然後盯着 exit code 發呆,結束了才驗檔案、判品質、決定要不要重試。這套能用,但有點笨——agent 明明最清楚「我做完了」,卻只能等我們從外面猜。今天就來翻轉這個關係:用 hooks 讓 agent 在生命周期的關鍵時刻主動出聲。做完事、出了錯、要碰工具之前,它都會喊一嗓子,而我們的工作只是決定「聽到之後要做什麼」。這就是事件驅動的自動化骨架。

本篇目標

讀完這篇你會學到:

  • opencode hooks 的真身:它們住在 plugin 檔案裡,而不是 opencode.json 的某個設定欄位(這個誤會讓我多花了半小時)
  • 寫出第一個 exit hook:agent 做完事自動觸發通知,從 macOS 跳通知到 Discord 回報
  • 把 hooks 接進鐵人賽系統:完成回報、錯誤告警、機敏檔案守衛三件套

環境準備

  • 裝好的 opencode(plugin 機制各版本都有,本篇以 v2 為準)
  • macOS(通知示範會用到 osascript)
  • 想接 Discord 的話,先備好 webhook URL——放環境變數就好,等一下會解釋為什麼絕不能寫死在檔案裡
# hooks 住在 plugin 目錄裡,先看看兩個位置現況
ls .opencode/plugins/ 2>/dev/null            # 專案級:只對這個專案生效
ls ~/.config/opencode/plugins/ 2>/dev/null   # 全域:所有專案都會載入

主要內容

步驟一:hooks 住在 plugin 裡,不是設定檔裡

先講一個找錯地方的故事。我最直覺的反應是打開 opencode.json,找一個 hooks: {} 之類的欄位——結果官方文件翻半天,設定檔裡根本沒有這種東西。opencode 的 hooks 是 plugin 系統提供的:plugin 是一個 JavaScript/TypeScript 模組,放對目錄就會在 opencode 啟動時自動載入,它回傳一個物件,裡面每個 key 就是一個掛點。

目錄有三個來源:

  • .opencode/plugins/——專案級,跟著 repo 走
  • ~/.config/opencode/plugins/——全域,所有專案共用(我機器上就躺著 herdr 裝的狀態同步 plugin)
  • npm 套件——寫在 opencode.json 的 plugin 陣列裡,啟動時自動安裝

plugin 函數會收到一包 context:projectclient(opencode SDK client)、$(Bun 的 shell API,可以直接在 JS 裡跑指令)、directoryworktree。最小骨架長這樣:

// .opencode/plugins/hello-hook.js
export const HelloHook = async ({ project, client, $, directory, worktree }) => {
  return {
    // 所有 hooks 掛點都寫在這個回傳物件裡
  }
}

存檔即完成安裝,沒有註冊、沒有重載指令。這種樸素我喜歡:D

步驟二:第一個 exit hook——agent 做完事自動出聲

所謂「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 要重啟才會重載——排程場景不受影響,互動開發時記得這條就好。

小結

  • hooks 住在 plugin 裡:放對目錄自動載入,eventtool.execute.beforetool.execute.aftershell.env 都是掛點
  • exit hook 的本體是 session.idle:agent 做完事自己出聲,通知、Discord 戰報都在這一刻發生
  • 鐵人賽三件套就位:完成回報、錯誤告警、機敏守衛;防護從「事後掃描」推進到「事前擋下」
  • 事件驅動的骨架完成了,但「每天固定時間叫 agent 開工」還缺一顆引擎——那就是明天的主角

明日預告

下一篇我們要介紹「為什麼是 launchd 而不是 cron:macOS 排程的正確姿勢」,hooks 負責「做完事喊一聲」,launchd 負責「準時把它叫醒」,W2 自動化核心的兩根支柱即將會師,敬請期待!

參考資料:

有任何疑問但沒有 iT 邦幫忙帳號,或是想匿名提問?
歡迎到 https://dev.benben.me/q/Z5442T 提問或加油打氣,沒意外的話會在完賽之後一起回答 :D


上一篇
07 第一週回顧:踩坑與成果
下一篇
09 為什麼是 launchd 而不是 cron:macOS 排程的正確姿勢
系列文
自我耍廢組:全自動化の鐵人12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言