SLOPSHOPPER

pr-bridge-watch

gh pr create / pr-push.sh で PR ができたら、その branch の CI を gh-actions-bridge の ws /watch に Monitor で繋ぐ Claude Mod (function hooks)

newguardpromptprocessnetwork
v0.1.1no licenseupdated 2026-10-07ippoan/gh-actions-live/mods/pr-bridge-watch
A shopper browsing a rack in a slop shop
README

GitHub Actions Live

GitHub の repo の Actions ページを起点に、実行状態をポーリングせず受け取る Chrome 拡張。 repo ごとの列 + run カードで並べるので、同一 repo で並列に走っている run が全部同時に見える。

何をどこから取るか

1. GET https://github.com/<owner>/<repo>/actions   (cookie 付き)  = スナップショット
     ├─ .Box-row[id^=check_suite_]        → 全 run (畳まれていない)
     ├─ link[rel=shared-web-socket]       → alive の socket URL
     └─ data-channel                      → 署名済み購読トークン
2. wss://alive.github.com/_sockets/u/<userId>/ws?session=...
     → {"subscribe": {"<トークン>": null}}  →  {"e":"ack","off":"...","health":true}
   **この socket は github.com のタブ (content script) で張る。** 拡張ページから張ると
   Origin が chrome-extension:// になり alive が握手直後に 1006 で切る。
   background が監視対象 repo の Actions ページを pinned タブで開いて場所を用意する
   (pinned タブを閉じても / 他サイトへ移しても background が即ダッシュボードへ `closed` を伝え、数秒で開き直す #36)
3. push が来たら、その run の partial (/actions/workflow-run/<checkSuiteId>, 約 10KB) だけ
   取り直して状態を確定する

別の API を叩かないのが要点。Actions ページの取得そのものがスナップショットなので、 初期状態を取るための追加エンドポイントは要らない。

3 は GitHub 自身のクライアント (js-updatable-content + data-url) と同じ方針で、 未文書な push ペイロードの形に依存しないための作り。

なぜ集約ダッシュボードの WebSocket を使わないか

CI 状態を集約して WebSocket で配信するダッシュボードは珍しくないが、その手の実装は たいてい repo あたり「最新の in-progress」と「最新の completed」だけに畳んでいる。 一覧としては見やすいが、同一 repo で複数の workflow が並列に走るとそれらが 1 件に潰れる。

同じ tag に対して CI と複数の deploy worker が同時に走るような構成では、 畳まれた側からは 1 本しか見えない。潰れないのは GitHub 自身の alive socket (check_suites:<id> 単位) のほうで、本拡張がそちらを直接読む理由がこれ。

なお webhook 経由の集約は取りこぼしが起きる。実運用の集約基盤が 「一定時間 in_progress のまま残った run を API から取り直す」補償を持っているのは そのためで、webhook は「来たら早い」が「来ないことがある」経路だと考えたほうがよい。

なぜブラウザ拡張なのか

タブに JS を挿すRust / Worker拡張
タブを開いたままにする必要要る不要不要 (このウィンドウが本体)
セッションを外に持ち出す不要user_session を貼る必要あり不要
変化を外に push できるしにくいできるできる

user_session は 2FA を素通りするアカウント全権の資格情報なので、ブラウザの外に出さないのが要点。 拡張は「ブラウザの中」なので、host_permissions があれば fetch に cookie が自動で付く。

認証の境界 (実測)

ログイン有無認証
data-channel トークン26 個26 個 (貰える)
link[rel=shared-web-socket]あり無し (HTML に alive の言及 0 件)
partial /actions/workflow-run/<id>200 / 10.9KB404
フルページ200 / 440KB200 / 440KB

→ push はログイン機能。無認証でできるのは HTML / atom のポーリングまで。

ウィンドウ

ツールバーのアイコンをクリックすると、タブバーもアドレスバーも無い独立ウィンドウで開く。 設定画面から 独立ウィンドウ / 最大化 / 全画面 / タブ を選べる。

chrome.windows.create({ url, type: 'popup', width: 1280, height: 820 })  // アプリ風
chrome.windows.create({ url, type: 'normal', state: 'maximized' })       // 最大化
chrome.windows.create({ url, type: 'popup',  state: 'fullscreen' })      // 全画面

このウィンドウが fetch も WebSocket も描画も全部持つ。service worker は ウィンドウを開くのと通知を出すだけなので、MV3 の service worker 寿命問題に当たらない。

インストール

MSI は 1 つで、既定は perUser (admin 不要)。管理端末向けの機能は ALLUSERS=1 を付けたときだけ出る。

端末コマンド何が起きるか
非管理 (Windows Home / WORKGROUP / 個人 PC) — こちらが既定msiexec /i gh-actions-live-x.y.z-x64.msi (ダブルクリックでも同じ)%LOCALAPPDATA%\Programs\gh-actions-live\extension に配置 + 自動更新タスクを登録。Chrome への初回読み込みだけ手動
管理端末 (AD / Entra / Chrome Enterprise Core 登録済み)msiexec /i gh-actions-live-x.y.z-x64.msi ALLUSERS=1 (UAC 昇格)C:\Program Files\gh-actions-live に配置 + HKLM に ExtensionSettings (force_installed)。Chrome 再起動だけで入り、更新も Chrome が拾う
非管理端末を Chrome Enterprise Core に登録したい… ALLUSERS=1 ENROLLMENTTOKEN=<token>上に加えて CloudManagementEnrollmentToken を書く。token は Google Admin で発行

なぜ分かれるか: Chrome の公式仕様で、Windows では Web Store 外 の拡張を force_installed できるのは 管理端末だけ。非管理端末ではレジストリに正しく書けていても Chrome が黙って捨てる (chrome://policy に [BLOCKED] + 警告で出る)。詳細は #9。 自分の端末は dsregcmd /status で分かる (AzureAdJoined / DomainJoined / EnterpriseJoined が全部 NO なら非管理)。

非管理端末での手順 (既定)

  1. Releases から gh-actions-live-*-x64.msi を落とし、 設定ごと インストールする (admin 不要):
   msiexec /i gh-actions-live-x.y.z-x64.msi REPOS=owner/repo,owner/repo2 BRIDGEURL=ws://host:8799 NOTIFY=0

property は全部任意。渡した値は extension\config.json に書かれ、拡張が起動時に取り込む。 設定画面での手入力は不要。拡張を入れ直して chrome.storage が消えても config.json から戻る。 あとから変えたいときは同じコマンドを渡し直す (upgrade で上書き)。 ダブルクリックで入れた場合は設定画面から手で入れる (従来どおり)

  1. chrome://extensions → 右上「デベロッパー モード」ON → 「パッケージ化されていない拡張機能を読み込む」→ %LOCALAPPDATA%\Programs\gh-actions-live\extension
  2. ID が oaadakmclelmnaieokjbhldfacfckaaj になっていることを確認。その Chrome プロファイルで GitHub にログインしていること

以降の更新はダッシュボードの「更新」ボタン 1 クリック。MSI を入れ直す必要は無い。

更新の仕組み

Chrome は Web Store 外の拡張の update_url を相手にしないので自前で組む。拡張はディスクに 書けないため、MSI が小さな native messaging host (host.bat → host.ps1) を HKCU\Software\Google\Chrome\NativeMessagingHosts に登録する (Policies 配下ではないので admin 不要)。

  1. ヘッダに v0.0.12 → v0.0.13 あり と出たら 「更新」ボタンを押す
  2. 拡張が host を呼び、host が update.ps1 を実行: update.xml の版を見て gh-actions-live-extension.zip を落とし、sha256 を照合して extension\ を差し替える (ログは update.log)
  3. background が reloadSelf(): 先にダッシュボードのタブを about:blank に差し替えてから chrome.runtime.reload()。reload 後は同じタブにダッシュボードを読み込み直す (何もせず reload すると空ウィンドウが残る → #30。閉じて開き直すと新規ウィンドウが 前面に出ない → #32)

Linux 側 (bridge) から {"command":"update"} を送っても同じことが起きる。 定期的に勝手に上げたい人は update.ps1 -Register でタスク スケジューラに登録できる (既定では登録しない)。

MSI を使わずに zip を展開して読み込むこともできる。その場合は host が無いので、 新しい zip を同じフォルダに上書き展開して拡張カードの ↻ を押す。

再読込 / 更新の手動確認 (実機でしか分からない)

拡張の再起動 (chrome.runtime.reload()) はページを殺すだけでウィンドウは閉じないので、 放っておくと空のウィンドウが 1 枚残る (#30)。かといって先にタブを閉じると、復活が新規ウィンドウに なり、reload 直後の service worker からでは Windows の foreground lock で前面に出ない (#32)。 reload は background の reloadSelf() に一本化してあり、先にダッシュボードのタブを about:blank に差し替えてから reload し、onInstalled で同じタブに読み込み直す。 実機で見るのはここ:

  1. ダッシュボードを開いた状態で「再読込」→ 同じウィンドウのまま中身が復活し、前面にあること (空白のウィンドウが増えていない / ウィンドウが消えていない)
  2. bridge から {"command":"update"} / {"command":"reload"} を送っても同じこと
  3. 10 分ごとの self-update (ディスクの版が上がったとき) でも同じこと

管理端末での手順

ALLUSERS=1 を付けて実行 (UAC 昇格が 1 回) → Chrome を再起動。それだけで入る。 HKLM の ExtensionSettings は update_url が releases/latest/download/... の固定 URL なので、 版が上がってもポリシーを書き換えなくてよい。Chrome に「組織によって管理されています」が出て、 ユーザーは拡張を Chrome から削除できない (アンインストールは「アプリと機能」から MSI を消す)。

HKCU\Software\Policies はユーザー権限で書けないため、ポリシー系は ALLUSERS=1 (HKLM) のときだけ出る。

トラブルシュート

  • chrome://policy に ExtensionSettings が載っていない → ALLUSERS=1 で入れたか。 reg query "HKLM\SOFTWARE\Policies\Google\Chrome" /v ExtensionSettings で単一の REG_SZ に JSON が入っているか。Chrome は再起動したか
  • 載っているが [BLOCKED] + 警告 → 端末が非管理。仕様なので perUser (既定) の手順を使う
  • 「更新」ボタンが失敗する → %LOCALAPPDATA%\Programs\gh-actions-live\update.log を見る。 「native host が見つかりません」なら MSI で入れていない (zip 展開) か、 reg query HKCU\Software\Google\Chrome\NativeMessagingHosts\jp.ippoan.gh_actions_live が無い

リリースサイクル

バージョンは自動採番する。直近の v* tag の patch を +1、tag が無ければ 0.0.1 から。

  • stable: main への push で自動的に次の版を採番し、vX.Y.Z の Release を作る (Latest)
  • auto-merge: PR (non-draft) の CI が全部緑になると、org 標準の reusable (ippoan/ci-workflows/.github/workflows/auto-merge.yml) が squash merge を queue する。 PR を出して緑になれば、そのまま新しい版が公開される
  • dev: PR (non-draft) の CI が dev-<run_number> の prerelease を PR head に打つ。 merge を待たずに MSI を試せる。 版は「その PR が出す予定の stable と同じ」
  • 手動: 任意の版を切りたい場合は Release workflow を workflow_dispatch で 実行し、version に X.Y.Z を指定する

tag は Release 作成時に打たれる。手で tag を push する経路は持たない (二重採番になるため)。 manifest の version は tag から stamp されるので、repo に入っている値は開発用の目安。

リポジトリ構成

extension/          MV3 拡張本体 (これを Chrome に読み込む)
installer/main.wxs  MSI (WiX v4+、perUserOrMachine)。配置 + 自動更新タスク + (ALLUSERS=1 で) Chrome ポリシー
installer/update.ps1 更新スクリプト (native host / 手動 / 任意でタスク登録)
installer/host.ps1   native messaging host (「更新」ボタンの実体)
bridge/             Claude Code (Linux) 側の常駐リレー (Rust / systemd --user。下記)
mods/pr-bridge-watch Claude Mod: PR を作ったら branch の CI を bridge の /watch に繋ぐ (下記)
.claude-plugin/     上の mod を配る plugin marketplace

既知の制約

  • 未テスト。alive のプロトコル (接続・購読・ack)、認証境界、partial のサイズは 実機で確認済みだが、拡張として読み込んだ状態での動作確認はまだ。
  • push ペイロードの形は未確認。生サンプルを 50 件まで保存して設定画面に出すので、 形が分かったら ws.onmessage を絞り込める。
  • data-channel のトークンは発行時刻 t 入りの時限。20 分ごとにページを取り直して更新する。
  • 未文書の内部プロトコルなので、GitHub 側の変更で黙って壊れうる。

Claude から設定を入れる (github.com 経由)

拡張は externally_connectable で https://github.com/* からのメッセージを受ける。 Claude in Chrome は github.com のタブで JS を実行できるので、設定画面を触らずに そこから設定を流し込める (bridge URL が未設定でも届く):

// github.com のタブで
chrome.runtime.sendMessage('oaadakmclelmnaieokjbhldfacfckaaj',
  { command: 'set-config', repos: ['owner/repo'], bridgeUrl: 'ws://host:8799', notify: false },
  r => console.log(r));
// command: get-config / open-dashboard / access-login / update / check-update / native-ping / status / alive-reset / reload も同じ経路で使える

Claude Code への途中通知 (bridge)

拡張は GitHub を見ているだけなので、そのままでは Claude Code (別マシン) に何も届かない。 bridge/ (Rust の常駐サーバー gh-actions-bridge) を Linux 側で 1 本だけ 動かし、拡張からそこへ outbound で WebSocket を張ると双方向になる。Claude Code の各セッションは bridge に繋ぐだけで、 bridge 自体を起動しない (セッションが Monitor で起動していた頃は 8799 を取り合い、起動したセッションが 終わると bridge ごと落ち、全 repo の変化がそのセッションにだけ流れていた)。

Windows Chrome 拡張 ──ws://<linux>:8799/?role=extension──▶ gh-actions-bridge (systemd --user)
        ▲                                                        │ ws /watch?repo=…&run=…
        └──── {"type":"command",...} ◀── POST /cmd               ▼ (条件に合う run だけ、1 フレーム 1 行)
                                                       Claude Code の各セッション (Monitor の ws ソース)

導入 (Linux 側、1 回だけ):

cargo install --path bridge
cp bridge/gh-actions-bridge.service ~/.config/systemd/user/
systemctl --user enable --now gh-actions-bridge

bridge 自体の出力 (全 repo の変化・接続ログ) は journalctl --user -u gh-actions-bridge -f。

  • 見張り (/watch): セッションごとに条件を付けて繋ぐ。一致した run の変化だけがテキストで届く `` Monitor({ ws: { url: "ws://127.0.0.1:8799/watch?repo=ippoan/rust-alc-api&workflow=CI&run=1619" }, ... }) ` 条件は repo / ref / run / workflow (部分一致・大小無視) / by。同じ key を重ねると OR、違う key は AND。 条件なしは 400 (全量が流れるのを防ぐ。本当に全部見るなら all=1`)
  • 繋いだ時点の状態を bridge の写しから出す (進行中の run と、run 指定の run)。 既に終わった run を指定しても待ちぼうけにならない
  • run を指定すると、その run が全部終わった時点で bridge が socket を閉じる (= Monitor の見張りが終わる)。 run 番号は workflow ごとなので workflow と併用する。re-run は同じ番号で走り直すので、閉じた後に re-run したら繋ぎ直す
  • ref は完全一致 (拡張が 40 文字で切るので長い branch 名は先頭 40 文字で比べる)。ref だけの見張りは閉じない
  • 拡張 (ダッシュボード) が bridge から外れた / 戻ったときも 1 行出す (目隠しを黙らない)。bridge が落ちれば socket が閉じる
  • 生の JSON が欲しい購読者は /?role=listener (繋いだ時点で bridge の写しを snapshot として 1 回受ける。送った JSON は拡張へのコマンド)
  • Linux → 拡張 (設定): curl -X POST localhost:8799/cmd -d '{"command":"set-config","repos":["owner/repo"],"notify":false}' で repo を変えられる。get-config で現在値を返す。repos は全セッション共通なので、 自分の PR に絞るのは set-config ではなく /watch の条件で
  • Linux → 拡張: curl -X POST localhost:8799/cmd -d '{"command":"open-dashboard","mode":"popup"}' でウィンドウを遠隔で開ける。refresh / snapshot も受ける
  • Cloudflare Access のログイン承認: curl -X POST localhost:8799/cmd -d '{"command":"access-login","url":"<cloudflared access login が出した URL>"}' で承認ページを前面のウィンドウに開く。トークンを取りに行くのは Linux 側の cloudflared 自身なので、 ブラウザは「Approve」を押すだけでよく、Windows の Chrome で承認しても通る。 bridge には認証が無く、8799 に届く者が Chrome で任意のページを開けると capability の穴になるので、 開ける URL は 3 条件を全部満たすものだけ:
  • スキームが https:
  • ホストが accessLoginHosts に完全一致 (後方一致・ワイルドカードは無し。 endsWith('.ippoan.org') 型は evil-ippoan.org のような取り違えを招くため)。 未設定・空配列なら全部拒否 (deny-by-default) — 設定を入れ忘れた環境が一番危険になる既定は採らない
  • パスが /cdn-cgi/access/cli ちょうど (new URL() が .. を畳んだ後の値で完全一致)

加えて userinfo 付きの URL は無条件で拒否する。https://dtako.ippoan.org@evil.example.com/... は文字列としては正規のホストに見えるのに実際に開く先は evil.example.com で、 URL.host に userinfo は入らないためホスト検査だけでは防げない (なりすまし)。

許可ホストの設定 / 確認:

  curl -X POST localhost:8799/cmd -d '{"command":"set-config","accessLoginHosts":["dtako.ippoan.org"]}'
  curl -X POST localhost:8799/cmd -d '{"command":"get-config"}'   # accessLoginHosts が返る

弾いた URL はログにも応答にも全体を出さない (token= の nonce が乗っている。出すのはホスト名とパスまで)

  • 診断: curl -X POST localhost:8799/cmd -d '{"command":"status"}'。ダッシュボードからは {"type":"status", alive:{connected, fails, lastState, lastMessageAt, idleMs, idleLimitMs, idleResets, watchdogArmed, idleArmed, reconnectPending, background:{state, lastMessageAt, idleMs, relay:{readyState, tokens, lastFrameAt, sinceLastFrameMs, frames}}}}、 service worker からは {"type":"ack","command":"status", alive:{tab, state, lastMessageAt, idleMs, relay}} が返る (relay.readyState は 0=CONNECTING 1=OPEN 2=CLOSING 3=CLOSED、null = socket 無し)
  • connected:true は「生きている」の証明にならない (#28)。half-open (TCP が静かに死ぬ) だと readyState は OPEN のままで ws.send() も通るので、購読し直しは成功したように見える。 生死は フレームを受け取った時刻 で見る: alive.idleMs (ダッシュボードが最後にフレームを受けてからの ms) と alive.background.relay.sinceLastFrameMs (socket を持つ github.com のタブ側の実測)。 idleMs が idleLimitMs (既定 10 分) を超えるとダッシュボードが自分で close → connect で張り直し、 idleResets が 1 増える。ダッシュボードのヘッダにも「最終受信 HH:MM:SS」として出る
  • 強制再接続: curl -X POST localhost:8799/cmd -d '{"command":"alive-reset"}'。relay の socket を閉じ、 ダッシュボードが張り直す (閉じていれば開く)。status が connected:false のまま戻らないときに
  • 拡張側は 設定画面の「Linux 側リレーの URL」 に ws://<host>:8799 を入れる
  • service worker も 1 本張っていて、ダッシュボードが閉じていても open-dashboard を受けられる (リレーの 20 秒 ping → pong の往来で MV3 の service worker が生き続ける)
  • 認証は無い。tailnet / LAN 内で使う前提。外に出すなら前段に Access 等を置く

PR を作ったら bridge に自動で繋ぐ (Claude Mod)

mods/pr-bridge-watch は Claude Code の function hooks (製品名 Claude Mods、early access) で書いた plugin。 Bash の tool.call を包み、gh pr create / pr-push.sh が成功して PR の URL が出たら:

  1. gh pr view <url> --json headRefName で branch を取る (失敗したら command の --head)
  2. bridge の GET / で生死を見る
  3. $.tool.call({ tool: 'Monitor', ws: { url: 'ws://127.0.0.1:8799/watch?repo=…&ref=<branch>' }, persistent: true })
  4. 結果に context を 1 行足す (「繋いだ (task …)。gh run list で polling しない」)

bridge が落ちている / Monitor が拒否されたときは張らずに、自分で張る Monitor(...) の引数を context に書く (見張りが黙って欠けるより model に拾わせる)。同じセッションで同じ branch は二度張らない。

張らない環境が 2 つある。どちらも「自分で張れ」は返さず、1 行の事実だけを context に足す:

  • Claude desktop (Code タブ) では張ろうとしない (CLAUDE_CODE_ENTRYPOINT が claude-desktop)。app 側が PR の CI を 監視していて、model が Monitor で CI を見張ることを禁じているため。bridge も gh pr view も叩かない
  • auto mode では張れない。 hook の発した Monitor に分類器が判定を返さない (gave no verdict)。 model に張らせる指示は出さず、「CI は PR の監視か gh pr checks で確認する」とだけ返す

Monitor は archive の前に止める。 ref の /watch は bridge が閉じないが、archive_session は生きた background task を持つセッションを畳まない (still has live work)。放っておくと見張りが archive を塞ぐので:

  • Actions の通知 (task-notification) が来るたびに全部の見張りを突合し、local の branch (git --git-dir=<共通 dir> show-ref refs/heads/<ref>) が消えていたら TaskStop。 PR は親セッションが作るので、子の archive → worktree-janitor で branch が消える → 親に次の通知が来た時点で止まる。 gh の polling や timer は持たない
  • archive_session { session_id: "self" } の tool.call を包み、先に全部の Monitor を止めてから通す
  • 他のセッションの archive が live work で断られたら、相手へ [pr-bridge-watch:stop] 入りの send_message を送って 3 秒後に 1 回やり直す。受けた側の mod は session.receive (届かなければ prompt.submit) で要求を飲み込み (turn を起こさない)、全部止める。子が自分で PR を作ったときの経路。pinned 等の拒否ではやり直さない

task ID (Monitor の結果) が取れないとき・PR の branch が local に無いときは自動では止まらないので、context にそう書く。 installed plugin は起動中のセッションでは読み直されない (更新は新しいセッションから効く)。

導入 (Claude Code 2.1.260 以上。function hooks は既定 off):

# ~/.claude/settings.json の env に "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" (起動中のセッションにも効く)
claude plugin marketplace add ippoan/gh-actions-live
claude plugin install pr-bridge-watch@gh-actions-live

bridge が別ホストなら userConfig の bridgeUrl (既定 ws://127.0.0.1:8799) を変える。

開発: 型は build ごとに生成する (commit しない。mods/*/types/ は gitignore)。

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude -p "/plugin-types mods/pr-bridge-watch/types"
tsc -p mods/pr-bridge-watch/tsconfig.json
claude plugin validate mods/pr-bridge-watch
npm test    # test/pr-bridge-watch.test.mjs が偽の $ で register を回す (Node 24 の type stripping で .ts を直接 import)
Source 2 files
hooks/register.ts 270 lines
1import type { EngineInterface, ProcessRunInit, ProcessRunResult, Register, ToolCallResult } from 'claude-code'
2
3import {
4  ARCHIVE_TOOL,
5  archiveTargetOf,
6  headOfCommand,
7  isAutoModeNoVerdict,
8  isDesktopEntrypoint,
9  isLiveWorkRefusal,
10  isPrCreateCommand,
11  isSelfArchive,
12  isStopRequest,
13  pullRefsOf,
14  SEND_MESSAGE_TOOL,
15  statusUrlOf,
16  STOP_REQUEST,
17  watchUrlOf,
18} from './pr-watch.ts'
19
20const DEFAULT_BRIDGE = 'ws://127.0.0.1:8799'
21/** 停止要求を送ってから archive をやり直すまでの待ち */
22export const STOP_REQUEST_WAIT_MS = 3000
23
24/** gitDir: branch の実在を突合する git の共通ディレクトリ。取れなければ null (突合では止めない) */
25type Watch = { pr: string; ref: string; gitDir: string | null; taskId: string | null }
26
27/** engine の口。`$` は変数に持てない (plugin validate が拒否する) ので閉包で持つ */
28type Host = {
29  run: (argv: readonly string[], init?: ProcessRunInit) => Promise<ProcessRunResult>
30  taskStop: (taskId: string) => Promise<ToolCallResult>
31  log: (text: string) => void
32}
33
34function hostOf($: EngineInterface): Host {
35  return {
36    run: (argv, init) => $.process.run(argv, init),
37    taskStop: taskId => $.tool.call({ tool: 'TaskStop', task_id: taskId } as Parameters<typeof $.tool.call>[0]),
38    log: text => $.ui.log(text),
39  }
40}
41
42/**
43 * PR を作る Bash (`gh pr create` / pr-push.sh) を包み、成功して PR の URL が出たら
44 * その branch の CI を gh-actions-bridge の `ws /watch?repo=…&ref=…` に Monitor で繋ぐ。
45 *
46 * bridge は systemd --user の常駐 1 本 (CLAUDE.md)。ここでは起動も kill もしない。
47 * 繋げなかったとき (bridge が落ちている / Monitor が拒否された) は、model への context に
48 * 自分で張る Monitor の引数を書いて返す — 見張りが黙って欠けるより model に拾わせる。
49 * 例外は model が従えない 2 つで、どちらも 1 行の事実だけを返す:
50 * - Claude desktop (Code タブ): app 側が PR の CI を監視するので、張ろうとしない
51 * - auto mode: hook の発した Monitor に分類器が判定を返さない。model に張らせても同じなので頼まない
52 *
53 * `ref` の /watch は bridge が閉じず、persistent な Monitor は `archive_session` を
54 * 「still has live work」で断らせる。止めどきは次の 3 つ:
55 * - Actions の通知 (task-notification) が来るたびに全部の見張りを突合し、local の branch が
56 *   消えていたら TaskStop (PR は親が作る → 子の archive + worktree-janitor で branch が消える → 親の見張りが止まる)
57 * - このセッション自身を archive する直前に全部止める
58 * - 他のセッションの archive が live work で断られたら、相手へ停止要求を send_message して 1 回やり直す。
59 *   受けた側は session.receive / prompt.submit で要求を飲み込み (turn を起こさない)、全部止める
60 */
61export const register: Register = (on, options) => {
62  const bridge = typeof options.bridgeUrl === 'string' && options.bridgeUrl !== '' ? options.bridgeUrl : DEFAULT_BRIDGE
63  // repo#ref → 見張り。同じセッションで同じ branch を二重に見張らない (pr-push.sh の再実行・PR の作り直し)
64  const watches = new Map<string, Watch>()
65  let reconciling = false
66
67  const stopWatch = async (host: Host, key: string, why: string) => {
68    const w = watches.get(key)
69    if (w === undefined) return
70    watches.delete(key)
71    if (w.taskId === null) return
72    try {
73      const r = await host.taskStop(w.taskId)
74      const failed = r.deny ?? (r.isError ? r.text : undefined)
75      host.log(
76        failed === undefined
77          ? `pr-bridge-watch: ${w.pr} ${why} → Monitor (task ${w.taskId}) を止めた`
78          : `pr-bridge-watch: ${w.pr} ${why} → Monitor (task ${w.taskId}) を止められなかった (${failed})`,
79      )
80    } catch (err) {
81      host.log(`pr-bridge-watch: ${w.pr} ${why} → Monitor (task ${w.taskId}) を止められなかった (${String(err)})`)
82    }
83  }
84
85  const stopAll = (host: Host, why: string) => Promise.all([...watches.keys()].map(key => stopWatch(host, key, why)))
86
87  /** 全部の見張りを local の branch と突合し、消えた branch の Monitor を止める */
88  const reconcile = async (host: Host) => {
89    if (reconciling) return
90    reconciling = true
91    try {
92      for (const [key, w] of [...watches]) {
93        if (w.gitDir === null) continue
94        if ((await branchExists(host, w.gitDir, w.ref)) === false) await stopWatch(host, key, `の branch ${w.ref} が local に無い`)
95      }
96    } finally {
97      reconciling = false
98    }
99  }
100
101  // Actions の通知 (Monitor の event) が来たら突合する。通知そのものは素通し
102  on('prompt.submit', async ($, e, next) => {
103    if (isStopRequest(e.text)) {
104      await stopAll(hostOf($), 'のセッションに archive の停止要求が来た')
105      return { drop: 'pr-bridge-watch: archive の停止要求を受けて Monitor を止めた' }
106    }
107    if (e.origin.kind === 'task-notification' && watches.size > 0) void reconcile(hostOf($))
108    return next(e)
109  })
110
111  // 他のセッションの send_message は queue に入る前にここを通る。停止要求なら turn を起こさずに飲み込む
112  on('session.receive', async ($, e, next) => {
113    if (!isStopRequest(e.text)) return next(e)
114    await stopAll(hostOf($), 'のセッションに archive の停止要求が来た')
115    return { consumed: 'pr-bridge-watch: archive の停止要求を受けて Monitor を止めた' }
116  })
117
118  on('tool.call', async ($, e, next) => {
119    const input = e as { session_id?: unknown; reason?: unknown }
120    if (isSelfArchive(e.tool, input)) {
121      if (watches.size > 0) await stopAll(hostOf($), 'のセッションを archive する')
122      return next(e)
123    }
124    const target = archiveTargetOf(e.tool, input)
125    if (target === null) return next(e)
126
127    const result = await next(e)
128    if (!result.isError || !isLiveWorkRefusal(result.text ?? String(result.result ?? ''))) return result
129    try {
130      // ccd の MCP tool は /plugin-types を打った環境によって型に載らないので unknown 経由
131      const sent = await $.tool.call({ tool: SEND_MESSAGE_TOOL, session_id: target, message: STOP_REQUEST } as unknown as Parameters<
132        typeof $.tool.call
133      >[0])
134      if (sent.deny !== undefined || sent.isError) return result
135      await $.clock.sleep(STOP_REQUEST_WAIT_MS, { signal: next.signal })
136      const retried = await $.tool.call({
137        tool: ARCHIVE_TOOL,
138        session_id: target,
139        ...(typeof input.reason === 'string' ? { reason: input.reason } : {}),
140      } as unknown as Parameters<typeof $.tool.call>[0])
141      const note = retried.isError
142        ? 'pr-bridge-watch: live work で断られたので相手に Monitor の停止要求を送り 1 回やり直したが、まだ畳めない'
143        : 'pr-bridge-watch: live work で断られたので相手に Monitor の停止要求を送り、やり直して畳めた'
144      return { ...retried, context: [...(retried.context ?? []), note] } as ToolCallResult
145    } catch {
146      return result
147    }
148  })
149
150  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
151    if (!isPrCreateCommand(e.command)) return next(e)
152
153    const result = await next(e)
154    if (result.deny !== undefined || result.isError) return result
155
156    const prs = pullRefsOf(result.text ?? '')
157    if (prs.length === 0) return result
158    if (await isDesktop($)) {
159      const note = `pr-bridge-watch: Claude desktop では app が PR の CI を監視するので bridge に Monitor を張らない (${prs.map(pr => pr.url).join(' ')})`
160      return { ...result, context: [...(result.context ?? []), note] }
161    }
162
163    const host = hostOf($)
164    const notes: string[] = []
165    for (const pr of prs) {
166      const ref = (await headRefOf(host, pr.url)) ?? headOfCommand(e.command)
167      if (ref === null) {
168        notes.push(`pr-bridge-watch: ${pr.url} の branch が分からず bridge に繋いでいない`)
169        continue
170      }
171      const key = `${pr.repo}#${ref}`
172      if (watches.has(key)) continue
173
174      const url = watchUrlOf(bridge, pr.repo, ref)
175      const description = `${pr.repo} PR #${pr.number} CI [${ref}]`
176      const manual = `Monitor({ ws: { url: "${url}" }, description: "${description}", persistent: true, timeout_ms: 3600000 })`
177      const noVerdict = `pr-bridge-watch: auto mode では hook から Monitor を張れない (分類器が判定を返さない)。${pr.url} の CI は PR の監視か gh pr checks で確認する`
178
179      if (!(await isBridgeUp($, bridge))) {
180        notes.push(`pr-bridge-watch: bridge (${statusUrlOf(bridge)}) に届かない。systemctl --user status gh-actions-bridge を確認し、戻ったら ${manual}`)
181        continue
182      }
183      try {
184        const started = await $.tool.call({
185          tool: 'Monitor',
186          description,
187          ws: { url },
188          persistent: true,
189          timeout_ms: 3600000,
190        } as Parameters<typeof $.tool.call>[0])
191        if (started.deny !== undefined || started.isError) {
192          const refused = started.deny ?? started.text
193          notes.push(isAutoModeNoVerdict(refused ?? '') ? noVerdict : `pr-bridge-watch: Monitor を張れなかった (${refused})。自分で ${manual}`)
194          continue
195        }
196        const id = (started.result as { taskId?: unknown } | undefined)?.taskId
197        const taskId = typeof id === 'string' ? id : null
198        const gitDir = await gitDirWithBranch(host, ref)
199        watches.set(key, { pr: pr.url, ref, gitDir, taskId })
200        const stops =
201          taskId === null
202            ? '。task ID が取れず自動では止められない — 要らなくなったら TaskStop すること (残すと archive_session が畳めない)。'
203            : gitDir === null
204              ? ` (task ${taskId})。local に branch ${ref} が見つからず突合では止まらない — 要らなくなったら TaskStop すること (archive 時は自動で止める)。`
205              : ` (task ${taskId})。local の branch が消えたら次の Actions 通知で、archive のときはその前に自動で止める。`
206        notes.push(
207          `pr-bridge-watch: ${pr.url} の CI を bridge の /watch (repo=${pr.repo} ref=${ref}) に Monitor で繋いだ${stops}` +
208            'gh run list で polling しない。通知が来なければ拡張の watch 対象 repos (get-config) を確認',
209        )
210      } catch (err) {
211        notes.push(isAutoModeNoVerdict(String(err)) ? noVerdict : `pr-bridge-watch: Monitor の起動に失敗 (${String(err)})。自分で ${manual}`)
212      }
213    }
214    if (notes.length === 0) return result
215    return { ...result, context: [...(result.context ?? []), ...notes] }
216  })
217}
218
219/** PR の head branch。gh が無い / 失敗したら null (呼び出し側が command の --head に落ちる) */
220async function headRefOf(host: Host, url: string): Promise<string | null> {
221  try {
222    const r = await host.run(['gh', 'pr', 'view', url, '--json', 'headRefName', '--jq', '.headRefName'], { timeoutMs: 15000 })
223    const value = r.stdout.trim()
224    return r.exitCode === 0 && value !== '' ? value : null
225  } catch {
226    return null
227  }
228}
229
230/** セッションの cwd の git 共通ディレクトリ (worktree でも main clone の .git)。そこに branch が在るときだけ返す */
231async function gitDirWithBranch(host: Host, ref: string): Promise<string | null> {
232  try {
233    const r = await host.run(['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'], { timeoutMs: 15000 })
234    const gitDir = r.stdout.trim()
235    if (r.exitCode !== 0 || gitDir === '') return null
236    return (await branchExists(host, gitDir, ref)) === true ? gitDir : null
237  } catch {
238    return null
239  }
240}
241
242/** true: 在る / false: 無い (branch か git dir ごと消えた) / null: git を走らせられなかった (止めない) */
243async function branchExists(host: Host, gitDir: string, ref: string): Promise<boolean | null> {
244  try {
245    const r = await host.run(['git', `--git-dir=${gitDir}`, 'show-ref', '--verify', '--quiet', `refs/heads/${ref}`], {
246      timeoutMs: 15000,
247    })
248    return r.exitCode === 0
249  } catch {
250    return null
251  }
252}
253
254/** 読めなければ desktop ではない扱い (いままでどおり張りに行く) */
255async function isDesktop($: EngineInterface): Promise<boolean> {
256  try {
257    return isDesktopEntrypoint(await $.env.get('CLAUDE_CODE_ENTRYPOINT'))
258  } catch {
259    return false
260  }
261}
262
263async function isBridgeUp($: EngineInterface, bridge: string): Promise<boolean> {
264  try {
265    return (await $.http.fetch(statusUrlOf(bridge))).ok
266  } catch {
267    return false
268  }
269}
270
hooks/pr-watch.ts 84 lines
1// 純粋な判定だけを置く (engine の `$` に触らない)。node --test から直接 import する。
2
3/** PR を作る Bash か。`gh pr create` 直打ちと claude-skills の pr-push.sh */
4export function isPrCreateCommand(command: string): boolean {
5  return /\bgh\s+pr\s+create\b/.test(command) || /\bpr-push(\.sh)?\b/.test(command)
6}
7
8export type PullRef = { repo: string; number: number; url: string }
9
10/** 出力に出た PR の URL (重複は 1 つに畳む) */
11export function pullRefsOf(text: string): PullRef[] {
12  const seen = new Map<string, PullRef>()
13  for (const m of text.matchAll(/https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/pull\/(\d+)/g)) {
14    const url = m[0]
15    if (!seen.has(url)) seen.set(url, { repo: m[1]!, number: Number(m[2]), url })
16  }
17  return [...seen.values()]
18}
19
20/** `--head X` / `-H X` / `--head=X` を command から拾う (gh pr view が失敗したときの保険) */
21export function headOfCommand(command: string): string | null {
22  const m = command.match(/(?:--head|-H)(?:=|\s+)(["']?)([^\s"']+)\1/)
23  return m ? m[2]! : null
24}
25
26/**
27 * bridge の /watch URL。`ws://127.0.0.1:8799` → `ws://127.0.0.1:8799/watch?repo=…&ref=…`。
28 * ref の 40 文字切りは bridge 側がやる (watch.rs) のでここでは切らない
29 */
30export function watchUrlOf(bridge: string, repo: string, ref: string): string {
31  const base = bridge.replace(/\/+$/, '')
32  return `${base}/watch?repo=${encodeURIComponent(repo)}&ref=${encodeURIComponent(ref)}`
33}
34
35/** bridge の状態 (`GET /`) を叩く http URL */
36export function statusUrlOf(bridge: string): string {
37  return bridge.replace(/^ws(s?):/, 'http$1:').replace(/\/+$/, '') + '/'
38}
39
40/**
41 * Claude desktop (Code タブ) のセッションか。`CLAUDE_CODE_ENTRYPOINT` の値で見る。
42 * desktop は app 側が PR の CI を監視し、model が Monitor で CI を見張ることを禁じているので張らない
43 */
44export function isDesktopEntrypoint(entrypoint: string | undefined): boolean {
45  return entrypoint === 'claude-desktop'
46}
47
48/**
49 * auto mode の分類器が、hook の発した tool 呼び出しに判定を返さなかった拒否か。
50 * `$.tool.call` の戻りに種別の欄は無いので文面で見る (テストで文面を固定している)
51 */
52export function isAutoModeNoVerdict(text: string): boolean {
53  return /auto mode classifier/i.test(text) && /gave no verdict/i.test(text)
54}
55
56export const ARCHIVE_TOOL ='mcp__ccd_session_mgmt__archive_session'
57export const SEND_MESSAGE_TOOL = 'mcp__ccd_session_mgmt__send_message'
58
59/** `archive_session` がこのセッション自身を畳む呼び出しか */
60export function isSelfArchive(tool: string, input: { session_id?: unknown }): boolean {
61  return tool === ARCHIVE_TOOL && input.session_id === 'self'
62}
63
64/** 他のセッションを畳む `archive_session` なら、その session_id */
65export function archiveTargetOf(tool: string, input: { session_id?: unknown }): string | null {
66  return tool === ARCHIVE_TOOL && typeof input.session_id === 'string' && input.session_id !== 'self' ? input.session_id : null
67}
68
69/** archive が「まだ動いている (background task 等)」で断られた応答か。pinned / 画面に開いている等の拒否は含めない */
70export function isLiveWorkRefusal(text: string): boolean {
71  return /was not archived/i.test(text) && /live work|background (task|work)|still working/i.test(text)
72}
73
74/** 相手の pr-bridge-watch が拾って Monitor を全部止める合図 (send_message の本文に入れる) */
75export const STOP_MARKER = '[pr-bridge-watch:stop]'
76
77export const STOP_REQUEST =
78  `${STOP_MARKER} このセッションを archive するので、bridge の /watch に張った Monitor を止めてください。` +
79  'pr-bridge-watch が入っていれば自動で処理され、この文はモデルに届きません。届いた場合は TaskStop で Monitor を止めるだけで、返信は不要です。'
80
81export function isStopRequest(text: string): boolean {
82  return text.includes(STOP_MARKER)
83}
84