opencode-forgekeeper 2.4.0 → 3.0.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/README.md +494 -43
  2. package/dist/index.js +2855 -509
  3. package/dist/tui.js +819 -28
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  An [OpenCode](https://opencode.ai) plugin that tracks the issues or PRs/MRs a session works on.
4
4
  It prefixes the session title with their references, shows the PR/MR status in the prompt
5
- footer, and tells the agent about new review feedback. It supports GitLab and GitHub. The status
5
+ footer, and surfaces new review feedback in a persistent inbox. It supports GitLab and GitHub. The status
6
6
  and review notifications are optional; see [Keep only session titles](#keep-only-session-titles).
7
7
 
8
8
  Requires OpenCode 2.2 or later. It replaces
@@ -43,6 +43,32 @@ toasts.
43
43
  Without a forge CLI, titles still follow targets and branches, and `/forge-status` reports
44
44
  what's missing, such as `glab is not installed`.
45
45
 
46
+ ## Upgrade from forgekeeper 2.x
47
+
48
+ Forgekeeper 3 changes where review notifications go, and renames its options:
49
+
50
+ - **Notifications:** review feedback no longer resumes the session or asks the agent to
51
+ address it. It goes to the `/forge-feedback` inbox. To also post it to the session, set
52
+ `delivery.reviews` to `"passive"` or `"summary"`. See
53
+ [Review notifications](#review-notifications).
54
+ - **Options:** Forgekeeper no longer reads the former option names. It shows an error toast
55
+ that names each one, and uses that option's default instead. For example, a former
56
+ `reviewStatus: false` leaves status on, so rename it to `status: false` before you upgrade.
57
+ - **Monitoring:** Forgekeeper also watches recent sessions with PR/MR targets that no CLI
58
+ has shown, and announces conflicts, failed jobs, and merges of their targets. See
59
+ [Which sessions the server watches](#which-sessions-the-server-watches) and
60
+ [Merge request updates](#merge-request-updates). Set `watch.lifecycle` to `false` to
61
+ stop the merge request updates.
62
+
63
+ Rename the former options as follows:
64
+
65
+ | Former option | Now |
66
+ | ------------------------------------------- | --------------------------------------------------------------------------------------- |
67
+ | `reviewStatus` | `status` |
68
+ | `notifyHumanReviews` | `watch.humanReviews` |
69
+ | `notifyAutomatedReviews`, `notifyDuoReview` | `watch.automatedReviews`, and `watch.reviewBots` for review bots |
70
+ | `resumeSession` | `delivery.reviews`: `"summary"` starts a turn, and `"passive"` queues for the next turn |
71
+
46
72
  ## Session targets
47
73
 
48
74
  The server registers the `set_session_target` tool and adds instructions to the agent's
@@ -189,9 +215,38 @@ The server caches each session's status and polls every 2 minutes while a CLI sh
189
215
  PR/MR, or every 30 seconds while an automated review runs. Each change reaches the CLIs as
190
216
  an RPC event.
191
217
 
218
+ Sessions in the same checkout share cached status and human-feedback fetches for the same
219
+ forge, host, project, and PR/MR number. Overlapping requests share one in-flight fetch.
220
+ Successful status results remain fresh for the polling interval, capped at 30 seconds
221
+ while an automated review runs. Complete human-feedback results remain fresh for 60 seconds.
222
+ Failures and incomplete feedback are not retained for reuse. Each cache keeps at most
223
+ 1,000 entries and is cleared when the plugin stops.
224
+
225
+ Session timers, review baselines, and acknowledgements remain independent. Manual refresh
226
+ invalidates the selected session's target caches and feedback fetch timestamps. Completed turns invalidate the checkout's caches
227
+ before notifying watched sessions, so changes such as pushes can be observed. Caches are
228
+ not shared between checkouts, whose CLI credentials and repository context may differ.
229
+
192
230
  ### Which sessions the server watches
193
231
 
194
- The server watches only sessions that a CLI has shown since it started. The CLI renews a
232
+ The server discovers recent sessions at startup, every 5 minutes, and after target changes
233
+ or completed turns. A session qualifies when it is a non-archived root session in the
234
+ checkout, has an explicit PR/MR target, and has a user message in the last 72 hours.
235
+ A compaction drops earlier messages from the session's context, so when the context has no
236
+ user message, the newest compaction counts instead, since compactions run only during
237
+ turns. Viewing a tab, changing its title, or receiving a background notification does not
238
+ extend that window. Sessions without readable conversation activity are skipped. Discovery
239
+ reads conversation timestamps locally; it does not send conversations to a model.
240
+
241
+ Discovered sessions poll without a CLI lease while at least one explicit target is open.
242
+ Targets that couldn't be resolved, such as while the forge returns nothing for them, keep
243
+ being retried, even when the session's other targets closed or merged. While none of the
244
+ targets resolves, retries back off like failed lookups. Once every target is found and
245
+ closed or merged, polling stops. Discovery reevaluates the activity window
246
+ every 5 minutes. It runs only while OpenCode has this checkout's plugin instance loaded;
247
+ it is not a separate scheduler and cannot wake an unloaded location or stopped service.
248
+
249
+ The server also watches sessions that a CLI has shown since it started. The CLI renews a
195
250
  lease on each of them every minute, and a lease that isn't renewed for 3 minutes ends, such
196
251
  as after the CLI exits. A session keeps polling while it's on screen in any CLI. A hidden
197
252
  session with a lease keeps polling while it has a running automated review, human feedback
@@ -209,7 +264,7 @@ lookup or fetch ran is left to its new checkout. A review that finishes during t
209
264
  go unannounced, because the new instance never saw it running. If the session can't be
210
265
  read, the notification is retried later instead of being dropped.
211
266
 
212
- After that read, the server checks the targets again and writes the message for the
267
+ After that read, the server checks the targets again and records feedback for the
213
268
  PRs/MRs that are still targets. A target removed in the meantime isn't mentioned.
214
269
 
215
270
  When the server plugin stops, such as on a reload, no new lookup, fetch, notification,
@@ -222,30 +277,185 @@ sent again.
222
277
 
223
278
  ## Review notifications
224
279
 
225
- For each explicit target that is an open PR/MR, the server tells the agent about reviews.
280
+ For each explicit target that is an open PR/MR, the server records new review feedback.
226
281
  A target that merges or closes gets no more notifications, and a notification waiting for a
227
282
  retry is dropped.
228
- Each notification is a queued synthetic message, followed by a toast in the CLIs that have
229
- shown the session. With session tabs on, those CLIs also reopen the session's tab if you
230
- closed it, without leaving the session you're in. The message resumes the session unless
231
- `resumeSession` is `false`.
232
- Messages include the full URL of each PR/MR. PRs/MRs from the title or the branch don't get
233
- notifications.
283
+ Feedback goes to a persistent inbox. The `delivery.reviews` option sets what else
284
+ an update does:
285
+
286
+ - `inbox`, the default, posts nothing to the session.
287
+ - `passive` queues a session message with `resume: false`, which starts no turn. It
288
+ doesn't mark the session unread, because the TUI marks sessions unread only when a
289
+ turn ends. When something else wakes the session, the message can run as its own
290
+ step; Forgekeeper then sends only the notice, without tools or the session's history.
291
+ - `summary` queues a message that starts a summary turn, described below. A turn that
292
+ ends marks the session unread. Updates without comments, such as Copilot reviews,
293
+ get a passive message instead.
294
+
295
+ Session messages contain the notification title, when the newest comment was written, and
296
+ links, not review bodies or instructions to act. Updates without comments, such as Copilot
297
+ reviews, show when Forgekeeper detected them instead. Times are absolute and local, with
298
+ their time zone, such as `Oct 28, 2026 at 3:25 PM GMT+1`, so they stay accurate in the
299
+ session's history. `/forge-feedback` shows the same time, and a summary starts by saying it.
300
+
301
+ Each update keeps the mode it was recorded in, so changing `delivery.reviews`
302
+ affects later updates only: switching to `summary` never starts turns for updates already
303
+ in the inbox. Summarize those with Ctrl+R instead. Stable message IDs deduplicate
304
+ automatic retries, including after reloads. Updates that haven't reached their session are
305
+ retried every minute and after a reload, each on its own, so one that keeps failing doesn't
306
+ hold the others back. Delivery doesn't hold up the inbox: `/forge-feedback`, pausing, and
307
+ acknowledging work while a summary reads its comments, and an update in flight isn't sent
308
+ again by a retry. An update acknowledged or cleared while its comments are read isn't
309
+ posted, and one paused meanwhile waits until monitoring resumes. An update whose session was deleted, archived, or moved, or that no
310
+ longer targets the PR/MR, is dropped, including when that changed while its comments were
311
+ read. Switching `delivery.reviews` to `inbox` stops retries of review updates, and
312
+ leaves them undelivered, so they go again once it's switched back. Merge request updates
313
+ follow `delivery.lifecycle` the same way. A summary requested from `/forge-feedback` is a
314
+ new request each time.
315
+
316
+ ### Summary turns
317
+
318
+ A summary turn runs after any active turn finishes, including its tool calls. Forgekeeper
319
+ reads the announced comments, at most 20 of them and 4,000 characters each, with context
320
+ the summary can't fetch itself:
321
+
322
+ - Earlier replies in each comment's thread, at most 5 of 1,000 characters each. GitHub
323
+ provides only the comment a reply answers.
324
+ - The code an inline comment is on, at most 3,000 characters. On GitLab, that's 10 lines
325
+ on either side of the commented line, from the MR's head commit, or from its base commit
326
+ for a removed line, for at most 10 files. On GitHub, it's the end of the diff hunk. A
327
+ file that can't be read leaves out only its code.
328
+
329
+ The PR/MR's author wrote that code, so the summary treats it as untrusted, like the
330
+ comments. Forgekeeper keeps all of this in plugin storage until the next user prompt, or for
331
+ at most 7 days. It never enters the session's history, where later turns with tools could
332
+ read it.
333
+
334
+ #### Linked notes
335
+
336
+ On GitLab, review comments often link to supporting notes, such as an issue in another
337
+ project with further details. A summary can read those with one tool,
338
+ `fetch_linked_review_note(url)`, which accepts only links that the announced comments'
339
+ own text contains:
340
+
341
+ - HTTPS links without credentials or a port, on the MR's GitLab host, to an issue, work
342
+ item, or MR note (`…/-/issues/1#note_2`). Notes in other projects count. Links in thread
343
+ replies, in code, or in a linked note don't, so nothing a linked note says can widen
344
+ what the summary reads.
345
+ - At most 5 links per summary, each at most 300 characters, without duplicates or links
346
+ to the announced comments.
347
+
348
+ Forgekeeper stores these links with the summary's record before posting its message. The
349
+ tool reads a note with one fixed GraphQL query through `glab`, and keeps it only if the
350
+ note GitLab returns, of any note type, has the link's note ID and names the same project,
351
+ issue or MR, and note. GitLab returns nothing for notes you can't read. The model supplies
352
+ only the link: never a host, query, method, or header.
353
+
354
+ Each summary gets 5 calls. Every call counts, including repeats, refused links, and failed
355
+ reads, and is spent before the read, so neither concurrent calls nor a reload restore it.
356
+ A linked note keeps at most 4,000 characters, and all of them together at most 12,000.
357
+ The comments come first in the request's 40,000-character budget. Linked notes, with
358
+ their links and status lines, get only what the comments leave; notes that don't fit are
359
+ reported as left out, and without room for the readable links, the request offers no tool.
360
+
361
+ The tool returns only a receipt that names the link, which is what stays in the session's
362
+ history. The note's text goes to the summary's record, and from there only into the
363
+ summary's next requests, as a `<linked-note>` block. Every request that can hold linked
364
+ notes, including the last one, which has no tool, keeps the instructions that treat them
365
+ as untrusted like the comments.
366
+
367
+ The tool works only for the summary of the session's latest model request, and checks
368
+ the session's context again on each call, so a user prompt ends its access even before
369
+ the next request. A record older than 7 days counts as missing in every check, not just
370
+ at the next startup. After a reload, the tool refuses until the next request. A stopped
371
+ instance writes nothing, and cleanup waits for its calls, so a late call can't overwrite
372
+ the budget its replacement uses. Ordinary requests never include the tool, and summaries
373
+ without links, notices, and steps whose summary can't be read get no tools at all.
374
+ GitHub summaries don't read linked notes.
375
+
376
+ Forgekeeper recognizes a step whose input is one of its messages in two independent ways:
377
+
378
+ - Every message it posts ends with a tag naming its ID, such as
379
+ `[forgekeeper:msg_forgekeeper_…]`, which the context hook finds in the request it's
380
+ about to send. This doesn't depend on stored state, so a failed restore or an expired
381
+ record can't let the message run as an ordinary request. A tag in a user prompt, such
382
+ as a pasted transcript, doesn't count when the session's context shows the prompt.
383
+ - It tracks every message it posts until a user prompt follows that message, and reads the
384
+ session's context before each request. A message still in the queue stays tracked while
385
+ earlier steps run, and a reminder sent after the message doesn't end its step.
386
+
387
+ Before each model request that either way identifies, its context hook:
388
+
389
+ - removes every tool from the request, except the linked-note tool while the summary has
390
+ links left to read;
391
+ - replaces the system prompt with summary instructions that treat comments as untrusted;
392
+ - replaces the conversation with one message holding the comments and the linked notes
393
+ read so far, or the notice, so the request resends neither the session's history nor
394
+ the step's own tool calls;
395
+ - caps the output at 1,200 tokens.
396
+
397
+ If the review text is missing, such as after its record expired, the request asks for a
398
+ short reply saying so, still without tools or history. If the hook can't build the
399
+ replacement message, the request fails rather than keeping any of the session's messages.
400
+
401
+ As backstops, Forgekeeper refuses every tool execution other than the linked-note tool,
402
+ which checks its own calls and asks for no permissions, and every permission request,
403
+ while the session's latest model request was one of these, and treats a step it can't classify as
404
+ one, such as when the session's context can't be read, or when a compaction left no
405
+ input in it. The turn ends at the next user prompt. A later turn that another plugin starts
406
+ without a user prompt also runs without tools. The summary is the only part that stays in
407
+ the session's history.
408
+
409
+ `/forge-feedback` lists pending feedback for
410
+ the current checkout. Select an update to open its PR/MR or comment links, press Ctrl+D
411
+ to acknowledge it, press Ctrl+R to summarize it in its session in any delivery mode, or
412
+ press Ctrl+S to pause or resume review monitoring. Acknowledgement
413
+ only dismisses the local update; it does not reply to or resolve a remote discussion.
414
+ The home and session footers show the pending count, and connected CLIs in the checkout
415
+ show a toast even when the session's tab has never been visited. With session tabs on,
416
+ those CLIs also reopen the session's tab, without leaving the session you're in.
417
+
418
+ An update leaves the inbox when you acknowledge it, or when it no longer needs attention:
419
+
420
+ - Once every comment it announced is resolved, by anyone. Comments that can't be resolved,
421
+ such as GitLab comments outside a thread or GitHub review bodies, need acknowledging,
422
+ and so do updates without comments, such as Copilot reviews.
423
+ - Once its PR/MR merges or closes, or its session stops targeting it.
424
+
425
+ Forgekeeper checks resolution and PR/MR state only for sessions it monitors. Resolution
426
+ counts only from a fetch that no later change made obsolete, and state only after a lookup
427
+ finishes, so a reload doesn't clear updates before the session's targets are known. A
428
+ target change clears that target's updates right away, whether or not Forgekeeper
429
+ monitors the session. Working on the feedback, with or without the agent, doesn't clear an
430
+ update by itself.
431
+
432
+ The inbox retains the newest 500 updates per checkout across plugin reloads. It stores
433
+ session IDs, target URLs, notification titles, comment URLs, and timestamps, not comment
434
+ bodies or credentials. A pause persists across reloads and leaves status display available.
435
+
436
+ PRs/MRs from the title or the branch don't get review notifications.
234
437
 
235
438
  Before it sends a notification, the server checks that the PR/MR is still one of the
236
439
  session's targets. A lookup that started before the targets changed is discarded, so a
237
440
  removed target never triggers a notification.
238
441
 
239
- | Notification | GitLab | GitHub |
240
- | ------------------------------------------ | ----------- | -------------- |
241
- | An automated review finished with feedback | GitLab Duo | GitHub Copilot |
242
- | New human review feedback | MR comments | PR comments |
243
-
244
- - **Automated reviews**: for GitLab, Duo's final state of `REVIEWED` or `REQUESTED_CHANGES`
245
- asks the agent to read its comments. Other final states, such as `APPROVED`, send nothing.
246
- The server notifies when it sees a review go from running to finished, so a Duo review
247
- that finished before the server first saw it running isn't announced. Reviews that finish
248
- in the same poll share one message.
442
+ | Notification | GitLab | GitHub |
443
+ | ------------------------------------------ | -------------------------------- | -------------- |
444
+ | An automated review finished with feedback | GitLab Duo | GitHub Copilot |
445
+ | Comments from review bots | Security reviewers, `reviewBots` | `reviewBots` |
446
+ | New human review feedback | MR comments | PR comments |
447
+
448
+ - **Duo reviews**: new unresolved inline findings from the GitLab Duo bot create inbox
449
+ updates after the findings remain unchanged for five minutes. Forgekeeper does not need
450
+ to observe the review running. Summary-only reviews, approvals without inline findings,
451
+ system notes, and resolved findings send nothing. Comment IDs persist independently of
452
+ human feedback, including an empty baseline when monitoring starts without findings.
453
+ Existing findings become the initial baseline; new findings posted while OpenCode is
454
+ offline are detected after monitoring resumes if a baseline was already stored.
455
+ When available, `duoCreatedSession.id` groups findings by code-review workflow run.
456
+ A linked workflow must be terminal before delivery, and the watcher refetches before
457
+ notifying. Older GitLab versions and permission-hidden workflows use comment IDs and
458
+ the settling window without a workflow link. Separate runs use distinct inbox IDs.
249
459
  - **Copilot reviews**: the server notifies when a submitted, undismissed review has at least
250
460
  one inline comment. A review without inline comments, such as one that
251
461
  found nothing or couldn't run for lack of quota, sends nothing. Copilot reviews are
@@ -255,15 +465,43 @@ removed target never triggers a notification.
255
465
  review is requested.
256
466
  - **Human reviews**: new comments and replies from people other than you and bots are
257
467
  announced once none of their unresolved comments has changed for 5 minutes. Expect one
258
- message 5 to 7 minutes after the review goes quiet. Resolved threads are skipped.
468
+ update 5 to 7 minutes after the review goes quiet. Resolved threads are skipped.
469
+ - **Review bots**: comments from the bot accounts that `reviewBots` names are announced
470
+ like human reviews, including comments without a file or line, such as a security
471
+ review's findings. `reviewBots` maps each host to exact usernames, ignoring case. By
472
+ default, it names GitLab.com's security reviewers for `gitlab-org`:
473
+
474
+ ```json
475
+ {
476
+ "reviewBots": {
477
+ "gitlab.com": ["duo-security-review-gitlab-org", "ai-appsec-security-mr-reviewer-gitlab-org"]
478
+ }
479
+ }
480
+ ```
481
+
482
+ Duo Security Review has one account per top-level group, so add yours, such as
483
+ `duo-security-review-acme`, and a self-managed instance's accounts under its host. Your
484
+ list replaces the defaults, and `watch.reviewBots: false` turns this off. Names have no
485
+ wildcards, since a group owner can create a bot account with any free name, such as one
486
+ that starts like a security reviewer's. Only accounts that the forge flags as bots count,
487
+ and only on the host they're listed for. `watch.reviewBots` controls them on its own,
488
+ apart from `watch.humanReviews` and `watch.automatedReviews`. GitLab Duo's code review
489
+ findings always go through their own watcher instead. Internal notes, such as Duo
490
+ Security Review's, count when your account can read them.
259
491
 
260
492
  The first look at a PR/MR records its newest comment as a baseline, so older comments never
261
493
  count as new. Announced comments are stored per session and PR/MR, so a restart replays
262
494
  nothing but still catches comments posted in the meantime.
263
495
 
264
- A failed send shows an error toast and is retried after 10 minutes. A failed automated
265
- review notification stays pending in the server's storage, so it's retried even after a
266
- restart, as long as the review still has feedback and the PR/MR is still an open target.
496
+ Feedback counts as announced once the inbox has it. If Forgekeeper can't record it, such as
497
+ when its storage fails, it shows an error toast and tries again after 10 minutes. Duo and
498
+ human feedback then keep their comment IDs unannounced, so a restart can retry them, and
499
+ Copilot notifications stay pending in storage while the review still has feedback and the
500
+ PR remains an open target. Legacy state-only Duo pending notifications are discarded.
501
+
502
+ A session message that fails, such as when the comment text can't be read for a summary,
503
+ shows an error toast. The update stays in the inbox, which retries the message every
504
+ minute. Acknowledging the update ends those retries, and the update doesn't come back.
267
505
 
268
506
  Every checkout's server plugin instance shares the plugin's storage, so each baseline and
269
507
  each pending notification has its own storage key. Instances never overwrite each other's
@@ -272,6 +510,41 @@ records. A failed storage write shows an error toast once.
272
510
  On GitLab, the server reads the newest 2,000 comments. On busier MRs, edits to older
273
511
  comments don't count as activity.
274
512
 
513
+ ### Live comment subscriptions
514
+
515
+ GitLab feedback monitoring attempts an outbound TLS WebSocket connection to the MR's host,
516
+ using GitLab's internal `Noteable::NotesChannel` Action Cable interface. Sessions targeting
517
+ the same MR share one subscription within a checkout. No webhook receiver or project
518
+ administration permission is required. This interface is not a public GraphQL subscription
519
+ and can differ across GitLab versions.
520
+
521
+ After the server confirms a subscription, comment events trigger a feedback fetch. Events
522
+ within a one-second window share a refresh. Quiet connections reconcile comments every
523
+ 15 minutes, on the next scheduled status check. The five-minute settling window still
524
+ applies, and the watcher fetches again before delivering pending feedback. Initial connection
525
+ and reconnection also trigger a fetch to catch changes while disconnected.
526
+
527
+ GitHub and hosts that cannot establish a subscription keep polling. Connection failures,
528
+ rejections, and missing heartbeats restore normal feedback polling. Reconnection attempts
529
+ back off from 30 seconds to 15 minutes. Pausing feedback, removing the last session watching
530
+ an MR, or unloading the plugin closes its connection. Subscriptions only accelerate comment
531
+ monitoring; status, pipeline, and automated-review polling continue at their existing intervals.
532
+
533
+ On HTTP 429, subscription retries honor `Retry-After` and GitLab's `RateLimit-Reset` and
534
+ `RateLimit-ResetTime` headers, even when the wait exceeds 15 minutes. The cooldown applies
535
+ to all MR subscriptions on that origin within the checkout and survives removing and
536
+ re-adding a target during the plugin instance's lifetime. Other origins remain independent.
537
+ Bun's native WebSocket client hides rejected-handshake headers, so a failed handshake
538
+ triggers one HTTP upgrade probe with a 10-second timeout and redirects disabled. The probe
539
+ reads the reset headers and discards the body. A 429 without usable headers delays retries
540
+ by at least one minute. Normal authenticated API polling continues during this subscription
541
+ cooldown.
542
+
543
+ GitLab can classify bearer-authenticated `/-/cable` requests as unauthenticated for rate
544
+ limiting: its rate-limit authentication checks API and feed routes, while Action Cable
545
+ authenticates the bearer token later. A valid API token therefore does not necessarily
546
+ avoid the unauthenticated per-IP limit on this route.
547
+
275
548
  On GitHub, the server reads three lists: conversation comments, the text of submitted
276
549
  reviews, and the inline comments of review threads. It keeps the newest 2,000 of each.
277
550
  It scans every thread to include recent replies in old threads, within a limit of 200 API
@@ -283,25 +556,175 @@ The baseline also records comment IDs at its timestamp, so a new comment posted
283
556
  second can still be announced. Baselines stored by older plugin versions retain their
284
557
  timestamp-only behavior until a new baseline is created.
285
558
 
559
+ ## Merge request updates
560
+
561
+ Forgekeeper also watches each session's explicit PR/MR targets for three changes:
562
+
563
+ - **Conflicts:** a target that could merge starts to conflict with its target branch.
564
+ - **Failed jobs:** a job fails in a target's head pipeline, even while the pipeline still
565
+ runs.
566
+ - **Merges:** a target merges.
567
+
568
+ All come from the forge's own state, compared between two lookups, never from comments
569
+ such as GitLab's "can no longer be merged" note. The first lookup of a target only records
570
+ it, so nothing that happened before Forgekeeper first saw the target is announced. Records
571
+ persist across restarts, so a change that happened while OpenCode wasn't running is
572
+ announced at the next lookup. A
573
+ mergeability the forge hasn't settled yet, such as while GitLab rechecks an MR after a
574
+ push to it or to its target branch, keeps the last known state. A conflict that ends and
575
+ starts again is a new update.
576
+ Sessions are watched the same way as for review feedback, without visiting their tabs.
577
+
578
+ Failed jobs don't wait for the pipeline to finish, which can take much longer than the
579
+ job. Each lookup reads the pipeline's failed jobs:
580
+
581
+ - On GitLab, the first 10 failed jobs that aren't retried, with the MR, and no other
582
+ query. One that isn't allowed to fail is enough to know the pipeline fails. When all
583
+ 10 are allowed to fail and more failed, an unread one may fail the pipeline, but none
584
+ counts as failing it while the pipeline runs. Once the pipeline finishes as failed,
585
+ which jobs allowed to fail never cause, the update reports that a job past those 10
586
+ failed it.
587
+ - On GitHub, the head commit's failed checks, which count as one pipeline.
588
+
589
+ Each job attempt counts once. A lookup that finds new failed jobs makes one update for
590
+ them, which names and links them, lists the jobs that failed earlier in that pipeline,
591
+ and says whether the pipeline still runs. That update replaces the pipeline's earlier one
592
+ in the inbox, and reaches the session again. A retry that fails again is a new attempt,
593
+ so it's a new failure. Forgekeeper remembers the newest 500 attempts it saw in a pipeline,
594
+ so one that newer failures push past the jobs it reads isn't new when it comes back.
595
+ Another pipeline that fails, such as after a push, is new too. At
596
+ most 10 jobs are named, and more are counted. Records stored before Forgekeeper told job
597
+ attempts apart only record the jobs, without an update. The update clears once the
598
+ pipeline no longer has failed jobs, another pipeline replaces it, or the PR/MR closes.
599
+ Forgekeeper only reports failed jobs: it doesn't retry or fix them.
600
+
601
+ Each change waits in the target's record until it reaches the inbox, in the same storage
602
+ write as the record. A merge stops polling, so waiting changes are retried every minute
603
+ and after a reload, without another lookup. Updates go to `/forge-feedback`, and
604
+ `delivery.lifecycle` sets what else they do: `"inbox"`, the default, `"passive"`, or
605
+ `"summary"`, as for [review feedback](#review-notifications). Each change keeps the mode
606
+ from when it was detected, so turning on summaries never starts turns for changes found
607
+ earlier. A summary turn has no tools, and gets only Forgekeeper's facts about the change,
608
+ never comments.
609
+
610
+ A conflict update clears once the conflict ends or the PR/MR closes. A merge update stays
611
+ until you acknowledge it, because the merge is the news. Updates for targets the session
612
+ drops clear right away. Setting `watch.lifecycle` to `false` stops all three kinds, and
613
+ retarget proposals too, since nothing notices merges then.
614
+
615
+ ### Stacked PRs/MRs
616
+
617
+ A target **builds on** another of the session's targets when it targets that one's source
618
+ branch in the same project, on the same host. Forgekeeper links them only when exactly one
619
+ target matches, and keeps the link once the parent merges, because forges may retarget the
620
+ dependent PR/MR or delete the merged branch at that point. Sharing a session doesn't make
621
+ two PRs/MRs a stack.
622
+
623
+ When a parent merges, its update lists each dependent, and `lifecycleExecution.parentMerged`
624
+ sets what Forgekeeper works out about them:
625
+
626
+ | Mode | What happens |
627
+ | ----------- | ------------------------------------------------------------------------------------- |
628
+ | `"notify"` | The default. The update names the dependents and the branch they target, and no more. |
629
+ | `"prepare"` | Forgekeeper works out the listed actions and proposes them in the update. |
630
+
631
+ `actions` lists what `prepare` works out, so it needs at least one. `parentMerged`
632
+ supports `"retarget"`:
633
+
634
+ ```json
635
+ {
636
+ "lifecycleExecution": { "parentMerged": { "mode": "prepare", "actions": ["retarget"] } }
637
+ }
638
+ ```
639
+
640
+ Forgekeeper proposes retargeting a dependent to the branch the parent merged into, and only
641
+ when it still targets the parent's source branch in the same project. It reads the
642
+ dependent from the forge first, so one the forge already retargeted gets no proposal, and
643
+ neither does one that targets something else, closed, or merged. A parent from a fork
644
+ gets no proposal, since its branch lives in another project. Only dependents the session
645
+ still targets count.
646
+
647
+ Forgekeeper never retargets anything itself, and never rebases. GitLab retargets up to
648
+ four dependent MRs when the merge deletes the source branch, so a proposal usually means
649
+ the branch was kept, which may be deliberate. Retarget the MR yourself, and rebase it if
650
+ the parent was squashed, since its commits stay on the dependent branch. Each event keeps
651
+ the policy it happened under, and the stricter of that and the current policy applies, so
652
+ enabling `prepare` never reads dependents again for earlier merges.
653
+
654
+ Conflicts and failed jobs only notify, so `lifecycleExecution` has no policy for them. An
655
+ invalid or unsupported policy, such as `"prepare"` without actions, falls back to
656
+ `"notify"`, and Forgekeeper shows an error toast saying which options it ignored.
657
+
286
658
  ## Options
287
659
 
660
+ ### Monitoring security boundary
661
+
662
+ Monitoring uses the existing authenticated `gh` and `glab` clients for fixed read-only
663
+ GraphQL queries and GitLab MR REST lookups. GitLab subscriptions read the host's token with
664
+ `glab config get token --host` and send it in the WebSocket authorization header over TLS.
665
+ Tokens are not stored in plugin storage or included in URLs. Subprocesses use argument arrays
666
+ rather than shell interpolation, share
667
+ the existing concurrency limit of four, and time out after 30 seconds. Their credentials
668
+ and environment are those of the OpenCode service; monitoring does not provide OS-level
669
+ isolation or reduce those credentials' scopes.
670
+
671
+ The `inbox` mode invokes a model only when you ask for a summary with Ctrl+R in
672
+ `/forge-feedback`. The `passive` mode starts no turn, but when
673
+ something else wakes the session, its message can run as one step that sends the model
674
+ only the notice's title, time, and links, without tools. The `summary` mode sends review
675
+ text to the session's model in a request whose only possible tool reads GitLab notes that
676
+ the comments link to, as described in [Linked notes](#linked-notes). It reads with your
677
+ `glab` credentials, so it can read private notes in other projects on the same host that
678
+ those links name. No mode executes commands from
679
+ review content, edits repository files, commits, pushes, posts replies, or resolves
680
+ discussions.
681
+
682
+ Forgekeeper changes nothing on a forge. For stacked PRs/MRs, it only proposes retargets.
683
+ See [Stacked PRs/MRs](#stacked-prsmrs).
684
+ Comment links are opened only after user selection and must use HTTPS on the target's
685
+ origin without embedded credentials. Review bodies remain external data, not trusted
686
+ instructions. Analysis and remediation are outside this feature's scope.
687
+
688
+ Pending updates are stored locally through OpenCode's plugin storage. Conversation history
689
+ is inspected locally only to find the latest user-message timestamp, with at most four
690
+ session checks in flight. The 72-hour window limits monitoring eligibility, not access
691
+ rights. OpenCode's provider, integration, and organization policies still apply. This
692
+ implementation has not received AppSec approval.
693
+
288
694
  Set options with the object form of the `plugins` entry in `opencode.json`:
289
695
 
290
696
  ```json
291
697
  {
292
- "plugins": [{ "package": "opencode-forgekeeper", "options": { "notifyHumanReviews": false } }]
698
+ "plugins": [
699
+ { "package": "opencode-forgekeeper", "options": { "watch": { "humanReviews": false } } }
700
+ ]
293
701
  }
294
702
  ```
295
703
 
296
- | Option | Default | Description |
297
- | ------------------------ | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
298
- | `reviewStatus` | `true` | Show the PR/MR status and notify the agent about reviews. Set to `false` to keep only session targets and titles. |
299
- | `hosts` | `["gitlab.com"]` | GitLab hosts whose remotes are recognized, besides hosts named `gitlab.*`. |
300
- | `githubHosts` | `["github.com"]` | GitHub hosts, including GitHub Enterprise hosts. |
301
- | `pollSeconds` | `120` | Normal polling interval. Running automated reviews poll at 30 seconds or this value, whichever is lower. |
302
- | `notifyAutomatedReviews` | `true` | Tell the agent when an automated review finishes with feedback. The former name, `notifyDuoReview`, still works. |
303
- | `notifyHumanReviews` | `true` | Tell the agent about new human review feedback. |
304
- | `resumeSession` | `true` | Resume the session when telling the agent about a review. Set to `false` to queue the message for the session's next turn instead. |
704
+ | Option | Default | Description |
705
+ | ------------------------ | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
706
+ | `status` | `true` | Show the PR/MR status and monitor the session's PRs/MRs. Set to `false` to keep only session targets and titles. |
707
+ | `hosts` | `["gitlab.com"]` | GitLab hosts whose remotes are recognized, besides hosts named `gitlab.*`. |
708
+ | `githubHosts` | `["github.com"]` | GitHub hosts, including GitHub Enterprise hosts. |
709
+ | `pollSeconds` | `120` | Normal polling interval. Running automated reviews poll at 30 seconds or this value, whichever is lower. |
710
+ | `watch.humanReviews` | `true` | Watch for new review feedback from people. |
711
+ | `watch.automatedReviews` | `true` | Watch for automated reviews that finish with feedback, such as GitLab Duo's and GitHub Copilot's. |
712
+ | `watch.reviewBots` | `true` | Watch for comments from the accounts `reviewBots` lists. |
713
+ | `watch.lifecycle` | `true` | Watch for targets that start to conflict, get failed jobs, or merge. See [Merge request updates](#merge-request-updates). |
714
+ | `reviewBots` | GitLab.com security reviewers | Bot accounts whose comments count as review feedback, by host, with exact usernames. See [Review bots](#review-notifications). |
715
+ | `delivery.reviews` | `"inbox"` | What a review update does besides entering the inbox: `"inbox"`, `"passive"`, or `"summary"`. See [Summary turns](#summary-turns). |
716
+ | `delivery.lifecycle` | `"inbox"` | What a merge request update does besides entering the inbox: `"inbox"`, `"passive"`, or `"summary"`. |
717
+ | `lifecycleExecution` | `"notify"` | What Forgekeeper works out when a parent PR/MR merges, in `parentMerged`. See [Stacked PRs/MRs](#stacked-prsmrs). |
718
+
719
+ `watch` and `delivery` list only what you change, and sources added in later releases start
720
+ on. For example, this summarizes review feedback and stops watching merge request changes:
721
+
722
+ ```json
723
+ { "watch": { "lifecycle": false }, "delivery": { "reviews": "summary" } }
724
+ ```
725
+
726
+ An option Forgekeeper doesn't know, or a value it can't use, shows an error toast at startup
727
+ that names the option and what's used instead, even with `status` off.
305
728
 
306
729
  The server entry point reads every option, so changing one takes effect when the server
307
730
  plugin reloads. The CLI has no options of its own. Each checkout's server reads the
@@ -313,7 +736,7 @@ To keep the behavior of `opencode-forge-session-title` alone, turn off review st
313
736
 
314
737
  ```json
315
738
  {
316
- "plugins": [{ "package": "opencode-forgekeeper", "options": { "reviewStatus": false } }]
739
+ "plugins": [{ "package": "opencode-forgekeeper", "options": { "status": false } }]
317
740
  }
318
741
  ```
319
742
 
@@ -321,6 +744,10 @@ The server still registers `set_session_target`, adds the agent guidance, and ma
321
744
  title prefix. It doesn't poll or send review notifications, and the CLI shows no footer.
322
745
  `/forge-status` and `/forge-open` explain that the status is off.
323
746
 
747
+ A summary or notice that Forgekeeper queued before you turned review status off can still
748
+ start a step. That step still runs without tools, history, or permissions. It doesn't read
749
+ the review text: it only says the text is unavailable.
750
+
324
751
  ## RPC
325
752
 
326
753
  The server registers an [RPC](https://opencode.ai/v2/docs/build/plugins/rpc) with the ID
@@ -335,6 +762,13 @@ The server registers an [RPC](https://opencode.ai/v2/docs/build/plugins/rpc) wit
335
762
  session. `visible` is the key on screen.
336
763
  - `release({ clientID })` drops the caller's leases.
337
764
  - `refresh({ key })` looks the key up now and returns `{ enabled, snapshot }`.
765
+ - `feedback({})` returns `{ items, paused }` for this checkout's pending feedback.
766
+ - `acknowledgeFeedback({ id })` dismisses a local update and returns the updated inbox.
767
+ - `summarizeFeedback({ id })` starts a summary turn for an update in its session.
768
+ - `monitoringStatus({ sessionID })` reports, without changing anything, whether the
769
+ session is monitored, and each target's fetch health and subscription state.
770
+ - `pauseFeedback({ paused })` persists the checkout's review-monitoring pause state.
771
+ - `feedbackChanged` fires with `{ directory }` when the inbox or pause state changes.
338
772
  - `targetChanged` fires after `set_session_target` runs, with the same fields as `target`
339
773
  and the `sessionID`. It has only the `sessionID` in branch mode.
340
774
  - `status` fires with `{ directory, key, snapshot }` when a key's status changes.
@@ -368,9 +802,12 @@ OpenCode scopes plugin storage by plugin ID, so nothing stored under the former
368
802
  aren't announced.
369
803
  - Callers of the RPC need the new ID, `opencode-forgekeeper`.
370
804
 
371
- With default options, the new footer and review notifications start right away, and
372
- notifications resume the session. Set `reviewStatus` to `false` to keep only titles, or
373
- `resumeSession` to `false` to queue notifications for the next turn.
805
+ With default options, the footer and review monitoring start right away. All notifications
806
+ stay in the feedback inbox, and no session turn starts unless you set `delivery.reviews`
807
+ or `delivery.lifecycle` to `"summary"`. Set `status` to `false` to keep only titles.
808
+
809
+ If you set options for `opencode-forgekeeper` 2.x, also see
810
+ [Upgrade from forgekeeper 2.x](#upgrade-from-forgekeeper-2x).
374
811
 
375
812
  ## Code layout
376
813
 
@@ -390,11 +827,13 @@ notifications resume the session. Set `reviewStatus` to `false` to keep only tit
390
827
  `src/tui.tsx` connects it to the RPC and renders the footer, dialog, commands, and notices.
391
828
  `src/notice.ts` decides which notices a CLI shows and which tabs it reopens.
392
829
  - `src/forge.ts` defines the forge-neutral types, the `Forge` adapter interface, and
393
- `ForgeTraits` with its optional `automatedReview` and `feedback` capabilities.
394
- - `src/gitlab-feedback.ts` and `src/github-feedback.ts` read each forge's comments for its
395
- `feedback` capability.
396
- - `src/gitlab.ts` and `src/github.ts` provide each forge's adapter and traits. Adapters own
397
- API calls, pagination, error classification, and status normalization. Traits cover
830
+ `ForgeTraits` with its optional `automatedReview`, `feedback`, and `lifecycle`
831
+ capabilities.
832
+ - `src/adapters/gitlab/feedback.ts` and `src/adapters/github/feedback.ts` read each forge's
833
+ comments for its `feedback` capability.
834
+ - `src/adapters/gitlab/adapter.ts` and `src/adapters/github/adapter.ts` provide each forge's
835
+ adapter and traits. Adapters own API calls, pagination, error classification, and status
836
+ normalization. Traits cover
398
837
  everything else: hosts, URL parsing, reference syntax, vocabulary, and forge-specific
399
838
  footer and dialog content.
400
839
  - `src/forges.ts` registers each forge's traits, classifies hosts, opens adapters, and
@@ -405,6 +844,18 @@ notifications resume the session. Set `reviewStatus` to `false` to keep only tit
405
844
  renders the footer and dialog.
406
845
  - `src/automated-review-watch.ts` and `src/human-review-watch.ts` decide when to notify the
407
846
  agent.
847
+ - `src/review-monitoring.ts` configures the review watchers, stores their state, and records
848
+ review updates in the inbox.
849
+ - `src/lifecycle-watch.ts` finds conflicts, failed jobs, merges, and stack links between lookups, and
850
+ keeps each event until it reaches the inbox. `src/lifecycle-execution.ts` proposes retargets
851
+ of stacked PRs/MRs.
852
+ - `src/lifecycle-notifications.ts` formats lifecycle updates, checks retarget proposals, and
853
+ connects lifecycle events to the inbox. `src/feedback-cleanup.ts` identifies resolved,
854
+ closed, and settled updates for removal.
855
+ - `src/adapters/gitlab/linked-notes.ts` finds and reads the notes review comments link to.
856
+ - `src/summary-delivery.ts` stores and delivers summary turns, restricts their model requests,
857
+ and handles linked-note access. `src/review-summary.ts` formats their content.
858
+ - `src/keyed-queue.ts` serializes updates by session or record while allowing other keys to progress.
408
859
 
409
860
  Shared code never compares a forge against a specific name. It asks the forge's traits, or
410
861
  checks for a capability, instead. To add a forge, add its kind to `ForgeKind` and