opencode-bug-review-gate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +105 -0
  3. package/package.json +35 -0
  4. package/src/index.ts +372 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Coral Bricks
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # opencode-bug-review-gate
2
+
3
+ An [opencode](https://opencode.ai) plugin that automatically runs a bug review
4
+ over every turn that edited files, drives fix rounds for blocking findings, and
5
+ resumes turns that were interrupted midstream.
6
+
7
+ ## Why
8
+
9
+ Two failure modes make coding sessions silently stall or ship unreviewed
10
+ changes:
11
+
12
+ 1. **Unreviewed edits** — agents mark turns "done" without anyone checking the
13
+ diff. This gate runs a dedicated review subagent after every turn that
14
+ edited files, parses its verdict, and re-prompts the session to fix every
15
+ BUG-class finding (up to `maxFixRounds` rounds).
16
+ 2. **Interrupted turns** — a model stream can terminate prematurely (e.g. a
17
+ spurious EOS from the provider or the model itself) and report a clean
18
+ `stop` with zero output. opencode treats that as a normal end-of-turn, so
19
+ the session just goes idle. This gate detects the signature — a finished
20
+ turn with no error, no text, and no tool calls — and automatically sends a
21
+ continue prompt (up to `maxAutoContinues` times), resuming the task with
22
+ the same agent that was interrupted. It also retries a review that dies
23
+ the same way, and resumes a fix turn that dies the same way.
24
+
25
+ ## Install
26
+
27
+ Add to `opencode.json` (or `~/.config/opencode/opencode.json`):
28
+
29
+ ```json
30
+ {
31
+ "plugin": ["opencode-bug-review-gate"]
32
+ }
33
+ ```
34
+
35
+ With options:
36
+
37
+ ```json
38
+ {
39
+ "plugin": [
40
+ ["opencode-bug-review-gate", { "maxAutoContinues": 3 }]
41
+ ]
42
+ }
43
+ ```
44
+
45
+ Restart opencode after changing config — plugins are loaded once at startup.
46
+
47
+ ## Options
48
+
49
+ | Option | Default | Description |
50
+ | --------------------- | -------------- | ------------------------------------------------------------------------ |
51
+ | `reviewAgent` | `"bug-review"` | Agent that performs the static review. |
52
+ | `maxFixRounds` | `2` | Fix-and-re-review rounds after an `ISSUES` verdict. |
53
+ | `maxNoVerdictRetries` | `1` | Retries when a review ends without a verdict line. |
54
+ | `maxAutoContinues` | `2` | Resume attempts for turns that stop with no model output. |
55
+ | `askResponse` | `"once"` | How gate-driven turns answer permission asks (`"once"` or `"reject"`). |
56
+ | `autoContinue` | `true` | Resume turns that stop with no model output. |
57
+ | `defineAgent` | `true` | Register a default `bug-review` subagent when the config has none. |
58
+
59
+ ## The default review agent
60
+
61
+ If your config does not define an agent named `bug-review` (or your
62
+ `reviewAgent` value), the plugin registers one via its `config` hook: a
63
+ subagent that cannot edit files (`edit: deny`, the hard guarantee — read,
64
+ glob, grep, and list default to allow), whose bash permission allows only
65
+ git diff/log/status/show and asks for everything else, and whose prompt
66
+ directs it to find real defects, classify findings (BUG / RISK / VERIFY),
67
+ cite `file:line`, and end with a verdict line:
68
+
69
+ ```
70
+ PASS — no blocking issues found
71
+ ISSUES — n blocking, m advisory
72
+ ```
73
+
74
+ Define your own agent with that name to override it entirely.
75
+
76
+ ## Security notes
77
+
78
+ - The review agent cannot edit or write files, and the review prompt forbids
79
+ executing the code under review.
80
+ - While the gate is driving a session (review / fix / resume turns), permission
81
+ asks are answered automatically (`askResponse`, default `"once"` = allow one
82
+ execution) so headless runs cannot hang on a dialog. Normal user turns are
83
+ never auto-answered. Set `askResponse` to `"reject"` to make gate turns fail
84
+ fast instead, or restrict the review agent's permissions further.
85
+ - Auto-resume only fires for turns that edited files (the gate only runs for
86
+ those), only when the turn's stream ended with no error and no visible
87
+ output, and is capped by `maxAutoContinues`.
88
+
89
+ ## Verdicts
90
+
91
+ - `PASS` — toast, session goes idle.
92
+ - `ISSUES` — the gate prompts the session to fix every BUG-class finding and
93
+ re-reviews, up to `maxFixRounds` times; remaining issues produce a warning
94
+ toast.
95
+ - No verdict after retries — warning toast, no auto-fixing.
96
+ - Degenerate stops (main turn, review, or fix turn) — auto-continue, capped.
97
+
98
+ ## Compatibility
99
+
100
+ Targets opencode ≥ 1.18 (plugin API with the `config` hook and
101
+ `client.session.messages`). No runtime dependencies.
102
+
103
+ ## License
104
+
105
+ MIT
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "opencode-bug-review-gate",
3
+ "version": "0.1.0",
4
+ "description": "opencode plugin: automated post-change bug review gate, with auto-resume for turns interrupted midstream",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "src/index.ts",
8
+ "author": "Coral Bricks",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/Coral-Bricks-AI/opencode-bug-review-gate.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/Coral-Bricks-AI/opencode-bug-review-gate/issues"
15
+ },
16
+ "homepage": "https://github.com/Coral-Bricks-AI/opencode-bug-review-gate#readme",
17
+ "files": [
18
+ "src",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "keywords": [
23
+ "opencode",
24
+ "opencode-plugin",
25
+ "code-review",
26
+ "agent"
27
+ ],
28
+ "peerDependencies": {
29
+ "@opencode-ai/plugin": ">=1.18.0"
30
+ },
31
+ "devDependencies": {
32
+ "@opencode-ai/plugin": "^1.18.4",
33
+ "typescript": "^5.9.2"
34
+ }
35
+ }
package/src/index.ts ADDED
@@ -0,0 +1,372 @@
1
+ import type { Plugin } from "@opencode-ai/plugin"
2
+
3
+ // How the gate answers permission asks raised by its own review/fix/resume
4
+ // turns. "once" keeps the turn alive (the agent may run the command one time);
5
+ // "reject" would abort the turn entirely, losing the review.
6
+ type AskResponse = "once" | "reject"
7
+
8
+ export type Options = {
9
+ /** Agent that performs the static review. Defaults to "bug-review". */
10
+ reviewAgent?: string
11
+ /** Fix rounds after an ISSUES verdict. Defaults to 2. */
12
+ maxFixRounds?: number
13
+ /** Retries for a review that ends without a verdict line. Defaults to 1. */
14
+ maxNoVerdictRetries?: number
15
+ /** Auto-continues for turns that stop with no model output. Defaults to 2. */
16
+ maxAutoContinues?: number
17
+ /** How gate-driven turns answer permission asks. Defaults to "once". */
18
+ askResponse?: AskResponse
19
+ /** Resume turns that stop with no model output. Defaults to true. */
20
+ autoContinue?: boolean
21
+ /** Define the default review agent when the config does not provide one. Defaults to true. */
22
+ defineAgent?: boolean
23
+ }
24
+
25
+ const DEFAULT_REVIEW_AGENT_PROMPT = `You are a meticulous bug reviewer. Your job is to find real defects in
26
+ code that was just written or modified, and to challenge the reasoning
27
+ that produced it. You do NOT fix things — you report findings.
28
+
29
+ Review the changes (ask for or locate the diff if not provided) and
30
+ scrutinize:
31
+
32
+ ## Code correctness
33
+ - Edge cases: empty inputs, zero/negative values, nil/null, off-by-one,
34
+ boundary conditions, unicode/encoding issues.
35
+ - Error handling: unhandled exceptions, swallowed errors, missing
36
+ rollbacks/cleanup, resource leaks (files, connections, locks).
37
+ - Concurrency: race conditions, deadlocks, non-thread-safe shared state.
38
+ - Type/logic errors: wrong operators, inverted conditions, unreachable
39
+ branches, incorrect defaults, copy-paste mistakes (wrong variable
40
+ names, wrong constants).
41
+
42
+ ## Reasoning quality
43
+ - Assumptions presented as facts: verify against the actual codebase,
44
+ not the claim.
45
+ - Claims that code "works" or "handles X" without evidence — check the
46
+ code paths.
47
+ - Mismatch between stated intent and what the code actually does.
48
+ - Silent scope creep: changes to files/behavior beyond what was asked.
49
+
50
+ ## Security & robustness
51
+ - Injection, path traversal, exposed secrets, unsafe deserialization,
52
+ missing auth checks on new endpoints.
53
+
54
+ ## Process
55
+ - Run whatever read-only verification you can (grep for callers of
56
+ changed functions, check test coverage of modified paths, inspect
57
+ dependent code).
58
+ - For each finding, cite file:line and explain the concrete failure
59
+ scenario (input → wrong behavior), not just a style complaint.
60
+ - Classify each finding: BUG (will misbehave), RISK (could misbehave
61
+ under conditions), VERIFY (claim you could not confirm).
62
+ - End with a verdict: "PASS — no blocking issues found" or "ISSUES —
63
+ n blocking, m advisory" plus a one-line summary.
64
+
65
+ Be skeptical. A quiet, empty report is only acceptable if you actively
66
+ looked and can say what you checked.`
67
+
68
+ const DEFAULT_REVIEW_AGENT_DESCRIPTION =
69
+ "Reviews generated code and the reasoning behind it for bugs, edge cases, and incorrect assumptions. Use after any non-trivial code change, or whenever the main agent should verify its own work before finishing."
70
+
71
+ const EDIT_TOOLS = new Set(["edit", "write", "patch", "apply"])
72
+
73
+ type GateState = { edited: boolean; busy: boolean }
74
+
75
+ type ReviewOutcome = "pass" | "no-verdict" | "issues-remain" | "no-fix-edits" | "fix-interrupted"
76
+
77
+ function textOf(parts: unknown): string {
78
+ if (!Array.isArray(parts)) return ""
79
+ const out: string[] = []
80
+ for (const part of parts) {
81
+ if (part && typeof part === "object") {
82
+ const p = part as { type?: string; text?: unknown }
83
+ if (p.type === "text" && typeof p.text === "string") out.push(p.text)
84
+ }
85
+ }
86
+ return out.join("\n")
87
+ }
88
+
89
+ function lastVerdict(text: string): "PASS" | "ISSUES" | undefined {
90
+ let verdict: "PASS" | "ISSUES" | undefined
91
+ const re = /(?:^|\n)[ \t]*(PASS|ISSUES)\b/g
92
+ let m: RegExpExecArray | null
93
+ while ((m = re.exec(text))) verdict = m[1] as "PASS" | "ISSUES"
94
+ return verdict
95
+ }
96
+
97
+ export const BugReviewGate: Plugin = async ({ client, serverUrl }, rawOptions = {}) => {
98
+ const options = rawOptions as Options
99
+ const reviewAgent = options.reviewAgent ?? "bug-review"
100
+ const maxFixRounds = options.maxFixRounds ?? 2
101
+ const maxNoVerdictRetries = options.maxNoVerdictRetries ?? 1
102
+ const maxAutoContinues = options.maxAutoContinues ?? 2
103
+ const askResponse: AskResponse = options.askResponse ?? "once"
104
+ const autoContinue = options.autoContinue !== false
105
+ const defineAgent = options.defineAgent !== false
106
+ const marker = `[[gate:${reviewAgent}]]`
107
+
108
+ const state = new Map<string, GateState>()
109
+ const answered = new Set<string>()
110
+
111
+ const get = (sessionID: string): GateState => {
112
+ let s = state.get(sessionID)
113
+ if (!s) {
114
+ s = { edited: false, busy: false }
115
+ state.set(sessionID, s)
116
+ }
117
+ return s
118
+ }
119
+
120
+ const log = async (
121
+ level: "debug" | "info" | "warn" | "error",
122
+ message: string,
123
+ extra?: Record<string, unknown>,
124
+ ) => {
125
+ try {
126
+ await client.app.log({ body: { service: "bug-review-gate", level, message, extra } })
127
+ } catch {
128
+ // logging must never break the gate
129
+ }
130
+ }
131
+
132
+ const toast = async (message: string, variant: "success" | "warning" | "error") => {
133
+ try {
134
+ await client.tui.showToast({ body: { message, variant } })
135
+ } catch {
136
+ // no TUI attached (headless / serve mode)
137
+ }
138
+ }
139
+
140
+ const ask = async (sessionID: string, body: Record<string, unknown>): Promise<string> => {
141
+ const res = await client.session.prompt({ path: { id: sessionID }, body: body as never })
142
+ const data = ((res as { data?: unknown }).data ?? res) as { parts?: unknown }
143
+ return textOf(data.parts)
144
+ }
145
+
146
+ // Inspect the turn that just went idle. "interrupted" is true when its last
147
+ // assistant message has no error yet produced no visible output — the stream
148
+ // stopped cleanly without completing (a degenerate stop, e.g. a premature
149
+ // EOS). Errored or user-aborted turns are never "interrupted", so they are
150
+ // left stopped. "agent" is the agent that ran the turn, so a resume reuses
151
+ // it instead of falling back to the default agent.
152
+ const lastTurnState = async (sessionID: string): Promise<{ interrupted: boolean; agent?: string }> => {
153
+ try {
154
+ const res = await client.session.messages({ path: { id: sessionID } })
155
+ const list = ((res as { data?: unknown }).data ?? res) as Array<{
156
+ info?: { role?: string; agent?: string; error?: unknown }
157
+ parts?: unknown
158
+ }>
159
+ let agent: string | undefined
160
+ for (let i = list.length - 1; i >= 0; i--) {
161
+ const info = list[i]?.info
162
+ if (info?.role === "user") {
163
+ if (info.agent && info.agent !== reviewAgent) agent = info.agent
164
+ break
165
+ }
166
+ }
167
+ for (let i = list.length - 1; i >= 0; i--) {
168
+ const info = list[i]?.info
169
+ if (info?.role !== "assistant") continue
170
+ if (info.error) return { interrupted: false }
171
+ return { interrupted: textOf(list[i]?.parts).trim().length === 0, agent }
172
+ }
173
+ return { interrupted: false }
174
+ } catch (err) {
175
+ await log("warn", `failed to inspect last turn: ${err instanceof Error ? err.message : String(err)}`)
176
+ return { interrupted: false }
177
+ }
178
+ }
179
+
180
+ const runReviewCycle = async (sessionID: string, s: GateState): Promise<ReviewOutcome> => {
181
+ for (let round = 0; round <= maxFixRounds; round++) {
182
+ let verdict: "PASS" | "ISSUES" | undefined = undefined
183
+ for (let retry = 0; ; retry++) {
184
+ const review = await ask(sessionID, {
185
+ agent: reviewAgent,
186
+ parts: [
187
+ {
188
+ type: "text",
189
+ text: `${marker} Automated post-change review. Files were edited in this session earlier. Locate those changes (read the session transcript; run git diff / git status if this is a git repo; read the edited files) and review them per your protocol. End with your verdict line: "PASS — no blocking issues found" or "ISSUES — n blocking, m advisory". Note: keep this a static review — do NOT execute the code under review (no python/node/test runs); rely on git commands, reading files, and grep.`,
190
+ },
191
+ ],
192
+ })
193
+ verdict = lastVerdict(review)
194
+ if (verdict !== undefined || retry >= maxNoVerdictRetries) break
195
+ await log("warn", "review ended without a verdict line; retrying review", { sessionID })
196
+ }
197
+ await log("info", `review round ${round + 1} verdict: ${verdict ?? "NO_VERDICT"}`, { sessionID })
198
+ if (verdict === "PASS") return "pass"
199
+ if (verdict === undefined) return "no-verdict"
200
+ if (round === maxFixRounds) break
201
+ s.edited = false
202
+ await ask(sessionID, {
203
+ parts: [
204
+ {
205
+ type: "text",
206
+ text: `${marker} The automated review above found blocking issues. Fix every BUG-class finding now: edit the code, then run lint/tests where available. Do not launch another bug-review yourself — the gate will re-review your fixes automatically.`,
207
+ },
208
+ ],
209
+ })
210
+ if (!s.edited) {
211
+ // Distinguish a fix turn that died midstream (no output, no edits)
212
+ // from one that deliberately made no edits.
213
+ const turn = await lastTurnState(sessionID)
214
+ if (turn.interrupted) {
215
+ await log("warn", "fix turn ended with no model output; treating as interrupted", { sessionID })
216
+ return "fix-interrupted"
217
+ }
218
+ await log("info", "fix turn made no file edits; stopping gate", { sessionID })
219
+ return "no-fix-edits"
220
+ }
221
+ }
222
+ return "issues-remain"
223
+ }
224
+
225
+ const runGate = async (sessionID: string) => {
226
+ const s = get(sessionID)
227
+ s.busy = true
228
+ s.edited = false
229
+ try {
230
+ await log("info", "gate started", { sessionID })
231
+ for (let resume = 0; ; resume++) {
232
+ const { interrupted, agent } = autoContinue ? await lastTurnState(sessionID) : { interrupted: false, agent: undefined }
233
+ const outcome = await runReviewCycle(sessionID, s)
234
+ if (outcome === "issues-remain") {
235
+ await log("warn", `issues remain after ${maxFixRounds} fix rounds`, { sessionID })
236
+ await toast(`bug-review gate: ISSUES remain after ${maxFixRounds} fix rounds — needs attention`, "warning")
237
+ return
238
+ }
239
+ if (outcome === "no-fix-edits") return
240
+ const mustResume = autoContinue && (outcome === "fix-interrupted" || interrupted)
241
+ if (!mustResume || resume >= maxAutoContinues) {
242
+ if (outcome === "pass") {
243
+ await toast("bug-review gate: PASS", "success")
244
+ } else if (outcome === "no-verdict") {
245
+ await log("warn", "review ended without a verdict line; not auto-fixing", { sessionID })
246
+ await toast("bug-review gate: no verdict — see transcript", "warning")
247
+ } else {
248
+ await toast("bug-review gate: fix turn interrupted — needs attention", "warning")
249
+ }
250
+ if (mustResume) {
251
+ await log("warn", `turn ended with no model output; auto-continue cap (${maxAutoContinues}) reached`, { sessionID })
252
+ }
253
+ return
254
+ }
255
+ await log("info", `turn ended with no model output; auto-continuing (${resume + 1}/${maxAutoContinues})`, {
256
+ sessionID,
257
+ })
258
+ await toast("bug-review gate: resuming interrupted turn", "warning")
259
+ s.edited = false
260
+ const body: Record<string, unknown> = {
261
+ parts: [
262
+ {
263
+ type: "text",
264
+ text: `${marker} The previous turn ended abnormally — the model stream stopped before producing any output. Continue the task from where it stopped (re-read the transcript above to see what was in progress). Do not launch a bug-review yourself — the gate reviews changes automatically.`,
265
+ },
266
+ ],
267
+ }
268
+ // A fix-interrupted resume continues the fix turn, which ran under
269
+ // the default agent — not the main turn's agent.
270
+ if (agent && outcome !== "fix-interrupted") body.agent = agent
271
+ await ask(sessionID, body)
272
+ }
273
+ } catch (err) {
274
+ await log("error", `gate failed: ${err instanceof Error ? err.message : String(err)}`)
275
+ } finally {
276
+ s.busy = false
277
+ s.edited = false
278
+ }
279
+ }
280
+
281
+ const respondPermission = async (sessionID: string, permissionID: string) => {
282
+ try {
283
+ const res = await fetch(`${serverUrl}session/${sessionID}/permissions/${permissionID}`, {
284
+ method: "POST",
285
+ headers: { "content-type": "application/json" },
286
+ body: JSON.stringify({ response: askResponse }),
287
+ })
288
+ await log("info", `auto-answered permission ${permissionID} with ${askResponse} (status ${res.status})`, {
289
+ sessionID,
290
+ })
291
+ } catch (err) {
292
+ await log("warn", `failed to answer permission ${permissionID}: ${err instanceof Error ? err.message : String(err)}`)
293
+ }
294
+ }
295
+
296
+ return {
297
+ config: async (input) => {
298
+ if (!defineAgent) return
299
+ if (input.agent?.[reviewAgent]) return
300
+ input.agent ??= {}
301
+ input.agent[reviewAgent] = {
302
+ description: DEFAULT_REVIEW_AGENT_DESCRIPTION,
303
+ mode: "subagent",
304
+ prompt: DEFAULT_REVIEW_AGENT_PROMPT,
305
+ permission: {
306
+ edit: "deny",
307
+ bash: {
308
+ "*": "ask",
309
+ "git diff*": "allow",
310
+ "git log*": "allow",
311
+ "git status*": "allow",
312
+ "git show*": "allow",
313
+ },
314
+ },
315
+ }
316
+ },
317
+
318
+ "permission.ask": async (input, output) => {
319
+ // While the gate is driving a session (review/fix/resume turns), answer
320
+ // permission asks automatically so headless runs can't hang on a
321
+ // dialog. The configured response keeps the turn alive. Normal user
322
+ // turns are unaffected.
323
+ const s = state.get(input.sessionID)
324
+ if (s?.busy) {
325
+ output.status = askResponse === "once" ? "allow" : "deny"
326
+ await log("info", `permission.ask hook answered ask with ${output.status}`, { sessionID: input.sessionID, id: input.id })
327
+ }
328
+ },
329
+
330
+ "tool.execute.after": async ({ tool, sessionID }) => {
331
+ if (EDIT_TOOLS.has(tool)) get(sessionID).edited = true
332
+ },
333
+
334
+ event: async ({ event }) => {
335
+ if (event.type === "session.deleted") {
336
+ const info = (event.properties as { info?: { id?: string } }).info
337
+ if (info?.id) state.delete(info.id)
338
+ return
339
+ }
340
+ if ((event.type as string) === "permission.asked" || event.type === "permission.updated") {
341
+ // Safety net for permission asks raised by gate-driven turns
342
+ // (review/fix/resume): watch the ask broadcast and answer it via the
343
+ // server API (the same channel a TUI client uses), so headless
344
+ // runs can't hang on a dialog. Normal user turns are unaffected
345
+ // (their sessions aren't gate-busy).
346
+ const p = event.properties as { id?: string; sessionID?: string }
347
+ const s = p.sessionID ? state.get(p.sessionID) : undefined
348
+ if (p.id && s?.busy && !answered.has(p.id)) {
349
+ answered.add(p.id)
350
+ await respondPermission(p.sessionID as string, p.id)
351
+ }
352
+ return
353
+ }
354
+ if (event.type !== "session.idle") return
355
+ const sessionID = (event.properties as { sessionID?: string }).sessionID
356
+ if (!sessionID) return
357
+ const s = get(sessionID)
358
+ if (!s.edited || s.busy) return
359
+ let session: { id?: string; parentID?: string }
360
+ try {
361
+ const res = await client.session.get({ path: { id: sessionID } })
362
+ session = ((res as { data?: unknown }).data ?? res) as { id?: string; parentID?: string }
363
+ } catch {
364
+ return
365
+ }
366
+ if (!session?.id || session.parentID) return
367
+ void runGate(sessionID)
368
+ },
369
+ }
370
+ }
371
+
372
+ export default BugReviewGate