SLOPSHOPPER

pr-relay

Watches the session's pull request and wakes the session when it is merged or Codex reviews it

newbandguardtoaststatusprompt
★ 17v0.9.0no licenseupdated 2026-10-05HolyGrail/claude-mods/plugins/pr-relay
A shopper browsing a rack in a slop shop
README

pr-relay

セッションの PR を 1 分ごとに確かめ、マージされたときと Codex がレビューを付けたときだけセッションを起こす Claude Code の mod です。 人が GitHub を見て「マージした」「レビューが来た」と打たなくても、セッションが次の作業を始めます。 モデルがレビューの到着を待つために sleep やポーリングの tool を呼ぶ必要もなくなります。

監視する PR

Desktop アプリでは、セッションの PR バーの先頭に表示される PR(最後に紐付けた PR)を監視します。 新しい PR が紐付けられると、次の確認で監視先を切り替えます。 先頭の PR が再オープンした場合も、その PR の監視を始めます。 PR バーのほかの PR はマージとクローズだけを追い、トーストで知らせます。 マージされた PR には、それぞれ cleanup の行も出します。 この確認にはアプリのキャッシュを使い、GitHub への問い合わせは増やしません。

ターミナルでは、これまでどおりセッションの開始時に gh pr view で現在のブランチの OPEN な PR を探します。 Desktop アプリでも、先頭に OPEN な PR がない場合やアプリの情報を取得できない場合は、同じ方法で探します。

セッションの途中で gh pr create を実行すると、その出力の URL から監視を始めます。 gh pr new や gh -R owner/repo pr create も認識し、-Rowner/repo、--repo owner/repo、--repo=owner/repo の形式でも指定できます。 git push を実行したときに PR を監視していなければ、もう一度 gh pr view で探します。 git -C <dir> push では、指定したディレクトリのブランチの PR を探します。 相対パスはセッションの作業ディレクトリを基準にし、-C が複数ある場合は指定順に結合して、.. の解決はファイルシステムに任せます。 --work-tree=<dir> または --work-tree <dir> があれば、そのディレクトリの PR を探し、相対パスはすべての -C を適用した後のディレクトリを基準にします。 引用符で囲まれていない先頭の ~ や ~/ は HOME で展開し、引用符で囲まれたチルダは文字どおりの相対パスとして扱います。 ディレクトリに $ やバッククォートが含まれる場合、引用符のない ~user の形式の場合、HOME がなく ~ を展開できない場合は、その push による PR 検索を省略します。 1 回の Bash 呼び出しに複数の git push があり、解決した検索先のディレクトリが異なる場合も、push の追跡は続けて PR 検索だけを省略します。 git push-deploy や gh pr create-extra、gh pr new-extra は検出対象に含めません。 mcp__pr-relay__watch に pr_url を渡した場合も、その PR の監視を始めます。 Desktop アプリの先頭の PR が同じで OPEN のままなら、これらの方法で指定した監視先を保ちます。 アプリの情報が一部省略されても、確認済みの PR や状態は保ちます。

起こし方

GitHub で起きたことセッションへの知らせ方
Codex がレビューを付けた指摘の本文と場所を載せたプロンプトを送る
Codex が 👍 を付けた、または「Didn't find any major issues」とコメントしたCI の確認と完了の報告を促すプロンプトを送る
Codex の利用上限に達したトーストだけ出す
監視中の PR またはアプリに紐付いた PR のいずれかがマージされたトーストを出し、プロンプトの上の帯にその PR の cleanup ボタンを出す。押すと、PR のブランチから対象の worktree を探して、worktree とローカルブランチを片付けるよう促すプロンプトを送る。監視中の PR なら、ほかのセッションへお知らせも出す
監視中の PR またはアプリに紐付いた PR がクローズされたトーストだけ出す

Desktop アプリで Auto-fix(CI モニター)が有効な PR では、Codex のレビュー、コメントによる approved、利用上限の通知を、コメント本文も届ける CI モニターに任せます。 その場合、pr-relay は Codex の 👍 とマージを引き続き知らせ、ステータス行に「CI モニター併用」と表示します。 先頭の PR はアプリの状態から、ほかの PR はセッションに届いた CI モニター自身の通知から Auto-fix が有効だと判断します。 アプリから PR の紐付けがないと報告された場合は、この判定をリセットします。 アプリが紐付いた PR の一覧を返した場合は、その一覧と先頭の PR から外れた PR の判定も解除します。 一覧が省略された場合は、これまでの判定を保ちます。

マージ後の cleanup は worktree とローカルブランチを消すので、自動では始めずボタンにしています。 プロンプトでは PR の URL からブランチとリポジトリを確かめ、そのブランチを checkout している worktree を探すよう伝えます。 見つからない場合や、別のリポジトリの PR の場合は、何も消さずに報告するよう伝えます。 プロンプトでは削除前に未コミットの変更や push していないコミットを確かめ、残っていれば消さずに報告するよう伝えます。 cleanup の行は新しいものから最大 3 件を表示し、cleanup と 閉じる はその行だけに作用します。 デスクトップでは各行を角丸の枠で囲み、使用量メーターとは別のカードとして表示します。 監視先が変わってもほかの PR の行は残り、同じ PR を再び監視するとその行を消します。 アプリで OPEN と確認した PR の行も消します。 アプリのほかの PR は、このセッションで OPEN だったものがマージまたはクローズされたときだけ知らせます。 /resume、/clear、/branch で会話が変わると、アプリの PR の状態と cleanup の行をリセットします。 マージとクローズを見たら、その PR のポーリングは止めます。

ほかのセッションへのお知らせ

PR がマージされると、同じリポジトリで作業しているほかのセッションのブランチは、マージ先より古くなります。 監視中の PR のマージを見たセッションは、notice-board の /notice で次のお知らせを出します。

main advanced (#12). Rebase before the next push.

main の位置には、PR のマージ先のブランチ名が入ります。 ブランチ名が - で始まる場合は、/notice のオプションと読まれないよう Branch を前に付けます。 rebase を次の push の前まで待たせるのは、作業の途中で rebase すると、コンフリクトの解消が実装の変更と混ざるからです。

お知らせを出さない場合が 4 つあります。

  • notice-board が入っていない。/notice が存在しないので、トーストと cleanup ボタンだけが出ます
  • PR がマージされずにクローズされた。マージ先は進んでいません
  • PR がセッションのリポジトリのものではない(mcp__pr-relay__watch に別のリポジトリの URL を渡した場合など)。/notice はセッションのリポジトリ宛てに出るので、origin が PR の GitHub リポジトリと一致するときだけ出します
  • アプリの PR バーにある、監視中ではない PR がマージされた。こちらはトーストと cleanup の行だけです

同じ PR を複数のセッションが見ていても、お知らせを出すのはトーストを出すセッションだけです。 notice-board は投稿したセッションにも同じお知らせを伝えるので、マージを見たセッション自身の帯とモデルにも届きます。 2 つのセッションがまったく同時にマージを見た場合は、同じお知らせが 2 件並ぶことがあります。 お知らせは自動では消えないので、不要になったら /notice clear で消してください。 /notice はセッションが待機中になってから実行されるので、お知らせは数分遅れることがあります。

プロンプトは $.prompt.submit で送るので、実行中のターンには割り込まず、セッションが待機中になってから新しいターンを始めます。

ステータス行には、監視中の PR 番号と最後に確かめた時刻(JST)を出します。 起動時に gh pr view が失敗して PR を探せなかったとき(未認証、ネットワークの切断)は、PR 検索失敗 21:00: <gh のエラー> を出し、1 分ごとに探し直します。

Codex の判定

Codex の 👍 は PR に 1 つしか付かず、作成時刻が前の push のときのまま残ることがあります。 そのため「👍 がある」だけでは approved と見なさず、最後の push より後のイベントだけを数えます。

最後の push の時刻には、次のうち最も新しいものを使います。

  • mcp__pr-relay__watch の since で指定した時刻、または gh pr create を始めた時刻
  • このセッションで git push を始めた時刻(push が終わってから、PR の head がその push の出力にあるコミットになったときだけ使う。出力にコミットがなければ、push 開始時に分かっていた head から変わったときに使う。Everything up-to-date や別ブランチへの push では基準時刻を進めない)
  • PR の最新コミットのコミット時刻

まだ知らせていないレビューがあれば、approved より先に知らせます。 最新のレビューより後に approved のイベントがあれば、レビューのプロンプトの末尾にその時刻を添え、別の approved のプロンプトは送りません。 最新のレビュー以前の approved も通知済みとして記録し、レビューだけを知らせます。

レビューを知らせるときだけ、対象のレビューの詳細を 1 回の追加クエリで取得し、指摘の本文と場所、コメント ID、URL をプロンプトに載せます。 本文は 1 件につき 4,000 文字で切り、プロンプト全体は 30,000 文字以内に収めます。 各レビューの inline コメントは 100 件まで取得し、載せきれないコメントは残りの件数と PR の files ページで案内します。 inline コメントがないレビューでは、<details> の部分を除いたレビュー本文を載せます。 詳細を取得できなくても、件数と gh api でコメントを読む手順を載せてセッションを起こします。

mod は通知済みかどうかを記録しますが、指摘への対応が済んだかや、現在の head がレビュー済みかは判定しません。 利用上限のトーストは、最後の push より後に上限のコメントがあれば、上限が解けた後でも出ます。

監視中の PR について、知らせたイベントは $.store に PR ごとに記録します。 同じ PR を見ている別のセッションや、再起動したセッションが、同じイベントでもう一度起こされることはありません。 同じ PR を複数のセッションが同時に確かめたときは、先に確認を始めたセッションだけを起こします(下の「制約」)。 プロンプトがセッションに入らなかったとき(送信の失敗や、別の plugin による拒否)は記録を戻し、次の確認で送り直します。

/dev スキルと組み合わせるとき

mod はツール mcp__pr-relay__watch を登録するので、モデルのツール一覧にこのツールがあれば mod が動いています。 /dev スキルからこのツールに pr_url と since を渡すと、PR と最後の push の時刻を mod に伝えられます。 引数なしで呼ぶと、監視中の PR を返します。 ツールの説明では、mod が監視している間はモデル自身でポーリングせず、ターンを終えて起こされるのを待つよう伝えています。 mod はスキルがないセッションでも動き、レビューへの対応や作業の完了に必要な手順をプロンプトで伝えます。

使い方

gh が PATH にあり、認証済みである必要があります。

インストール

Claude Code のプロンプトで、次の 3 つを順に実行します。

/plugin marketplace add HolyGrail/claude-mods
/plugin install pr-relay@claude-mods
/reload-plugins

更新の手順と注意点は、リポジトリの README にまとめてあります。

インストールせずに試す

clone したリポジトリのルートで、次のように起動します。

claude --plugin-dir ./plugins/pr-relay

手元のコードを常に読み込むなら、~/.claude/settings.json の env にある CLAUDE_CODE_PLUGIN_DIRS にこのディレクトリの絶対パスを足します。

制約

GitHub への定期的な問い合わせは、1 分に 1 回の GraphQL クエリです。 レビュー、コメント、👍 はそれぞれ新しい順に 100 件まで取り、100 件目がまだ最後の push より後なら、前のページを最大 10 ページまでたどります。 レビューを知らせるときは、指摘の本文を取得するクエリを 1 回追加します。 単純なクエリは 1 回 1 ポイントなので、12 セッションが同時に動いても毎時 720 ポイントで、上限の毎時 5,000 ポイントに収まります。

監視開始時に基準時刻が指定されず、どのセッションも git push の実行を見ていない PR では、最新コミットのコミット時刻を最後の push の時刻とみなします。 コミットしてから push するまでの間に付いた Codex のイベントを、新しいものと見なすことがあります。

$.store には atomic な更新がないので、同じ PR を見ているセッションは、確認を始めるたびに自分のキー(poll:<セッション ID>:<PR>)へ開始時刻を書きます。 30 秒以内に同じ PR の確認を先に始めたセッション、またはそれより前に始めてまだ GitHub の応答を待っているセッション(10 分まで)があれば、新しいイベントの通知はそのセッションに任せ、PR の記録にも書きません(読んだ時点より古い内容で上書きしないため)。 自分が知っている push の時刻(監視開始時の基準時刻や、GitHub にまだ head が反映されていない自分の git push を含む)は確認の開始時から自分のキーに書き、通知するセッションがそれを読んで基準時刻に加えます。 1 つのセッションの中では、前の確認が GitHub の応答を待っている間は次の確認を始めません。 GitHub への問い合わせは store への書き込みよりずっと長くかかるので、問い合わせの後に読めば先のセッションの書き込みが見えるという前提です。 任されたセッションが通知の前に終了しても、次の確認(1 分後)で残ったセッションが送ります。 確認に失敗したセッションや終了したセッションのキーは、通知を任される対象から外れますが、知っていた push の時刻は残ります。 このキーは 1 時間で消しますが、PR の記録がまだ数えていない push の時刻を持つものは 14 日まで残します。 マージやクローズも任されたセッションだけがトーストを出し、任せた側は次の確認で記録からそれを知って、ポーリングを止め cleanup ボタンを出します。 自分のキーを書けなかった確認は、他のセッションから見えないので、GitHub に問い合わせずに失敗としてステータス行に出します。 他のセッションのキーを読めなかった確認も、何も通知せずに失敗として出します。 どれかのセッションが git push を実行している間(開始から 10 分まで)は、GitHub がまだ置き換わる前の head を見せていることがあるので、どのセッションも通知しません。 どのセッションが起こされるかは確認の順番で決まり、/dev のセッションを優先するわけではありません。

gh の失敗(未認証、ネットワークの切断)はステータス行に出し、次の確認で再試行します。 レビュー詳細の取得に失敗した場合は、再試行を待たずにコメントの確認手順をプロンプトで伝えます。

CI の結果は監視しません。 Desktop アプリが PR の CI の結果をセッションへ送るので、両方で見ると同じ失敗で二重に起こされます。

テスト

cd plugins/pr-relay
claude plugin test
Source 1 files
hooks/register.js 1628 lines
1// Watches this session's pull request on a timer and wakes the session only when something
2// changed: a merge, a Codex review, or Codex's thumbs-up. The model then never has to poll.
3
4// How often to ask GitHub about the pull request. One GraphQL query a tick costs one point of the
5// 5,000 an hour, so even a dozen sessions polling at once stay far below the limit.
6const TICK_MS = 60_000
7// The desktop app exposes its cached pull requests through this tool
8const DESKTOP_STATUS = 'mcp__ccd_pr__get_status'
9// Codex's login as GraphQL spells it (REST adds "[bot]")
10const CODEX = 'chatgpt-codex-connector'
11// What this module has already relayed for a pull request lives under this prefix plus its URL, in
12// $.store, so another session on the same pull request, or this one after a restart, is not woken
13// again for the same event
14const KEY_PREFIX = 'pr:'
15// Each session notes under this prefix plus its id and the pull request's when it last started a
16// poll of it, as { pr, at, since, pending?, running?, idle? }, so sessions polling the same pull
17// request together let only the first of them relay. since is the last push the session knows of,
18// which the session that relays takes from it, and pending a push it ran that counts once the head
19// has moved, { at, head } with the head it last saw;
20// running stays set until the poll has written what it relayed; idle marks a note whose session
21// stopped polling, kept only for its since. A session writes only its own key, since $.store has
22// no atomic update.
23const POLL_PREFIX = 'poll:'
24// Polls of one pull request that started this close together are one round: the one that started
25// first relays what is new, and the others leave it to that one. The query a poll waits on takes
26// far longer than a store write, so each of them sees the earlier one's note by the time it reads.
27const ROUND_MS = 30_000
28// A poll still running holds back the later ones of other sessions past its round, since it has not
29// written what it relayed yet; one this old is taken to belong to a session that died mid-poll
30const RUNNING_MS = 10 * 60_000
31const LATE = 'poll outlasted its turn'
32const PR_URL = /https:\/\/github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)/
33// Option values are one unquoted, single-quoted or double-quoted shell word
34const SHELL_WORD = String.raw`(?:"[^"]*"|'[^']*'|[^\s"';&|<>]+)`
35// Accepts gh pr create or new, optionally preceded by -R repo, -Rrepo, --repo repo or --repo=repo
36const GH_PR_CREATE = new RegExp(String.raw`\bgh\s+(?:(?:-R\s*|--repo(?:\s+|=))${SHELL_WORD}\s+)*pr\s+(?:create|new)(?=$|[\s;&|)])`)
37const GIT_OPTION = String.raw`(?:-C\s+(${SHELL_WORD})|--work-tree(?:=|\s+)(${SHELL_WORD})|-c\s+${SHELL_WORD}|--git-dir(?:=|\s+)${SHELL_WORD}|--no-pager)`
38// Accepts git push with any combination of -C dir, -c key=value, --git-dir=dir,
39// --work-tree=dir and --no-pager before push, including space-separated directory values
40const GIT_PUSH = new RegExp(String.raw`\bgit\s+((?:${GIT_OPTION}\s+)*)push(?=$|[\s;&|)])`, 'g')
41// What gh pr view says when the branch has no pull request, as opposed to failing to ask
42const NO_PR = /no pull requests found/i
43// The session.end reasons after which this module stops
44const FINAL_REASONS = ['prompt_input_exit', 'other']
45
46const HOUR_MS = 3_600_000
47// Records of pull requests that ended and are untouched this long are of no more use. One left
48// open keeps what was relayed for it far longer, since a session that finds it again would
49// otherwise be woken for the same events.
50const STALE_MS = 14 * 24 * HOUR_MS
51const OPEN_STALE_MS = 90 * 24 * HOUR_MS
52// Events are paged back only to the previous poll of the same watch, less this much
53const PAGE_OVERLAP_MS = 5 * 60_000
54// How a pull request that is no longer open reads
55const ENDED = { MERGED: 'マージ', CLOSED: 'クローズ' }
56// JST has no daylight saving time, so a fixed offset gives its clock
57const JST_OFFSET_MS = 9 * HOUR_MS
58
59// The newest events come last, and only those after the last push count. A page holds 100, the
60// most GraphQL gives; a connection whose oldest item on it is still after the push is followed
61// back a page at a time, up to MAX_PAGES.
62const PAGE = 'last: 100, before: $before'
63const PAGE_INFO = 'pageInfo { hasPreviousPage startCursor }'
64const CONNECTIONS = {
65  reactors: `reactionGroups { content reactors(${PAGE}) { ${PAGE_INFO} edges { reactedAt node { ... on Bot { login } ... on User { login } } } } }`,
66  reviews: `reviews(${PAGE}) { ${PAGE_INFO} nodes { id databaseId fullDatabaseId submittedAt author { login } comments { totalCount } } }`,
67  comments: `comments(${PAGE}) { ${PAGE_INFO} nodes { createdAt author { login } body } }`,
68}
69const MAX_PAGES = 10
70const queryOf = (fields) => `query($owner: String!, $name: String!, $number: Int!, $before: String) {
71  repository(owner: $owner, name: $name) { pullRequest(number: $number) { ${fields} } }
72}`
73const QUERY = queryOf(`state headRefOid headRefName baseRefName commits(last: 1) { nodes { commit { committedDate } } } ${Object.values(CONNECTIONS).join(' ')}`)
74// Review bodies stay out of the per-minute query so gh's output does not get cut
75const REVIEW_QUERY = `query($ids: [ID!]!) {
76  nodes(ids: $ids) { ... on PullRequestReview {
77    databaseId fullDatabaseId url submittedAt commit { oid } body
78    comments(first: 100) { totalCount nodes { databaseId fullDatabaseId path line originalLine url body outdated replyTo { id } subjectType } }
79  } }
80}`
81// The most characters of a body to include before linking to the full text
82const BODY_LIMIT = 4_000
83// The most characters in a review prompt, including the omitted count and approval note
84const PROMPT_LIMIT = 30_000
85
86// The pull request this session watches: { url, id, owner, name, number, since, pagedAt?, ended? },
87// or null. since is the last push the watch started from.
88let watched = null
89// The timers that poll it, kept so the next watch or session.start can stop them
90let timers = []
91// Bumped by every watch and stop, so a poll or a lookup started before leaves the state alone
92let generation = 0
93// The last poll: { at, error? }, for the status line
94let lastCheck = null
95// The last lookup of the pull request when gh could not answer it, { at, error }, for the status line
96let lookupFailure = null
97// The generation of the poll running now, so a tick that comes while one still waits on GitHub
98// leaves it its note rather than overwriting it with a later round's
99let polling = null
100// The last push this session knows of for the watched pull request, which its poll note carries
101let knownSince = 0
102// The head of the watched pull request as this session's last poll found it, and when
103let lastHead = null
104let lastHeadAt = 0
105// This session's latest poll note, { key, pr, at, since, pending, running, serial }, as last written,
106// so an older poll does not mark it finished, a push can update it and a stop can leave it idle
107let lastNote = null
108// The notes a stop left idle, by key, which a push that finishes later still updates
109const retired = new Map()
110// The last write of each note key still going out
111const noteWrites = new Map()
112// Tells apart the polls of one session, which may share a key and a time
113let noteSerial = 0
114// Cleanup offers stay newest first, with at most three rows shown
115let cleanupOffers = []
116// Offers waiting for their prompts stay in order but are hidden until submission settles
117const cleanupPending = new Set()
118// Desktop availability is asked once per conversation and kept separately from watch timers
119let desktopAvailable = null
120// The desktop timer survives a watch change or the watched pull request ending
121let desktopTimer = null
122// This generation is reading the desktop cache, so slow calls do not pile up within a watch
123let desktopPolling = null
124// Successful desktop reads keep the last primary URL here
125let lastPrimary = null
126// A primary becoming open counts as a change even when its URL stays the same
127let lastPrimaryOpen = false
128// This map keeps the last known bound states in this conversation by parsed URL id
129let boundStates = new Map()
130// The desktop CI monitor relays comments for these pull requests in this conversation
131const monitored = new Set()
132// The git push calls this session ran for the watched pull request that have not counted yet, each
133// { id, pr, at, head, shas, refs, running }: its own id, the pull request it was bound to (null when
134// none was watched), when it started, the head the pull request had as far as the session knew
135// then, and once it has finished, the commits it moved refs to and the commit each branch moved to
136// (null when its output does not say). A push becomes the baseline only once GitHub shows what it pushed, since one that changed
137// nothing ("Everything up-to-date", another branch) must not hide the events that came before it.
138// Notes hold the same objects, so a push that finishes after the session turned to another pull
139// request still updates the note it was in.
140let pushes = []
141// How many pushes this session started for each pull request
142const pushStarts = new Map()
143// The session id as last asked, so a push need not wait on it
144let sessionId = null
145let idAsks = 0
146// git push calls still running. While one is, what Codex said may be about the head it replaces,
147// so nothing is relayed until it ends.
148// The read, change and write of a record in this session, one after another: a poll and a send
149// that takes its mark back would otherwise each write what they read before the other wrote
150let writes = Promise.resolve()
151
152export function register(on) {
153  // Fires again on an enable or a worker respawn, which may keep this module's variables
154  on('session.start', async ($, e, next) => {
155    sessionIdOf($).catch(() => {})
156    stop({ desktop: true })
157    await retire($)
158    reset($)
159    await $.tool.register({
160      name: 'watch',
161      description:
162        'pr-relay polls a pull request for this session and sends a prompt when it is merged, when Codex ' +
163        'reviews it, or when Codex gives it a thumbs-up. While it watches, do not poll for those yourself ' +
164        '(sleep loops, polling scripts, ScheduleWakeup): end the turn and wait to be woken. ' +
165        'Without arguments, returns what it watches. With pr_url, watches that pull request; since (ISO ' +
166        '8601) is the last push, and only Codex activity after it counts.',
167      inputSchema: {
168        type: 'object',
169        properties: {
170          pr_url: { type: 'string', description: 'https://github.com/<owner>/<repo>/pull/<number>' },
171          since: { type: 'string', description: 'The last push, ISO 8601' },
172        },
173      },
174    })
175    startDesktop($)
176    // Finding the pull request calls tools, so it starts once the session is ready
177    timers.push($.clock.after(0, () => discover($)))
178    return next(e)
179  })
180
181  on('session.end', async ($, e, next) => {
182    if (FINAL_REASONS.includes(e.reason)) {
183      stop({ desktop: true })
184      await retire($)
185    }
186    return next(e)
187  })
188
189  // /resume can turn this process to another conversation without a new session.start, and that
190  // conversation may belong to another worktree and pull request. /clear and /branch (fork) go on
191  // in the same one, but under a new id as well: the note under the last id is left idle, since
192  // that conversation no longer runs here, and the pull request is looked up again
193  on('classic.SessionStart', { source: ['resume', 'clear', 'fork'] }, async ($, e, next) => {
194    // The new conversation has its own id, so a push before its first poll must not write under
195    // the last one's
196    sessionId = null
197    sessionIdOf($).catch(() => {})
198    stop({ desktop: true })
199    await retire($)
200    reset($)
201    startDesktop($)
202    timers.push($.clock.after(0, () => discover($)))
203    return next(e)
204  })
205
206  on('tool.call', { tool: 'mcp__pr-relay__watch' }, async ($, e) => {
207    if (typeof e.pr_url === 'string') {
208      if (!PR_URL.test(e.pr_url)) return { result: `Not a pull request URL: ${e.pr_url}` }
209      const since = typeof e.since === 'string' ? Date.parse(e.since) : NaN
210      watch($, { url: e.pr_url, since: Number.isNaN(since) ? 0 : since })
211    }
212    return { result: describe() }
213  })
214
215  on('prompt.submit', async ($, e, next) => {
216    if (e.origin?.kind !== 'sdk' || typeof e.text !== 'string' || !e.text.startsWith('<ci-monitor-event>')) return next(e)
217    let result
218    try {
219      result = await next(e)
220      if (result?.drop === undefined) {
221        // Only the notice's first line names its pull request; later lines may quote GitHub text
222        const firstLine = e.text.slice('<ci-monitor-event>'.length).trimStart().split(/\r?\n/, 1)[0]
223        const match = firstLine.match(/\bwatching ([\w.-]+\/[\w.-]+) PR #(\d+)\b/)
224        if (match) monitored.add(parse(`https://github.com/${match[1]}/pull/${match[2]}`).id)
225      }
226    } catch {}
227    return result
228  })
229
230  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
231    const command = e.command ?? ''
232    // Best effort: a push made another way is caught by the head commit's date, and session.start
233    // looks up the branch's pull request
234    const creates = GH_PR_CREATE.test(command)
235    const pushMatches = creates ? [] : [...command.matchAll(GIT_PUSH)]
236    const pushing = pushMatches.length > 0
237    if (!creates && !pushing) return next(e)
238    // Bound to the pull request watched as the push starts, before anything else can turn the
239    // watch to another
240    const prId = watched && !watched.ended ? watched.id : null
241    // A failure to publish is shown only while the watch the push started under lasts
242    const watchGen = generation
243    // Codex reviews what was pushed, so its earlier activity says nothing about this push. The push
244    // is counted as started before anything is awaited, so a poll finishing meanwhile holds back
245    // what it read from the head being replaced; a watch that turns to another pull request while
246    // the time is read leaves it behind with the rest of the last one's pushes
247    let push = null
248    if (pushing) {
249      push = { id: null, pr: prId, at: null, head: null, shas: null, running: true }
250      if (prId) pushStarts.set(prId, (pushStarts.get(prId) ?? 0) + 1)
251      pushes.push(push)
252    }
253    // The time is set before the push runs, so a poll that sees the new head while it is still
254    // finishing already applies it, and put back if the push failed
255    const startedAt = await $.clock.now()
256    if (pushing) {
257      push.at = startedAt
258      // id tells parallel pushes started in the same millisecond apart, in this session and others
259      push.id = pushId(startedAt)
260      for (const note of notesOf(prId)) {
261        note.pending = [...note.pending.filter((p) => p !== push), push]
262        note.dirty = true
263      }
264      // A pull request with no note yet (its first poll has not written one, or the watch turned
265      // away first) gets an idle one, so the other sessions see the push run; the first poll takes
266      // it up
267      if (prId && !notesOf(prId).length) {
268        // The id is known from the start, so the push goes out without waiting on it
269        const key = `${POLL_PREFIX}${sessionId ?? (await sessionIdOf($))}:${prId}`
270        if (!notesOf(prId).length) retired.set(key, { key, pr: prId, at: startedAt, since: 0, pending: [push], idle: true, dirty: true })
271      }
272      // The other sessions hold back their relays only if they can see the push, so it goes out
273      // running before its head is looked up, and again with the head; a store that will not take
274      // it is shown rather than holding up the push
275      const publish = () =>
276        refreshNote($, { strict: true }).catch((error) => {
277          if (generation !== watchGen || (watched?.id ?? null) !== prId) return
278          lastCheck = { at: startedAt, error: `push note: ${error?.message ?? error}` }
279          showStatus($)
280        })
281      await publish()
282      push.head = await headBefore($, prId)
283      for (const note of notesOf(prId)) note.dirty = true
284      await publish()
285      // Codex activity that came while the notes went out is still about the head being replaced,
286      // so the push counts from when it actually runs; the notes take the time with its end
287      push.at = await $.clock.now()
288      for (const note of [lastNote, ...retired.values()]) if (note?.pending.includes(push)) note.dirty = true
289      // The other sessions hold back for RUNNING_MS from the time they see, so they see this one
290      await publish()
291      // Activity that came while that went out is still about the head being replaced, so the push
292      // counts from here; the notes take this time with the push's end, which is when it applies
293      push.at = await $.clock.now()
294      for (const note of [lastNote, ...retired.values()]) if (note?.pending.includes(push)) note.dirty = true
295      // and the other sessions hold back from it too, so it goes out as Bash runs, not before
296      publish()
297    }
298    // The other sessions hold back their relays only while the push runs
299    const finish = async ({ dropped = false, shas = null, refs = null } = {}) => {
300      // A poll whose GitHub answer came before this may have seen the head the push replaced
301      push.doneAt = await $.clock.now()
302      push.running = false
303      if (dropped) push.dropped = true
304      else {
305        push.shas = shas
306        push.refs = refs
307      }
308      pushes = pushes.filter((p) => !p.dropped)
309      for (const note of [lastNote, ...retired.values()]) if (note?.pending.includes(push)) note.dirty = true
310      await refreshNote($)
311    }
312    let ran
313    try {
314      ran = await next(e)
315    } catch (error) {
316      // Whether it pushed is unknown, so it counts only once the head moves
317      if (push) await finish().catch(() => {})
318      throw error
319    }
320    // A push that failed or moved nothing says nothing about what Codex reviewed
321    const failed = ran.deny !== undefined || ran.isError
322    const moved = push && !failed ? pushedCommits(ran) : null
323    if (push) await finish({ dropped: failed || moved?.shas.length === 0, shas: moved?.shas, refs: moved?.refs })
324    if (failed) return ran
325    if (creates) {
326      const url = ran.text?.match(PR_URL)?.[0]
327      if (url) watch($, { url, since: startedAt })
328    } else {
329      if (!watched || watched.ended) timers.push($.clock.after(0, async () => {
330        const directories = await Promise.all(pushMatches.map((match) => pushDirectory($, match[1])))
331        if (directories.includes(null)) return
332        const cwd = directories[0]
333        // A Bash call may push several repositories, so only an unambiguous directory is looked up
334        if (directories.length > 1) {
335          const sessionCwd = directories.includes(undefined) ? await $.session.cwd() : undefined
336          if (directories.some((dir) => (dir ?? sessionCwd) !== (cwd ?? sessionCwd))) return
337        }
338        await discover($, { branchOnly: true, cwd })
339      }))
340    }
341    return ran
342  })
343
344  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
345    if (!cleanupOffers.length) return next(e)
346    const elements = $.ui.resolve(e)
347    const lines = cleanupOffers.filter((offer) => !cleanupPending.has(offer)).slice(0, 3).map((offer) => {
348      const { url, number } = offer
349      const { id } = parse(url)
350      const close = () => {
351        cleanupOffers = cleanupOffers.filter((entry) => entry !== offer)
352        $.ui.invalidate('ui.render')
353      }
354      return elements.Box({
355        flexDirection: 'row',
356        columnGap: 2,
357        // On the desktop each offer is a card of its own, apart from the meters below it
358        ...(e.surface === 'desktop' && { borderStyle: 'round', paddingX: 1 }),
359        children: [
360          elements.Text({ children: [`PR #${number} がマージされました`] }),
361          elements.Button({
362            key: `cleanup-${id}`,
363            label: 'cleanup',
364            variant: 'primary',
365            onPress: async () => {
366              cleanupPending.add(offer)
367              $.ui.invalidate('ui.render')
368              const text =
369                `PR ${url} がマージされました。この PR のブランチの worktree とローカルブランチを片付けてください。\n` +
370                `gh pr view ${url} --json headRefName,headRepository でブランチを確かめ、git worktree list でそのブランチを checkout している worktree を探してください。\n` +
371                '見つからない場合や、別のリポジトリの PR の場合は、何も消さずに報告してください。\n' +
372                '消す前に、未コミットの変更や push していないコミットが残っていないかを確かめ、残っていれば消さずに報告してください。'
373              // A prompt that did not enter reveals its row in place, unless a new watch or a
374              // conversation reset removed that offer while it waited
375              if (await submit($, text)) close()
376              cleanupPending.delete(offer)
377              $.ui.invalidate('ui.render')
378            },
379          }),
380          elements.Button({ key: `dismiss-${id}`, label: '閉じる', role: 'dismiss', onPress: close }),
381        ],
382      })
383    })
384    // Keep what the mods after this one draw in the band
385    const rest = await next(e)
386    return elements.Box({
387      flexDirection: 'column',
388      ...(e.surface === 'desktop' && { rowGap: 1 }),
389      children: [...lines, ...(rest ? [rest] : [])],
390    })
391  })
392}
393
394function reset($) {
395  watched = null
396  lastCheck = null
397  lookupFailure = null
398  cleanupOffers = []
399  cleanupPending.clear()
400  desktopAvailable = null
401  desktopPolling = null
402  lastPrimary = null
403  lastPrimaryOpen = false
404  boundStates = new Map()
405  monitored.clear()
406  pushes = []
407  $.ui.invalidate('ui.render')
408  showStatus($)
409}
410
411// Looks for the pull request to watch, and asks again a tick later when gh could not answer
412async function discover($, { branchOnly = false, cwd } = {}) {
413  if (watched && !watched.ended) return
414  const gen = generation
415  // Pruning is startup housekeeping, skipped for a lookup after a push; a store that fails it
416  // does not keep the pull request unwatched
417  if (!branchOnly) await prune($).catch(() => {})
418  if (gen !== generation) return
419  const desktop = branchOnly ? null : await readDesktop($)
420  if (gen !== generation) return
421  const found = desktop?.primary?.state === 'OPEN'
422    ? { url: desktop.primary.url, since: 0 }
423    : await findForBranch($, cwd)
424  const at = await $.clock.now()
425  if ((watched && !watched.ended) || gen !== generation) return
426  if (desktop) {
427    trackBound($, desktop)
428  }
429  if (found?.error !== undefined) {
430    // Shown in place of the watch's line, or of the ended pull request's when a push looks again
431    lookupFailure = { at, error: found.error }
432    showStatus($)
433    timers.push($.clock.after(TICK_MS, () => discover($, { branchOnly, cwd })))
434    return
435  }
436  if (lookupFailure) {
437    lookupFailure = null
438    showStatus($)
439  }
440  if (found) watch($, found)
441}
442
443// Starts watching. The first poll runs on the clock, never inside the caller's hook: a prompt it
444// submits starts its turn only once the session is idle, which a tool call still running is not.
445function watch($, { url, since }) {
446  stop()
447  const gen = generation
448  const pr = parse(url)
449  // The note of the poll this stops must not hold back the sessions still watching its pull request
450  // while this watch's first poll starts; watching the same pull request again, its first poll
451  // takes up what the note held
452  if (lastNote) retire($)
453  // A push still waiting for its head to move stays with the pull request it was bound to, even one
454  // that ended; one made while none was watched is the push that led here
455  if (watched?.id !== pr.id) pushes = pushes.filter((push) => !push.pr)
456  // The pull request that takes up a push made while none was watched keeps it
457  for (const push of pushes) push.pr ??= pr.id
458  // Watching the same pull request again keeps the baseline its polls learned
459  if (watched?.id !== pr.id) knownSince = 0
460  watched = { ...pr, since }
461  lookupFailure = null
462  lastHead = null
463  lastHeadAt = 0
464  // Watching a pull request again removes only its own cleanup offer
465  removeCleanup($, pr.id)
466  // Ask at once: a review or a merge may have come while no session watched
467  timers.push($.clock.after(0, () => poll($, gen)))
468  timers.push($.clock.every(TICK_MS, () => poll($, gen)))
469}
470
471function stop({ desktop = false } = {}) {
472  for (const timer of timers) timer?.cancel()
473  timers = []
474  if (desktop) {
475    desktopTimer?.cancel()
476    desktopTimer = null
477  }
478  generation += 1
479}
480
481// Each conversation starts its own desktop timer and asks once whether the tool is available
482function startDesktop($) {
483  desktopAvailable = $.tool.list().then(
484    (tools) => tools.some((tool) => tool.name === DESKTOP_STATUS),
485  ).catch(() => false)
486  desktopTimer = $.clock.every(TICK_MS, () => refreshDesktop($))
487}
488
489// This source reads only the app's cache and leaves gh discovery available after any tool failure
490async function readDesktop($) {
491  try {
492    if (!(await desktopAvailable)) return null
493    const ran = await $.tool.call({ tool: DESKTOP_STATUS })
494    if (ran.deny !== undefined || ran.isError) return null
495    const status = JSON.parse(ran.text)
496    if (!status || typeof status !== 'object' || Array.isArray(status)) return null
497    if (status.bound === false) return { bound: false, primary: null, others: [] }
498    const pull = (url, state) => {
499      if (typeof url !== 'string' || url.match(PR_URL)?.index !== 0) return null
500      const pr = parse(url)
501      if (!Number.isSafeInteger(pr.number) || pr.number <= 0) return null
502      return { url: pr.url, number: pr.number, state: typeof state === 'string' ? state.toUpperCase() : null }
503    }
504    const pr = status.pr
505    const primary = pr?.host == null || pr.host.toLowerCase?.() === 'github.com' ? pull(pr?.url, pr?.state) : null
506    const others = Array.isArray(status.otherBoundPrs) ? status.otherBoundPrs.flatMap((other) => {
507      if (typeof other?.repo !== 'string' || !/^[\w.-]+\/[\w.-]+$/.test(other.repo)) return []
508      if (!Number.isSafeInteger(other.number) || other.number <= 0) return []
509      const parsed = pull(`https://github.com/${other.repo}/pull/${other.number}`, other.state)
510      return parsed ? [parsed] : []
511    }) : null
512    return {
513      bound: status.bound,
514      primary: primary ? { url: primary.url, state: primary.state, autoFix: status.monitor?.auto_fix } : null,
515      others,
516    }
517  } catch {
518    return null
519  }
520}
521
522// A changed open primary takes over the watch, and an ended primary resumes when reopened
523async function refreshDesktop($) {
524  const gen = generation
525  if (desktopPolling === gen) return
526  desktopPolling = gen
527  try {
528    const desktop = await readDesktop($)
529    if (!desktop || gen !== generation) return
530    const primary = desktop.primary
531    const id = primary ? parse(primary.url).id : null
532    const changed = (id !== (lastPrimary ? parse(lastPrimary).id : null) || !lastPrimaryOpen) && id !== watched?.id
533    const reopened = id === watched?.id && watched.ended
534    if (primary?.state === 'OPEN' && (changed || reopened)) {
535      watch($, { url: primary.url, since: 0 })
536    }
537    trackBound($, desktop)
538  } finally {
539    if (desktopPolling === gen) desktopPolling = null
540  }
541}
542
543// Bound states include the watched pull request, whose notifications still come from its poll
544function trackBound($, { bound, primary, others }) {
545  if (bound === false) {
546    lastPrimary = null
547    lastPrimaryOpen = false
548    boundStates.clear()
549    const wasMonitored = monitored.has(watched?.id)
550    monitored.clear()
551    if (wasMonitored) showStatus($)
552    return
553  }
554  if (primary) {
555    const { id } = parse(primary.url)
556    const previous = monitored.has(id)
557    if (primary.autoFix === true) monitored.add(id)
558    else if (primary.autoFix === false) monitored.delete(id)
559    if (id === watched?.id && monitored.has(id) !== previous) showStatus($)
560  }
561  // An incomplete primary must not hide a later open state for the same URL
562  if (primary && ['OPEN', 'MERGED', 'CLOSED'].includes(primary.state)) {
563    lastPrimary = primary.url
564    lastPrimaryOpen = primary.state === 'OPEN'
565  }
566  const entries = [...(others ?? []), ...(primary ? [primary] : [])]
567  // Only a supplied list can unbind other pull requests; an omitted primary remains known
568  if (Array.isArray(others)) {
569    const ids = new Set(entries.map((entry) => parse(entry.url).id))
570    if (!primary && lastPrimary) ids.add(parse(lastPrimary).id)
571    for (const id of boundStates.keys()) if (!ids.has(id)) boundStates.delete(id)
572    const wasMonitored = monitored.has(watched?.id)
573    for (const id of monitored) if (!ids.has(id)) monitored.delete(id)
574    if (wasMonitored !== monitored.has(watched?.id)) showStatus($)
575  }
576  for (const entry of entries) {
577    const pr = parse(entry.url)
578    const { state } = entry
579    if (state !== 'OPEN' && state !== 'MERGED' && state !== 'CLOSED') continue
580    const previous = boundStates.get(pr.id)
581    boundStates.set(pr.id, state)
582    if (state === 'OPEN') removeCleanup($, pr.id)
583    if (pr.id === watched?.id || previous !== 'OPEN' || !ENDED[state]) continue
584    $.ui.toast(`PR #${pr.number} が${ENDED[state]}されました`)
585    if (state === 'MERGED') offerCleanup($, pr)
586  }
587}
588
589// A merged pull request goes at the front without duplicating an existing offer
590function offerCleanup($, { url, number }) {
591  const { id } = parse(url)
592  if (cleanupOffers.some((offer) => parse(offer.url).id === id)) return
593  cleanupOffers.unshift({ url, number })
594  $.ui.invalidate('ui.render')
595}
596
597// Watching or seeing a pull request open removes its own offer, including one awaiting a prompt
598function removeCleanup($, id) {
599  const remaining = cleanupOffers.filter((offer) => parse(offer.url).id !== id)
600  if (remaining.length === cleanupOffers.length) return
601  cleanupOffers = remaining
602  $.ui.invalidate('ui.render')
603}
604
605async function poll($, gen) {
606  if (polling === gen) return
607  polling = gen
608  try {
609    await pollOnce($, gen)
610  } finally {
611    if (polling === gen) polling = null
612  }
613}
614
615async function pollOnce($, gen) {
616  if (gen !== generation || !watched || watched.ended) return
617  await settleRetired($)
618  const pr = watched
619  const key = KEY_PREFIX + pr.id
620  // A watch that began while the retired notes settled owns what the session knows now
621  if (gen !== generation) return
622  // What an earlier poll of this watch paged through is not asked for again
623  const floor = Math.max(pr.since, (pr.pagedAt ?? 0) - PAGE_OVERLAP_MS)
624  // Noted before asking, so a session that starts polling while this one waits on GitHub finds it,
625  // with the pushes known already, so a session that relays before this one finishes counts them
626  knownSince = Math.max(knownSince, pr.since)
627  // A poll the other sessions cannot see would relay beside the one they elect, so it waits a tick
628  const note = await notePoll($, pr, gen).catch(async (error) => {
629    lastCheck = { at: await $.clock.now(), error: `poll note: ${error?.message ?? error}` }
630    showStatus($)
631    return null
632  })
633  if (!note) return
634  const now = note.at
635  try {
636    await pollNoted($, gen, { pr, key, now, floor, note })
637  } finally {
638    await finishNote($, note)
639  }
640}
641
642async function pollNoted($, gen, { pr, key, now, floor, note }) {
643  // Read before GitHub is asked: a push that starts, and even finishes, while the query or the
644  // record is on its way may have replaced the head the query saw
645  const started = pushStarts.get(pr.id) ?? 0
646  const answer = await query($, pr, floor).catch((error) => ({ error }))
647  // When GitHub showed the head, which is what tells a fresher head from a staler one
648  const seenAt = answer.seenAt
649  const answeredAt = await $.clock.now()
650  // A watch that began meanwhile owns the state now
651  if (gen !== generation) return
652  // A query that outlasted the time the others wait on a running note may have seen another
653  // session take the round over, which this one's election cannot tell: it relays nothing
654  if (!answer.error && lapsed(now, answeredAt)) answer.error = new Error(LATE)
655  if (answer.error) {
656    // A poll that learned nothing relays nothing, so it must not hold back the others' this round,
657    // but the push it knows of still counts
658    await retire($)
659    // A watch that began while the note was left idle shows its own state
660    if (gen !== generation) return
661    lastCheck = { at: now, error: String(answer.error?.message ?? answer.error) }
662    showStatus($)
663    return
664  }
665  const { data } = answer
666  // Another session polling this pull request in the same round, ahead of this one, relays what is
667  // new; this one leaves the record to it and passes on what it knows through its note
668  // Notes it could not read may hold an earlier poll, so this one then learned nothing either
669  const notes = await readNotes($, pr, note).catch((error) => ({ error }))
670  // Read before the watch is checked, so nothing waits between the check and what follows
671  const readAt = await $.clock.now()
672  if (gen !== generation) return
673  if (notes.error) {
674    await retire($)
675    if (gen !== generation) return
676    lastCheck = { at: now, error: `poll notes: ${notes.error?.message ?? notes.error}` }
677    showStatus($)
678    return
679  }
680  // So may one whose store answered too late
681  if (lapsed(now, readAt)) return late($, now, pr, note)
682  const { defers, since: othersSince, pending, pushing } = notes
683  // Said in the note, so a later poll of another session does not yield to this one in turn
684  if (defers && lastNote?.serial === note.serial) {
685    lastNote.deferred = true
686    // Written before the record is read, which the store may keep waiting. One the store refused
687    // would leave the others yielding to a poll that relays nothing, so the poll stops as failed
688    const refused = await writeNote($, lastNote, { strict: true }).then(
689      () => null,
690      (error) => error,
691    )
692    // A watch that began while it went out owns the head and the baseline now
693    if (gen !== generation) return
694    if (refused) {
695      await retire($)
696      if (gen !== generation) return
697      lastCheck = { at: now, error: `poll note: ${refused?.message ?? refused}` }
698      showStatus($)
699      return
700    }
701  }
702  if (data.headRefOid) {
703    lastHead = data.headRefOid
704    lastHeadAt = seenAt
705  }
706  lastCheck = { at: now }
707  // One that leaves the waking to another session leaves the end to it too, and sees it next round
708  // from the record, as the session that relays: only then does it stop and offer the cleanup
709  const ended = !defers && (data.state === 'MERGED' || data.state === 'CLOSED')
710  let endedGen = null
711  if (ended) {
712    stop()
713    endedGen = generation
714    watched = { ...watched, ended: data.state }
715    if (data.state === 'MERGED') {
716      offerCleanup($, pr)
717    }
718  }
719
720  // The watch ends only once the end is told: one a push took back, or that never reached the
721  // record, polls on so a later round tells it
722  let told = !ended
723  try {
724    let noted = 0
725    const sends = await exclusive(async () => {
726      const record = normalize(await $.store.get(key))
727      const writeAt = await $.clock.now()
728      // A watch that began while this waited its turn owns the pushes and the marks now
729      if (gen !== generation && !ended) return null
730      // Checked once more just before the record is written, since the store may have kept it waiting
731      if (lapsed(now, writeAt)) return LATE
732      // An ended poll goes on past a new watch, whose baseline is not this pull request's: it goes by
733      // what its note said
734      const owns = watched?.id === pr.id
735      noted = Math.max(othersSince, owns ? knownSince : note.since)
736      const sends = update($, pr, data, record, noted, pending, defers, pushing, now, seenAt)
737      // The session that relays writes the record at about this moment, so a copy read before its
738      // marks must not land over them: one that leaves it the waking writes only its own note
739      if (owns) knownSince = Math.max(knownSince, record.since)
740      if (defers) return sends
741      record.at = now
742      if (data.headRefOid) record.headAt = seenAt
743      await $.store.set(key, record)
744      // A write that landed past the lease may have gone over a session that took the round over and
745      // relayed the same, so this one sends nothing; $.store has no conditional write to stop it.
746      // Its marks are taken back, since a session that has not taken the round over yet would read
747      // them as sent, and nothing would send them
748      if (lapsed(now, await $.clock.now())) return { late: sends }
749      // Pages are skipped next time only once what they held is in the record: a session that leaves
750      // the waking to another goes through them again in case that one never writes it
751      if (gen === generation) watched = { ...watched, pagedAt: now }
752      return sends
753    })
754    if (sends === null) return
755    if (sends === LATE) return late($, now, pr, note)
756    if (sends.late) {
757      takeBackAll($, key, sends.late, pr)
758      return late($, now, pr, note)
759    }
760    // Only once the record says they were relayed, so a send that fails can take its mark back. A
761    // watch that began during the write (an ended pull request stopped the timers itself) takes
762    // the marks back instead, and a later watch of this pull request sends them.
763    // A push this session started meanwhile, running or already done, may have replaced the head
764    // they were read from
765    let pushedSince = (pushStarts.get(pr.id) ?? 0) !== started
766    // So may one another session started after its notes were read, which only they say
767    if (sends.length && !pushedSince) {
768      // A push known already that has finished since, even one that had run past the time it holds
769      // the relays back, counts the same as a new one
770      const state = (push) => `${push.running ? 'running' : 'done'}:${push.doneAt ?? ''}`
771      const known = new Map(pending.map((push) => [pushKey(push), state(push)]))
772      const moved = (again) =>
773        !again ||
774        again.pushing ||
775        again.since > noted ||
776        again.pending.some((push) => known.get(pushKey(push)) !== state(push))
777      pushedSince = moved(await readNotes($, pr, note).catch(() => null))
778      // A read the store kept waiting past the lease may have let another session take the round
779      // over; it read the marks, which this one wrote in time, so it sent nothing: they are taken back
780      // for the next round to send
781      if (lapsed(now, await $.clock.now())) {
782        takeBackAll($, key, sends, pr)
783        return late($, now, pr, note)
784      }
785      // A push this session started while the clock was read counts too, and so does one another
786      // session started meanwhile, which a last read of the notes shows; the lease keeps ROUND_MS
787      // for it, and nothing waits between it and the sends
788      pushedSince ||= (pushStarts.get(pr.id) ?? 0) !== started
789      if (!pushedSince) pushedSince = moved(await readNotes($, pr, note).catch(() => null))
790      pushedSince ||= (pushStarts.get(pr.id) ?? 0) !== started
791    }
792    const current = (gen === generation || ended) && !pushedSince && !(watched?.id === pr.id && pushes.some((push) => holds(push, now)))
793    if (current) {
794      told = true
795      for (const send of sends) deliver($, key, send)
796    } else takeBackAll($, key, sends, pr)
797    showStatus($)
798  } finally {
799    if (!told) reopen($, pr, endedGen)
800  }
801}
802
803// Watches a pull request again whose end a poll could not tell, unless a new watch began since
804function reopen($, pr, gen) {
805  if (gen !== generation || watched?.id !== pr.id || !watched.ended) return
806  watched = { ...watched, ended: null }
807  removeCleanup($, pr.id)
808  timers.push($.clock.every(TICK_MS, () => poll($, gen)))
809  showStatus($)
810}
811
812// Whether a poll started at now has run so long that another session may have taken its round over
813function lapsed(now, at) {
814  return at >= now + RUNNING_MS - ROUND_MS
815}
816
817// A poll that lapsed relays nothing, and says so. An ended one that a new watch went past leaves
818// that watch's note and status alone.
819async function late($, now, pr, note) {
820  if (lastNote?.serial === note.serial) await retire($)
821  if (watched?.id !== pr.id) return
822  lastCheck = { at: now, error: LATE }
823  showStatus($)
824}
825
826// Brings the record up to the pull request's state, and returns the prompts to send for what is new
827function update($, pr, data, record, notedPush, pending, defers, pushing, now, seenAt) {
828  // Codex reviews pushes, so only its activity after the latest push counts. The thumbs-up in
829  // particular is one reaction per pull request whose time can stay at an earlier push: its mere
830  // presence would read as an approval of every later push.
831  const committedAt = Date.parse(data.commits?.nodes?.[0]?.commit?.committedDate ?? '') || 0
832  record.since = Math.max(record.since, pr.since, committedAt, notedPush)
833  // An ended poll that finishes after the watch turned leaves the next pull request's pushes alone
834  if (watched?.id === pr.id) {
835    pushes = pushes.filter((push) => {
836      // The record's head is the head before the push only if GitHub showed it before the push
837      // started; one seen later may already be what the push put there
838      const recordHead = (record.headAt ?? 0) < push.at ? record.head : null
839      if (!pushCounts(push, data, now, { recordHead })) return true
840      record.since = Math.max(record.since, push.at)
841      return false
842    })
843  }
844  // Another session's push counts by the same rule; one that knew no head before it cannot tell a
845  // push that moved nothing from one that did, so it waits for that session's own poll
846  for (const push of pending) {
847    if (pushCounts(push, data, now)) record.since = Math.max(record.since, push.at)
848  }
849  record.head = data.headRefOid ?? record.head
850  if (data.state === 'MERGED' || data.state === 'CLOSED') {
851    if (!record.ended && !defers) {
852      const state = data.state
853      record.ended = state
854      // Raised once the record says so in time, like a prompt
855      return [
856        {
857          toast: `PR #${pr.number} が${ENDED[state]}されました`,
858          // Only a merge moves the base branch under the other sessions' work
859          notice: state === 'MERGED' ? `${baseName(data)} advanced (#${pr.number}). Rebase before the next push.` : null,
860          pr,
861          undo: (r) => {
862            if (r.ended === state) r.ended = null
863          },
864        },
865      ]
866    }
867    return []
868  }
869  // A reopened pull request ends again, and that end is news again
870  record.ended = null
871  // A push running in any session may be replacing the head GitHub still shows; this session's
872  // own are those it bound to this pull request
873  const running = pushes.some((push) => holds(push, now))
874  // So may one that finished after GitHub answered, which the head GitHub showed cannot tell from a
875  // push that moved nothing; the next poll sees what it left. One that finished in the same
876  // millisecond may have too, and one handed on with no end time counts from its start
877  const unseen = (push) => !push.running && (typeof push.doneAt === 'number' ? push.doneAt >= seenAt : push.at >= seenAt)
878  const replaced =
879    (watched?.id === pr.id && pushes.some(unseen)) || pending.some((push) => unseen(push) && !pushCounts(push, data, now))
880  return running || pushing || defers || replaced ? [] : relay($, pr, read(data, record.since), record)
881}
882
883// The cache takes only the latest answer, so one asked before a /resume cannot land over it
884async function sessionIdOf($) {
885  const ask = ++idAsks
886  const id = await $.session.id()
887  if (ask === idAsks) sessionId = id
888  return id
889}
890
891// Notes that this session starts a poll of the pull request now, and returns the note
892async function notePoll($, pr, gen) {
893  // One note per pull request, so a session that turns to another leaves this one's push behind
894  const key = `${POLL_PREFIX}${await sessionIdOf($)}:${pr.id}`
895  // A worker respawn starts this module afresh while the session's note from before stays in the
896  // store, still holding what the record may not have counted; a read that fails is retried
897  const stored = lastNote?.key === key || retired.has(key) ? null : await $.store.get(key)
898  // Read just before the note goes out, since the time is this poll's place in the election
899  const at = await $.clock.now()
900  if (gen !== generation) return null
901  // The note under the id before /clear or /resume would read as another session's poll
902  if (lastNote && lastNote.key !== key) await retire($)
903  // Watching the same pull request again starts from what its note last said
904  if (lastNote?.key === key) knownSince = Math.max(knownSince, lastNote.since)
905  // Coming back to a pull request this session left, its note still holds pushes the record may
906  // not have counted yet
907  const left = retired.get(key)
908  if (left) {
909    knownSince = Math.max(knownSince, left.since)
910    for (const push of left.pending) if (!push.dropped && !pushes.includes(push)) pushes.push(push)
911    retired.delete(key)
912  } else if (stored?.pr === pr.id) {
913    if (typeof stored.since === 'number') knownSince = Math.max(knownSince, stored.since)
914    for (const push of [].concat(stored.pending ?? [])) {
915      if (typeof push?.at !== 'number' || pushes.some((p) => pushKey(p) === pushKey(push))) continue
916      // Whether a push the last module saw running pushed anything is unknown, so it counts once
917      // the head moves; one that may still run (the worker went, not the Bash call) holds the
918      // relays back as long as another session's would
919      const finished = !push.running
920      const live = !finished && push.at >= at - RUNNING_MS
921      pushes.push({
922        id: push.id ?? null,
923        pr: pr.id,
924        at: push.at,
925        head: push.head ?? null,
926        shas: finished ? (push.shas ?? null) : null,
927        refs: finished ? (push.refs ?? null) : null,
928        ...(finished && typeof push.doneAt === 'number' ? { doneAt: push.doneAt } : {}),
929        running: live,
930      })
931    }
932  }
933  lastNote = { key, pr: pr.id, at, since: knownSince, pending: [...pushes], running: true, serial: ++noteSerial }
934  await writeNote($, lastNote, { strict: true })
935  // A note other sessions wrote while this one's was on its way may have been elected without
936  // seeing it, so the poll yields to those too
937  return { ...lastNote, landedAt: await $.clock.now() }
938}
939
940// Marks the note of a poll that has written what it relayed as finished, with what it now knows
941async function finishNote($, note) {
942  if (lastNote?.serial !== note.serial) return
943  catchUp(lastNote)
944  lastNote.running = false
945  await writeNote($, lastNote)
946}
947
948// Puts a push this session just started into its running or finished note, so a session that
949// relays before this one polls again counts it
950async function refreshNote($, { strict = false } = {}) {
951  const writes = []
952  for (const note of [lastNote, ...retired.values()]) {
953    if (!note || (note.pr !== watched?.id && !note.dirty)) continue
954    catchUp(note)
955    writes.push(writeNote($, note, { strict }))
956  }
957  await Promise.all(writes)
958}
959
960// Rewrites the notes this session left until the store has taken each as idle and with its pushes
961// as they stand; a write the store refused is tried again on the next poll. Each is kept for as
962// long as the session runs, since coming back to its pull request takes up what it held.
963async function settleRetired($) {
964  await Promise.all(
965    [...retired.values()].map(async (note) => {
966      if (note.dirty || !note.writtenIdle) {
967        catchUp(note)
968        await writeNote($, note, { strict: true }).catch(() => {})
969      }
970    }),
971  )
972}
973
974// The notes this session holds for a pull request
975function notesOf(prId) {
976  return prId ? [lastNote, ...retired.values()].filter((note) => note?.pr === prId) : []
977}
978
979// Leaves this session's note idle: it holds back no other session, and still hands on its push
980async function retire($) {
981  if (!lastNote) return
982  const note = lastNote
983  lastNote = null
984  // What the session knows belongs to the watch, which may have moved on to another pull request;
985  // then the note keeps what it last said
986  catchUp(note)
987  const idle = { ...note, pending: [...note.pending], running: false, idle: true, dirty: true }
988  retired.set(note.key, idle)
989  await writeNote($, idle)
990}
991
992// Brings a note of the watched pull request up to what this session knows of its pushes
993function catchUp(note) {
994  if (note.pr !== watched?.id) return
995  note.since = Math.max(note.since, knownSince)
996  note.pending = [...pushes]
997}
998
999// Writes a note as it stands, its pushes included. dirty says the store has not taken what the note
1000// holds now, and writtenIdle whether it last took the note as idle.
1001// Writes to one key go out one after another, each with the note as it stands when it goes out, so
1002// an earlier write that the store answers late never lands over a later one.
1003function writeNote($, note, { strict = false } = {}) {
1004  note.dirty = false
1005  const { key } = note
1006  let idle = false
1007  const write = (noteWrites.get(key) ?? Promise.resolve())
1008    .then(() => {
1009      idle = Boolean(note.idle)
1010      return $.store.set(key, noteValue(note))
1011    })
1012    .then(
1013      () => {
1014        note.writtenIdle = idle
1015      },
1016      (error) => {
1017        note.dirty = true
1018        throw error
1019      },
1020    )
1021  const tail = write.catch(() => {})
1022  noteWrites.set(key, tail)
1023  tail.then(() => {
1024    if (noteWrites.get(key) === tail) noteWrites.delete(key)
1025  })
1026  return strict ? write : tail
1027}
1028
1029function noteValue({ pr, at, since, pending = [], running, idle, deferred }) {
1030  const pushes = pending.filter((push) => !push.dropped).map(pushNote)
1031  return {
1032    pr,
1033    at,
1034    since,
1035    ...(pushes.length ? { pending: pushes } : {}),
1036    ...(running ? { running } : {}),
1037    ...(idle ? { idle } : {}),
1038    ...(deferred ? { deferred } : {}),
1039  }
1040}
1041
1042// A push as a note hands it on, for the session that relays to apply
1043function pushNote({ id, at, head, shas, refs, running, doneAt }) {
1044  return {
1045    ...(id ? { id } : {}),
1046    at,
1047    head,
1048    ...(shas ? { shas } : {}),
1049    ...(refs ? { refs } : {}),
1050    ...(running ? { running } : {}),
1051    ...(typeof doneAt === 'number' ? { doneAt } : {}),
1052  }
1053}
1054
1055// Whether a push of this session still holds back a poll started at now: one whose Bash call hung
1056// holds nothing back for long, the same as another session's in readNotes
1057function holds(push, now) {
1058  return push.running && (push.at === null || push.at >= now - RUNNING_MS)
1059}
1060
1061function pushId(at) {
1062  return `${at.toString(36)}-${Math.random().toString(36).slice(2, 10)}`
1063}
1064
1065// What tells one push from another: its id, or for a note written without one, its start
1066function pushKey(push) {
1067  return push.id ?? `at:${push.at}`
1068}
1069
1070// Whether a push moved the pull request to the head GitHub shows now: once it has finished, by the
1071// commits it pushed, or, when its output named none, by the head having changed since it started.
1072// A pushed commit the head already had before is some other ref moved to it. The session that
1073// pushed falls back on the record's head as GitHub showed it before the push; with no head known at all, the push stays pending, and
1074// the head commit's date is the baseline.
1075function pushCounts(push, data, now, { recordHead = null } = {}) {
1076  const head = data.headRefOid
1077  // A push whose Bash call hung past the time it holds the relays back is taken as finished with
1078  // nothing known of what it pushed: it counts once the head moves
1079  if (holds(push, now) || !head) return false
1080  const before = push.head ?? recordHead
1081  if (Array.isArray(push.shas)) {
1082    // A pushed commit the head has may be another ref moved to it, where the pull request's branch
1083    // got by itself: when the output says which branch moved where, only its own branch counts
1084    const own = data.headRefName ? push.refs?.[data.headRefName] : null
1085    const named = push.refs && Object.keys(push.refs).length > 0
1086    const moved = named ? typeof own === 'string' && head.startsWith(own) : push.shas.some((sha) => head.startsWith(sha))
1087    if (before != null) return before !== head && moved
1088    // Without the head before it, only the pull request's own branch moving to it says the push
1089    // moved the head
1090    return typeof own === 'string' && head.startsWith(own)
1091  }
1092  return before != null && head !== before
1093}
1094
1095// The commits a finished git push moved refs to, and the commit each branch moved to: none when it
1096// moved nothing, or null when its output does not say (a new branch, a quiet push)
1097function pushedCommits(ran) {
1098  const text = [ran.text, ran.result?.stdout, ran.result?.stderr].filter((t) => typeof t === 'string').join('\n')
1099  // The usual form (old..new  src -> dst) and --porcelain's (flag TAB src:dst TAB old..new)
1100  const moves = [
1101    ...[...text.matchAll(/\b[0-9a-f]{7,40}\.{2,3}([0-9a-f]{7,40})\s+\S+\s+->\s+(\S+)/g)].map((m) => [m[1], m[2]]),
1102    ...[...text.matchAll(/^[ +\-*!=]\t[^\t]*:(\S+)\t[0-9a-f]{7,40}\.{2,3}([0-9a-f]{7,40})\b/gm)].map((m) => [m[2], m[1]]),
1103  ]
1104  // Moved nothing only when no update was printed: one Bash call may run several pushes, of which
1105  // only the last was up to date. --porcelain says a ref already up to date with =
1106  if (!moves.length) return /Everything up-to-date|^=\t/m.test(text) ? { shas: [], refs: {} } : null
1107  // The branch each ref moved to its commit, so a push can be told to have moved the pull request's
1108  // own branch even with no head known before it
1109  const refs = Object.fromEntries(moves.map(([sha, ref]) => [ref.replace(/^refs\/heads\//, ''), sha]))
1110  return { shas: [...new Set(moves.map(([sha]) => sha))], refs }
1111}
1112
1113// The head the watched pull request had as lately as this session knows: its own last poll or the
1114// record another session wrote since
1115async function headBefore($, prId) {
1116  if (!prId) return null
1117  const own = watched?.id === prId ? lastHead : null
1118  // A record it cannot read may hold a fresher head than this session saw, so the head is unknown
1119  const record = await $.store.get(KEY_PREFIX + prId).catch(() => null)
1120  if (record === null) return null
1121  if (!record?.head || !own) return record?.head ?? own
1122  const recordAt = record.headAt ?? record.at ?? 0
1123  // Two heads seen in the same millisecond cannot be told apart, so neither is the head before
1124  if (recordAt === lastHeadAt && record.head !== own) return null
1125  return recordAt > lastHeadAt ? record.head : own
1126}
1127
1128// What the other sessions' notes on the same pull request say: defers, whether one of them started
1129// its poll before this one's (or at the same moment, under a smaller key) in the same round, or is
1130// still running it, so that every session agrees on the one that relays; since and pending, the
1131// pushes they know; and pushing, whether one of them is running a push. One that stopped polling or
1132// failed drops out of the next round by itself, or at once when it left its note idle. A store that
1133// cannot be read throws, since a note it hides may be the one this poll should yield to.
1134
1135async function readNotes($, pr, note) {
1136  const keys = (await $.store.keys()).filter((key) => key.startsWith(POLL_PREFIX) && key !== note.key)
1137  let defers = false
1138  let pushing = false
1139  let since = 0
1140  const pending = []
1141  for (const key of keys) {
1142    const other = await $.store.get(key)
1143    if (other?.pr !== pr.id || typeof other.at !== 'number') continue
1144    if (typeof other.since === 'number') since = Math.max(since, other.since)
1145    for (const push of [].concat(other.pending ?? [])) {
1146      if (typeof push?.at !== 'number') continue
1147      pending.push(push)
1148      // One left running by a session that died mid-push holds nothing back for long
1149      if (push.running && push.at >= note.at - RUNNING_MS) pushing = true
1150    }
1151    // A poll that left the round to another holds nothing back either: polls a few seconds apart
1152    // would otherwise each yield to the one before, round after round, and none would relay
1153    if (other.idle || other.deferred) continue
1154    // A note written while this one's was on its way, even in the same millisecond it was timed,
1155    // may have been elected without seeing it
1156    const landedAt = note.landedAt ?? note.at
1157    if (landedAt > note.at && other.at >= note.at && other.at <= landedAt) {
1158      defers = true
1159      continue
1160    }
1161    if (other.at > note.at) continue
1162    if (other.at < note.at - (other.running ? RUNNING_MS : ROUND_MS)) continue
1163    if (other.at < note.at || key < note.key) defers = true
1164  }
1165  return { defers, since, pending, pushing }
1166}
1167
1168// Marks what is new since the last push as relayed: reviews before an approval, then the usage
1169// limit. A newer approval is marked with the reviews, and mentioned only if it came after them.
1170// Review details are fetched at delivery, once the record is written, outside the write queue
1171function relay($, pr, signals, record) {
1172  const fresh = signals.reviews.filter((r) => !record.reviews.some((id) => String(id) === String(r.id))).sort((a, b) => a.at - b.at)
1173  const usesMonitor = monitored.has(pr.id)
1174  const approvedAt = usesMonitor ? signals.reactedAt : signals.approvedAt
1175  const previous = record.approvedAt
1176  if (usesMonitor) {
1177    // The monitor owns these comments, so their silent marks survive a failed approval prompt
1178    record.reviews.push(...fresh.map((r) => r.id))
1179    record.usageLimitAt = Math.max(record.usageLimitAt, signals.usageLimitAt)
1180    record.approvedAt = Math.max(record.approvedAt, signals.approvedAt)
1181    if (fresh.length && approvedAt <= fresh[fresh.length - 1].at) return []
1182  }
1183  if (fresh.length > 0 && !usesMonitor) {
1184    const previous = record.approvedAt
1185    const approvedAt = signals.approvedAt > previous ? signals.approvedAt : 0
1186    record.reviews.push(...fresh.map((r) => r.id))
1187    if (approvedAt) record.approvedAt = approvedAt
1188    return [
1189      {
1190        pr,
1191        reviews: fresh,
1192        approvedAt: approvedAt > fresh[fresh.length - 1].at ? approvedAt : 0,
1193        undo: (r) => {
1194          if (approvedAt && r.approvedAt === approvedAt) r.approvedAt = previous
1195          const pending = fresh.map((f) => String(f.id))
1196          r.reviews = r.reviews.filter((id) => !pending.includes(String(id)))
1197        },
1198      },
1199    ]
1200  }