From 089beef4391e321536eb70af1dcf639e295519bf Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Fri, 1 May 2026 16:32:27 +0000 Subject: [PATCH 1/3] feat(plugin): extract github-webhooks as a publishable package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the github-webhooks plugin from `plugins/github-webhooks{,.ts}` to `packages/opencode-webhooks/` so it can be published to npm and consumed by other OpenCode users via the standard `plugin: ["opencode-webhooks"]` config pattern (matching how lore is consumed today). Internal consumption (this image) is unchanged in behavior: - `opencode-config-package.json` declares the package as a `file:` dep, - Dockerfile copies `packages/` and `bun install` resolves it into `~/.config/opencode/node_modules/opencode-webhooks/`, - `opencode-user-config.json` references the resolved path. Decoupling: only the default DB path changed. Was hardcoded to `~/dev/.opencode/github-webhooks.sqlite` (assumes the image's symlinked volume); now defaults to `${XDG_DATA_HOME:-~/.local/share}/opencode-webhooks/deliveries.sqlite`. The image's existing `webhooks.json` is updated with an explicit `db_path` override to preserve the existing dedup history. Other behavior is bit-identical: - HMAC verification, identity gating, payload filters, prompt templating, bot identity resolution via `gh api user` — all unchanged. - The plugin's log prefix changed `[github-webhooks]` → `[opencode-webhooks]` to match the package name. - Health check response field changed from `{plugin: "github-webhooks"}` to `{plugin: "opencode-webhooks"}` (public API surface, callout in PR). Out of scope and tracked as follow-ups in the plan doc: - npm publishing workflow (will simplify the image's wiring once shipped), - decoupling the `gh` CLI dependency, - session reuse + event buffering. See `.opencode/plans/extract-webhooks-plugin-package.md` for the full design rationale, including a fact-check against OpenCode's plugin docs. --- .env.example | 10 +- .../plans/extract-webhooks-plugin-package.md | 211 ++++++++++++++++++ Dockerfile | 18 +- README.md | 18 +- opencode-config-bun.lock | 13 ++ opencode-config-package.json | 5 +- opencode-user-config.json | 3 +- packages/opencode-webhooks/.gitignore | 3 + packages/opencode-webhooks/LICENSE | 21 ++ packages/opencode-webhooks/README.md | 134 +++++++++++ packages/opencode-webhooks/package.json | 53 +++++ .../opencode-webhooks/src}/bot-identity.ts | 4 +- .../opencode-webhooks/src}/config.ts | 2 +- .../opencode-webhooks/src}/dispatch.ts | 8 +- .../opencode-webhooks/src}/handler.ts | 2 +- .../opencode-webhooks/src}/hmac.ts | 0 .../opencode-webhooks/src/index.ts | 43 ++-- .../opencode-webhooks/src}/matchers.ts | 0 .../opencode-webhooks/src}/semaphore.ts | 0 .../opencode-webhooks/src}/storage.ts | 0 .../opencode-webhooks/src}/template.ts | 0 .../opencode-webhooks/src}/types.ts | 2 +- packages/opencode-webhooks/tsconfig.json | 15 ++ webhooks.json | 1 + 24 files changed, 511 insertions(+), 55 deletions(-) create mode 100644 .opencode/plans/extract-webhooks-plugin-package.md create mode 100644 packages/opencode-webhooks/.gitignore create mode 100644 packages/opencode-webhooks/LICENSE create mode 100644 packages/opencode-webhooks/README.md create mode 100644 packages/opencode-webhooks/package.json rename {plugins/github-webhooks => packages/opencode-webhooks/src}/bot-identity.ts (82%) rename {plugins/github-webhooks => packages/opencode-webhooks/src}/config.ts (95%) rename {plugins/github-webhooks => packages/opencode-webhooks/src}/dispatch.ts (83%) rename {plugins/github-webhooks => packages/opencode-webhooks/src}/handler.ts (98%) rename {plugins/github-webhooks => packages/opencode-webhooks/src}/hmac.ts (100%) rename plugins/github-webhooks.ts => packages/opencode-webhooks/src/index.ts (58%) rename {plugins/github-webhooks => packages/opencode-webhooks/src}/matchers.ts (100%) rename {plugins/github-webhooks => packages/opencode-webhooks/src}/semaphore.ts (100%) rename {plugins/github-webhooks => packages/opencode-webhooks/src}/storage.ts (100%) rename {plugins/github-webhooks => packages/opencode-webhooks/src}/template.ts (100%) rename {plugins/github-webhooks => packages/opencode-webhooks/src}/types.ts (96%) create mode 100644 packages/opencode-webhooks/tsconfig.json diff --git a/.env.example b/.env.example index a691eb6..64be8c1 100644 --- a/.env.example +++ b/.env.example @@ -45,10 +45,12 @@ SENTRY_AUTH_TOKEN= # +@users.noreply.github.com). GH_TOKEN= -# === Optional: github-webhooks plugin === -# This image ships a plugin (~/.config/opencode/plugins/github-webhooks.ts) -# that turns inbound GitHub webhooks into OpenCode agent sessions. A -# baseline config file is already baked in at +# === Optional: opencode-webhooks plugin === +# This image ships the opencode-webhooks plugin (resolved into +# ~/.config/opencode/node_modules/opencode-webhooks at build time, with +# source under packages/opencode-webhooks/) which turns inbound GitHub +# webhooks into OpenCode agent sessions. A baseline config file is +# already baked in at # ~/.config/opencode/webhooks.json — it wires 7 triggers covering the # full PR lifecycle (issue assigned → resolve → review → fix CI → # respond to comments). Setting GITHUB_WEBHOOK_SECRET below + having diff --git a/.opencode/plans/extract-webhooks-plugin-package.md b/.opencode/plans/extract-webhooks-plugin-package.md new file mode 100644 index 0000000..4024b0f --- /dev/null +++ b/.opencode/plans/extract-webhooks-plugin-package.md @@ -0,0 +1,211 @@ +# Plan: extract github-webhooks plugin as a reusable package + +## Goal + +Today the plugin lives at `plugins/github-webhooks.ts` + `plugins/github-webhooks/*.ts` inside this repo. It's tightly scoped to the image's filesystem layout and isn't easily consumable by other OpenCode users. + +Restructure as a publishable npm package under `packages/opencode-webhooks/` (monorepo layout), so: + +1. Other OpenCode users can `bun add opencode-webhooks` and add it to their `plugin: [...]` array, like they do with `@loreai/opencode`. +2. This image continues to consume it locally (via a workspace/file dep) — no behavior change for the existing deployment. +3. Path defaults that assumed `~/dev/.opencode/...` are configurable with sensible non-image-specific fallbacks. + +## Decisions + +| Question | Choice | +|---|---| +| Distribution | npm package, same monorepo (under `packages/`) | +| Decoupling scope | Paths only — keep `gh` CLI as the bot-identity source | +| Layout | Move everything into `packages/opencode-webhooks/` and delete the old `plugins/github-webhooks*` files | +| License | MIT (matches lore) | +| Versioning | `0.1.0` initial; publishing workflow deferred to follow-up | +| Internal consumption | `file:` dep from `opencode-config-package.json` | +| Default DB path | `~/.local/share/opencode-webhooks/deliveries.sqlite` (XDG-ish), still overridable | +| Bot identity | unchanged: `gh api user`, fail-soft if missing | +| Peer deps | `@opencode-ai/plugin` as a peer (consumer pins the OpenCode version) | + +## File layout after extraction + +``` +packages/opencode-webhooks/ +├── package.json # name, version, deps, peer deps, exports +├── tsconfig.json # strict, ESM, bun types +├── README.md # install + config + trigger schema +├── LICENSE # MIT +├── src/ +│ ├── index.ts # exports default GitHubWebhooksPlugin (was plugins/github-webhooks.ts) +│ ├── bot-identity.ts # unchanged +│ ├── config.ts # readWebhookConfig, normalizeTrigger +│ ├── dispatch.ts # makeDispatcher +│ ├── handler.ts # makeFetchHandler +│ ├── hmac.ts # verifyHmac +│ ├── matchers.ts # findMatching, etc. +│ ├── semaphore.ts # makeSemaphore, makeDrainCounter +│ ├── storage.ts # openDeliveryStore +│ ├── template.ts # render(prompt_template, payload) +│ └── types.ts # Trigger, WebhookConfig, etc. +``` + +No build step — package ships raw `.ts` files (matches `@loreai/opencode`'s pattern). `package.json`'s `main`/`exports`/`types` all point at `./src/index.ts`. Bun and OpenCode handle TS natively. + +Repo root after extraction: +- `plugins/github-webhooks.ts` — **deleted** +- `plugins/github-webhooks/` — **deleted** +- `webhooks.json` — **stays** (image's deployment-specific trigger config) +- `agents/*.md` — **stay** (image's bundled agents) +- `opencode-config-package.json` — adds `"opencode-webhooks": "file:../../packages/opencode-webhooks"` (path resolves at install-time relative to the package being installed; we install from `~/.config/opencode/`, so the reference is to `packages/...` in the image's workdir) +- `opencode-user-config.json` — adds `"file:///home/developer/.config/opencode/node_modules/opencode-webhooks"` to the `plugin` array + +## Key changes from current plugin + +### 1. Default DB path + +Was: `~/dev/.opencode/github-webhooks.sqlite` (assumes `~/dev` symlink exists) +Now: `${XDG_DATA_HOME:-$HOME/.local/share}/opencode-webhooks/deliveries.sqlite` + +In this image, the existing `db_path: "/home/developer/dev/.opencode/github-webhooks.sqlite"` (or equivalent) will be set explicitly in `webhooks.json` to preserve persistence on the existing volume layout. New consumers get the XDG default automatically. + +### 2. Default config-file path + +Currently `~/.config/opencode/webhooks.json` (already XDG-aligned). No change. + +### 3. Plugin name in logs + +Was: `[github-webhooks]` +Now: `[opencode-webhooks]` (matches the package name; clearer for consumers reading logs from multiple plugins) + +### 4. Imports in `src/index.ts` + +Currently uses relative imports like `./github-webhooks/bot-identity`. Becomes `./bot-identity` (one level shallower since the package is its own root). + +### 5. Type exports + +Re-export `Trigger`, `WebhookConfig`, `NormalizedTrigger` from `index.ts` so consumers can author config-generators in TS with type safety. + +## What does NOT change + +- `webhooks.json` schema — stays the same. Existing consumers' configs work unchanged. +- HMAC verification, identity gating, payload filters, prompt templating — bit-identical behavior. +- Bot identity resolution via `gh api user` — unchanged. Documented as a soft dependency in the README ("install gh and set GH_TOKEN, or live without identity-gated triggers"). +- Agent dispatch via `client.session.create` + `client.session.prompt` — unchanged. +- All four migrated tests in the existing plugin (none currently — there are no tests; we're not adding any in this PR but the structure makes them addable later). + +## Internal-consumption flow (this image, after the change) + +1. `Dockerfile` `COPY packages/ /home/developer/.config/opencode/packages/` (new line) before the `bun install` step. +2. `opencode-config-package.json` declares `"opencode-webhooks": "file:./packages/opencode-webhooks"`. +3. `bun install` resolves the local file dep into `~/.config/opencode/node_modules/opencode-webhooks/`. +4. `opencode-user-config.json`'s `plugin` array references `file://.../node_modules/opencode-webhooks` (mirrors the existing `@loreai/opencode` entry). +5. Plugin loads at OpenCode startup. Reads `~/.config/opencode/webhooks.json` (unchanged path). Behavior identical. + +## External-consumption flow (other users, after publishing) + +```jsonc +// in their ~/.config/opencode/package.json +{ + "dependencies": { + "@opencode-ai/plugin": "^1.14.30", + "opencode-webhooks": "^0.1.0" + } +} +``` + +```jsonc +// in their ~/.config/opencode/opencode.json +{ + "plugin": [ + "opencode-webhooks" + ] +} +``` + +Plus a `webhooks.json` they author from the README's example config. Plus `GITHUB_WEBHOOK_SECRET` and (optionally) `GH_TOKEN` in env. + +## README outline (new file in the package) + +1. **What it does** — 2-paragraph elevator pitch. Receives GitHub webhooks, dispatches to OpenCode agents. +2. **Install** — `bun add opencode-webhooks`, plus add to `plugin: [...]`. +3. **Config** — `webhooks.json` schema reference. Link to the existing image's `webhooks.json` as a working example. +4. **Env vars** — `GITHUB_WEBHOOK_SECRET` (required), `GH_TOKEN` (recommended), `WEBHOOK_PORT` (default 5050), `WEBHOOKS_CONFIG` (path override). +5. **Triggers** — list of supported `event.action` pairs and what fields are gated on what. +6. **Identity gating** — explain `require_bot_match` + `ignore_authors` + `$BOT_LOGIN` substitution. +7. **Limitations** — soft deps on `gh` CLI; no built-in rate limiting beyond `max_concurrent`; one-process plugin sharing OpenCode's process. +8. **License** — MIT. + +Most of this prose can be lifted from the my-opencode README's existing webhook section (which already documents all the surfaces in a consumer-friendly way). + +## Open questions + +### 1. Should the package include reference agent prompts? + +The agents in `agents/*.md` (pr-reviewer, ci-fixer, etc.) are *examples* of what to put in your OpenCode agents directory. They're not part of the plugin — but external users wanting to replicate this setup will need them. + +**Proposal**: link from the README to `agents/` in this repo as a reference. Don't bundle them in the package — that's scope creep and would force a release to update agent prompts. + +### 2. What about the plugin currently doing `~/dev/.opencode/github-webhooks.sqlite`? + +For the image to keep its existing DB intact across the change, `webhooks.json` will need to gain an explicit `"db_path": "/home/developer/dev/.opencode/github-webhooks.sqlite"` line. Without this, on first boot after the change, the plugin will create a *new* DB at the XDG default and lose dedup history (~1000 entries). + +**Proposal**: include the explicit `db_path` in this PR's `webhooks.json` change so the cutover is seamless. + +### 3. Publishing workflow + +Out of scope for this PR. Track as a follow-up: GitHub Actions on tag push, `npm publish --access public`, version managed via `npm version ` + `git push --follow-tags`. Could use `changesets` if we expect frequent updates. + +### 4. Bun-specific APIs + +The plugin uses `Bun.serve`, `Bun.spawn`, `bun:sqlite`, `Bun.file`. These are runtime-locked to Bun. OpenCode itself runs Bun, so this is fine — but document it in the README so consumers don't try to use this with Node-based forks. + +**Proposal**: README has a "Runtime: Bun ≥ 1.0" line up top. + +### 5. Semantic version commitment + +`0.1.0` signals "API may change without notice." Drop to `1.0.0` once the config schema is committed-to. We're at the right level for now. + +## Out of scope for this PR + +- npm publishing workflow. +- Tests (the original plugin has none; adding now would inflate the diff). +- Schema validation of `webhooks.json` (currently structural; adding zod would change behavior subtly). +- Migration tooling for existing deliveries DB (`db_path` override handles it). +- Decoupling `gh` CLI dependency (your call: keep as-is). +- Replacing `Bun.serve` with a Node-compatible alternative. +- Splitting `bot-identity.ts` into pluggable backends (octokit, gh, env-var, etc.). + +## Follow-up: simplify config after npm publish + +Per OpenCode docs, once `opencode-webhooks` is published to npm, the cleaner config form is: + +```jsonc +{ "plugin": ["opencode-webhooks"] } +``` + +OpenCode auto-installs into `~/.cache/opencode/node_modules/` — no manual `bun install` needed. After publish, we can: + +1. Drop `"opencode-webhooks": "file:./packages/opencode-webhooks"` from `opencode-config-package.json`. +2. Replace the absolute `file://` URL in `opencode-user-config.json` with the bare name `"opencode-webhooks"`. +3. Skip the `COPY packages/` Dockerfile step. + +Out of scope for this PR — needed for testing the in-repo development cycle anyway. Track as a follow-up alongside the npm publish workflow. + +## Rollout + +This is a one-shot move (no feature flag). The path is: + +1. Create `packages/opencode-webhooks/` skeleton with `package.json`, `tsconfig.json`, `LICENSE`, `README.md`. +2. `git mv` the existing `plugins/github-webhooks.ts` → `packages/.../src/index.ts` and `plugins/github-webhooks/*.ts` → `packages/.../src/*.ts`. +3. Update relative imports inside `src/`. +4. Update `opencode-config-package.json` to declare the new dep. +5. Update `opencode-user-config.json`'s `plugin` array. +6. Update `Dockerfile` to copy `packages/` and re-run `bun install`. +7. Update `webhooks.json` to include explicit `db_path` (preserve existing volume). +8. Run a local boot test (build the image, hit the webhook endpoint with a fake delivery, verify the plugin loads from the new path). +9. Update repo README to point at the package's README for the canonical config docs. + +If step 8 reveals issues, fall back to a shim file (Option A from initial design) — but only as a contingency. + +## Risks + +- **OpenCode plugin loader behavior**. Need to verify OpenCode's `plugin: [...]` array correctly resolves a `file://` URL pointing at a `node_modules/...` directory containing both an ESM `dist/index.js` and a `package.json` with `"main"` / `"exports"`. The lore plugin uses this pattern, so it should work — but worth confirming during step 8. +- **bun-install resolution of local deps**. `file:./packages/...` from `~/.config/opencode/` resolves relative to the consumer's package.json. In the image, the relative path needs to be correct *after* the COPY. May need to use an absolute path or restructure. +- **Path persistence on cutover**. Per open question 2 above — without an explicit `db_path` in `webhooks.json`, the existing dedup DB is orphaned. Manageable but worth flagging. diff --git a/Dockerfile b/Dockerfile index 0b49904..a0f8922 100644 --- a/Dockerfile +++ b/Dockerfile @@ -190,11 +190,11 @@ COPY --chown=developer:developer agents \ COPY --chown=developer:developer skills \ /home/developer/.config/opencode/skills -# Bundled plugins (e.g. github-webhooks). OpenCode auto-loads any -# .ts/.js file in this directory at startup. The sibling package.json -# declares the npm deps the plugins import (@opencode-ai/plugin); we -# `bun install` them once at build time so OpenCode doesn't have to do -# it on every container start. +# Bundled plugin packages. The `opencode-webhooks` plugin lives under +# packages/ as a workspace-style file: dep referenced from +# opencode-config-package.json. `bun install` resolves it into +# node_modules/ alongside the npm-published @loreai/opencode plugin; +# both are referenced by absolute file:// URL from opencode.json. # # IMPORTANT: do NOT mount a runtime volume over /home/developer/.config/ # opencode — it would mask the baked-in node_modules and the plugin @@ -202,8 +202,8 @@ COPY --chown=developer:developer skills \ # plugin'`. Persistent state (sessions, auth) lives at ~/dev/.opencode # already via the symlink set up below; that's the only directory you # should attach a volume to. -COPY --chown=developer:developer plugins \ - /home/developer/.config/opencode/plugins +COPY --chown=developer:developer packages \ + /home/developer/.config/opencode/packages COPY --chown=developer:developer opencode-config-package.json \ /home/developer/.config/opencode/package.json COPY --chown=developer:developer opencode-config-bun.lock \ @@ -212,7 +212,7 @@ RUN cd /home/developer/.config/opencode \ && bun install --frozen-lockfile --production \ && rm -rf ~/.bun/install/cache -# Default config for the github-webhooks plugin: one trigger that wires +# Default config for the opencode-webhooks plugin: one trigger that wires # the `issues.assigned` event to the bundled `github-issue-resolver` # agent. The plugin reads this on startup; without it, the listener # stays off (no surprise port). Override per-deploy by setting @@ -237,7 +237,7 @@ EXPOSE 4096 5050 WORKDIR /home/developer/dev # PORT lets PaaS platforms (Railway/Fly/Render) assign a port; falls back -# to 4096 locally. WEBHOOK_PORT (default 5050) is what the github-webhooks +# to 4096 locally. WEBHOOK_PORT (default 5050) is what the opencode-webhooks # plugin binds its listener to. ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"] CMD ["sh", "-c", "exec opencode web --hostname 0.0.0.0 --port ${PORT:-4096}"] diff --git a/README.md b/README.md index 8d44299..b1384ce 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Self-hosted [OpenCode](https://opencode.ai) web UI in a Docker image, ready to d - **OpenCode** built from source from the [`BYK/opencode`](https://github.com/BYK/opencode/tree/byk/cumulative) fork (`byk/cumulative` branch) — carries question-dock UX, plan-mode, and db perf fixes that aren't yet in upstream. Built fresh into the image; auto-update is effectively disabled because the fork has no release feed. - [Sentry CLI](https://cli.sentry.dev), GitHub CLI, **nvm + Node 22 LTS** (`pnpm` / `yarn` via corepack), **Bun**, plus `git`, `ripgrep`, `fd`, `fzf`, `jq`, `yq`, and `build-essential`. - No MCP servers preconfigured — add your own via a project-local `opencode.json` or by editing [`opencode-user-config.json`](./opencode-user-config.json) before building. -- **Bundled OpenCode plugin: [`github-webhooks`](./plugins/github-webhooks.ts)** — turns inbound GitHub webhook deliveries into OpenCode agent sessions running in the same `opencode` process. Ships with [`webhooks.json`](./webhooks.json) baked in (8 triggers covering the full PR lifecycle). Activates on container start once you set `GITHUB_WEBHOOK_SECRET`. See [GitHub webhooks → agent sessions](#github-webhooks--agent-sessions). +- **Bundled OpenCode plugin: [`opencode-webhooks`](./packages/opencode-webhooks)** — turns inbound GitHub webhook deliveries into OpenCode agent sessions running in the same `opencode` process. Ships with [`webhooks.json`](./webhooks.json) baked in (8 triggers covering the full PR lifecycle). Activates on container start once you set `GITHUB_WEBHOOK_SECRET`. The plugin lives as a standalone, publishable npm package under [`packages/opencode-webhooks/`](./packages/opencode-webhooks) — see its [README](./packages/opencode-webhooks/README.md) for the full config schema and how to use it in your own OpenCode setup. See also [GitHub webhooks → agent sessions](#github-webhooks--agent-sessions) below for this image's specific wiring. - **Bundled agents** (permissions pre-broadened so they don't stall on approval prompts): - [`github-issue-resolver`](./agents/github-issue-resolver.md) — issue assigned → branch → plan → implement → draft PR. - [`pr-reviewer`](./agents/pr-reviewer.md) — PR opened / ready-for-review / review-requested / assigned → runs the `review` skill to find issues. On the bot's own PRs it spawns the `pr-fix-applier` subagent to push fixes directly. On others' PRs it posts a structured GitHub review. @@ -52,18 +52,18 @@ See [`.env.example`](./.env.example) for the full template. |---|---| | One of `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `GROQ_API_KEY`, `OPENROUTER_API_KEY` | **Required.** LLM provider key. | | `SENTRY_AUTH_TOKEN`, `SENTRY_ORG`, `SENTRY_PROJECT`, `SENTRY_URL` | For the bundled `sentry` CLI. | -| `GH_TOKEN` | For the bundled `gh` CLI **and** the `github-webhooks` plugin's identity-gated triggers. PAT with the scopes you need (typical: `repo`, `read:org`, `workflow`). The plugin runs `gh api user --jq .login` at boot to resolve the bot's GitHub identity; without `GH_TOKEN`, identity-gated triggers fail closed. | -| `GITHUB_WEBHOOK_SECRET` | HMAC secret for the `github-webhooks` plugin. Required to receive webhooks. | +| `GH_TOKEN` | For the bundled `gh` CLI **and** the `opencode-webhooks` plugin's identity-gated triggers. PAT with the scopes you need (typical: `repo`, `read:org`, `workflow`). The plugin runs `gh api user --jq .login` at boot to resolve the bot's GitHub identity; without `GH_TOKEN`, identity-gated triggers fail closed. | +| `GITHUB_WEBHOOK_SECRET` | HMAC secret for the `opencode-webhooks` plugin. Required to receive webhooks. | | `WEBHOOK_PORT`, `WEBHOOKS_CONFIG` | Optional plugin tuning. See [`.env.example`](./.env.example). | | `PORT` | Set automatically by most PaaS providers. Defaults to `4096`. | ## GitHub webhooks → agent sessions -The bundled [`github-webhooks`](./plugins/github-webhooks.ts) plugin runs -**inside** the OpenCode server process — no sidecar, no second process to -supervise, no loopback HTTP. It opens its own listener on port `5050` -(configurable via `WEBHOOK_PORT`) and dispatches verified deliveries -into agent sessions via the in-process SDK client. +The bundled [`opencode-webhooks`](./packages/opencode-webhooks) plugin +runs **inside** the OpenCode server process — no sidecar, no second +process to supervise, no loopback HTTP. It opens its own listener on +port `5050` (configurable via `WEBHOOK_PORT`) and dispatches verified +deliveries into agent sessions via the in-process SDK client. ### Default behavior @@ -238,7 +238,7 @@ afterward — view it in OpenCode's UI like any other session. ### Health check `GET http://:5050/healthz` (the plugin's port, not OpenCode's -4096) returns `{ "ok": true, "plugin": "github-webhooks" }` once the +4096) returns `{ "ok": true, "plugin": "opencode-webhooks" }` once the listener is up. No auth required. ## Local test diff --git a/opencode-config-bun.lock b/opencode-config-bun.lock index 5ec8fa5..302f339 100644 --- a/opencode-config-bun.lock +++ b/opencode-config-bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@loreai/opencode": "latest", "@opencode-ai/plugin": "^1.14.30", + "opencode-webhooks": "file:./packages/opencode-webhooks", }, }, }, @@ -33,16 +34,22 @@ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -135,6 +142,8 @@ "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], + "opencode-webhooks": ["opencode-webhooks@file:packages/opencode-webhooks", { "devDependencies": { "@opencode-ai/plugin": "^1.14.30", "@types/bun": "latest", "typescript": "^5.6.0" }, "peerDependencies": { "@opencode-ai/plugin": ">=1.1.0" } }], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="], @@ -153,6 +162,10 @@ "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], diff --git a/opencode-config-package.json b/opencode-config-package.json index cffa261..45423c7 100644 --- a/opencode-config-package.json +++ b/opencode-config-package.json @@ -2,10 +2,11 @@ "name": "opencode-config", "version": "0.1.0", "private": true, - "description": "Dependencies for plugins shipped under ~/.config/opencode/plugins (loaded by OpenCode at startup)", + "description": "Dependencies for plugins loaded by OpenCode at startup", "type": "module", "dependencies": { "@opencode-ai/plugin": "^1.14.30", - "@loreai/opencode": "latest" + "@loreai/opencode": "latest", + "opencode-webhooks": "file:./packages/opencode-webhooks" } } diff --git a/opencode-user-config.json b/opencode-user-config.json index d840027..0d7aef5 100644 --- a/opencode-user-config.json +++ b/opencode-user-config.json @@ -1,6 +1,7 @@ { "$schema": "https://opencode.ai/config.json", "plugin": [ - "file:///home/developer/.config/opencode/node_modules/@loreai/opencode" + "file:///home/developer/.config/opencode/node_modules/@loreai/opencode", + "file:///home/developer/.config/opencode/node_modules/opencode-webhooks" ] } diff --git a/packages/opencode-webhooks/.gitignore b/packages/opencode-webhooks/.gitignore new file mode 100644 index 0000000..0c059e8 --- /dev/null +++ b/packages/opencode-webhooks/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +bun.lock +*.tgz diff --git a/packages/opencode-webhooks/LICENSE b/packages/opencode-webhooks/LICENSE new file mode 100644 index 0000000..f5bbae9 --- /dev/null +++ b/packages/opencode-webhooks/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Aditya Mathur + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/opencode-webhooks/README.md b/packages/opencode-webhooks/README.md new file mode 100644 index 0000000..85f6c0d --- /dev/null +++ b/packages/opencode-webhooks/README.md @@ -0,0 +1,134 @@ +# opencode-webhooks + +OpenCode plugin: receive GitHub webhooks and dispatch them to OpenCode agent sessions running in the same process. + +When a configured webhook arrives, the plugin verifies the HMAC signature, deduplicates by `X-GitHub-Delivery`, runs identity/payload gating, renders a prompt template against the payload, and starts a new OpenCode agent session via the in-process SDK client. + +> **Runtime: Bun ≥ 1.2.** Uses `Bun.serve`, `Bun.spawn`, and `bun:sqlite`. + +## Install + +Once published to npm, add the package name to your OpenCode config's `plugin` array — OpenCode will install it into `~/.cache/opencode/node_modules/` automatically at startup: + +```jsonc +// ~/.config/opencode/opencode.json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + "opencode-webhooks" + ] +} +``` + +> Until the package is published to npm, install it manually: add `"opencode-webhooks": "file:/path/to/this/repo/packages/opencode-webhooks"` to a `package.json` in your OpenCode config directory (`~/.config/opencode/package.json`), run `bun install` there, and reference the resolved path: +> +> ```jsonc +> { +> "plugin": [ +> "file:///home//.config/opencode/node_modules/opencode-webhooks" +> ] +> } +> ``` + +## Configure + +The plugin reads `webhooks.json` from `~/.config/opencode/webhooks.json` by default. Override with the `WEBHOOKS_CONFIG` env var. + +Minimal config: + +```jsonc +{ + "max_concurrent": 2, + "timeout_ms": 1800000, + "retention": 1000, + "triggers": [ + { + "name": "issue-assigned", + "event": "issues", + "action": "assigned", + "agent": "github-issue-resolver", + "require_bot_match": ["assignee.login"], + "prompt_template": "Issue assigned: {{ payload.issue.html_url }}\n\n{{ payload.issue.body }}" + } + ] +} +``` + +### Top-level fields + +| Field | Default | Description | +|---|---|---| +| `port` | `5050` (or `WEBHOOK_PORT`) | TCP port for the listener. | +| `secret` | `GITHUB_WEBHOOK_SECRET` env | HMAC secret. Without one, every delivery is rejected with 503. | +| `timeout_ms` | `1800000` (30 min) | Per-session abort budget. | +| `max_concurrent` | `2` | Concurrency cap across all triggers. | +| `default_cwd` | OpenCode project root | Fallback session cwd when a trigger doesn't override. | +| `db_path` | `${XDG_DATA_HOME or ~/.local/share}/opencode-webhooks/deliveries.sqlite` | SQLite path for delivery dedup. | +| `retention` | `1000` | Max deliveries kept in dedup DB; oldest pruned. | +| `triggers` | `[]` | Array of trigger objects (see below). | + +### Trigger fields + +| Field | Required | Description | +|---|---|---| +| `name` | yes | Unique per-config; used in logs. | +| `event` | yes | GitHub event header (`issues`, `pull_request`, `*` for any). | +| `action` | no | If set, must match `payload.action` exactly. Omit/`null` = any action. | +| `agent` | yes | OpenCode agent name to invoke. | +| `prompt_template` | yes | Mustache-ish template. `{{ payload.foo.bar }}` looks up paths; missing renders empty. Synthetic `{{ review_state }}` is the lowercased `payload.review.state`. | +| `cwd` | no | Override session cwd. Falls back to `default_cwd`. | +| `enabled` | no | Set `false` to disable a trigger without removing it. | +| `ignore_authors` | no | Skip if `payload.sender.login` matches any entry (case-insensitive). The literal `"$BOT_LOGIN"` is substituted with the resolved bot login. | +| `payload_filter` | no | Object mapping dotted paths → expected values. `"*"` means any non-empty value; other values are scalar equality. AND across keys. | +| `require_bot_match` | no | List of dotted payload paths whose string value must equal the bot's resolved login (case-insensitive). Paths support a `[*]` wildcard (e.g. `requested_reviewers[*].login`). OR across paths. Skips with `bot identity unresolved` if `gh api user` failed at boot (fail-closed). | + +## Bot identity + +The plugin resolves "the bot" via `gh api user --jq .login` at boot. `gh` reads `GH_TOKEN` from the environment. The resolved login is used for: + +- The `require_bot_match` identity gate. +- The `"$BOT_LOGIN"` placeholder substitution in `ignore_authors`. + +If `gh` isn't installed or `GH_TOKEN` isn't set, identity-gated triggers refuse to fire (fail-closed). Triggers without `require_bot_match` are unaffected. + +> **Soft dependency.** `gh` is the GitHub CLI: . Install it on the host running OpenCode. + +## Environment variables + +| Variable | Purpose | +|---|---| +| `GITHUB_WEBHOOK_SECRET` | HMAC secret for `X-Hub-Signature-256` verification. Same value you set in GitHub's webhook config. | +| `GH_TOKEN` | GitHub PAT, read by `gh` CLI for `gh api user`. Required for identity-gated triggers. | +| `WEBHOOK_PORT` | Override listener port (default 5050). | +| `WEBHOOKS_CONFIG` | Path to `webhooks.json` (default `~/.config/opencode/webhooks.json`). | + +## Health check + +``` +GET /healthz → 200 { ok: true, plugin: "opencode-webhooks" } +``` + +## Webhook endpoint + +``` +POST /webhooks/github +``` + +Required headers: + +- `X-GitHub-Event` — event name (e.g. `issues`). +- `X-GitHub-Delivery` — UUID for dedup. +- `X-Hub-Signature-256` — `sha256=` HMAC of the raw body using `GITHUB_WEBHOOK_SECRET`. + +Returns 200 on accept, 401 on bad signature, 409 on duplicate delivery, 404 on path mismatch, 503 if the listener is starting up or the secret is unconfigured. + +## Limitations + +- Single-process plugin; shares the OpenCode server's process. An unhandled rejection inside the dispatcher could crash the host server. The plugin installs a top-level `unhandledRejection` handler, but consumers should still pin OpenCode versions. +- One trigger fires per inbound delivery. If multiple triggers' filters match, only the first (by config order) runs. +- No built-in rate limiting beyond `max_concurrent`. A burst of 200 deliveries in a second will queue at the semaphore but not be dropped. +- `bun:sqlite` is required for delivery dedup. The plugin won't run on Node. + +## License + +MIT — see [LICENSE](./LICENSE). diff --git a/packages/opencode-webhooks/package.json b/packages/opencode-webhooks/package.json new file mode 100644 index 0000000..863dc2a --- /dev/null +++ b/packages/opencode-webhooks/package.json @@ -0,0 +1,53 @@ +{ + "name": "opencode-webhooks", + "version": "0.1.0", + "type": "module", + "license": "MIT", + "description": "OpenCode plugin: receive GitHub webhooks and dispatch them to OpenCode agent sessions", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "bun": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "build": "echo 'opencode-webhooks ships raw TS — no build step needed'" + }, + "peerDependencies": { + "@opencode-ai/plugin": ">=1.1.0" + }, + "devDependencies": { + "@opencode-ai/plugin": "^1.14.30", + "@types/bun": "latest", + "typescript": "^5.6.0" + }, + "files": [ + "src/", + "README.md", + "LICENSE" + ], + "engines": { + "bun": ">=1.2.0" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/MathurAditya724/my-opencode.git", + "directory": "packages/opencode-webhooks" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "opencode", + "plugin", + "github", + "webhook", + "agent", + "automation" + ], + "author": "Aditya Mathur" +} diff --git a/plugins/github-webhooks/bot-identity.ts b/packages/opencode-webhooks/src/bot-identity.ts similarity index 82% rename from plugins/github-webhooks/bot-identity.ts rename to packages/opencode-webhooks/src/bot-identity.ts index a3d0fdd..8b7669c 100644 --- a/plugins/github-webhooks/bot-identity.ts +++ b/packages/opencode-webhooks/src/bot-identity.ts @@ -18,14 +18,14 @@ export async function resolveBotLogin(): Promise { clearTimeout(timer) if (exitCode !== 0) { console.warn( - `[github-webhooks] gh api user exit=${exitCode} stderr=${stderr.trim().slice(0, 200)}`, + `[opencode-webhooks] gh api user exit=${exitCode} stderr=${stderr.trim().slice(0, 200)}`, ) return null } const login = stdout.trim() return login.length > 0 ? login : null } catch (err) { - console.warn("[github-webhooks] resolveBotLogin failed:", err) + console.warn("[opencode-webhooks] resolveBotLogin failed:", err) return null } } diff --git a/plugins/github-webhooks/config.ts b/packages/opencode-webhooks/src/config.ts similarity index 95% rename from plugins/github-webhooks/config.ts rename to packages/opencode-webhooks/src/config.ts index e5962b5..f54aa92 100644 --- a/plugins/github-webhooks/config.ts +++ b/packages/opencode-webhooks/src/config.ts @@ -15,7 +15,7 @@ export async function readWebhookConfig(): Promise { if (!parsed || typeof parsed !== "object") return {} return parsed as WebhookConfig } catch (err) { - console.error(`[github-webhooks] failed to parse config at ${path}:`, err) + console.error(`[opencode-webhooks] failed to parse config at ${path}:`, err) return {} } } diff --git a/plugins/github-webhooks/dispatch.ts b/packages/opencode-webhooks/src/dispatch.ts similarity index 83% rename from plugins/github-webhooks/dispatch.ts rename to packages/opencode-webhooks/src/dispatch.ts index 7e2120a..a332418 100644 --- a/plugins/github-webhooks/dispatch.ts +++ b/packages/opencode-webhooks/src/dispatch.ts @@ -36,12 +36,12 @@ export function makeDispatcher(opts: { const sessionId = session.data?.id if (!sessionId) { console.error( - `[github-webhooks] trigger '${t.name}' (${deliveryId}): session.create returned no id`, + `[opencode-webhooks] trigger '${t.name}' (${deliveryId}): session.create returned no id`, ) return } console.log( - `[github-webhooks] trigger '${t.name}' (${deliveryId}) → session ${sessionId}`, + `[opencode-webhooks] trigger '${t.name}' (${deliveryId}) → session ${sessionId}`, ) await client.session.prompt({ path: { id: sessionId }, @@ -52,11 +52,11 @@ export function makeDispatcher(opts: { signal: abort.signal, }) console.log( - `[github-webhooks] trigger '${t.name}' (${deliveryId}) → session ${sessionId} completed`, + `[opencode-webhooks] trigger '${t.name}' (${deliveryId}) → session ${sessionId} completed`, ) } catch (err) { console.error( - `[github-webhooks] trigger '${t.name}' (${deliveryId}) failed:`, + `[opencode-webhooks] trigger '${t.name}' (${deliveryId}) failed:`, err, ) } finally { diff --git a/plugins/github-webhooks/handler.ts b/packages/opencode-webhooks/src/handler.ts similarity index 98% rename from plugins/github-webhooks/handler.ts rename to packages/opencode-webhooks/src/handler.ts index 305fd39..5a5c849 100644 --- a/plugins/github-webhooks/handler.ts +++ b/packages/opencode-webhooks/src/handler.ts @@ -29,7 +29,7 @@ export function makeFetchHandler(opts: { const url = new URL(req.url) if (req.method === "GET" && url.pathname === "/healthz") { - return Response.json({ ok: true, plugin: "github-webhooks" }) + return Response.json({ ok: true, plugin: "opencode-webhooks" }) } if (req.method !== "POST" || url.pathname !== "/webhooks/github") { return new Response("not found", { status: 404 }) diff --git a/plugins/github-webhooks/hmac.ts b/packages/opencode-webhooks/src/hmac.ts similarity index 100% rename from plugins/github-webhooks/hmac.ts rename to packages/opencode-webhooks/src/hmac.ts diff --git a/plugins/github-webhooks.ts b/packages/opencode-webhooks/src/index.ts similarity index 58% rename from plugins/github-webhooks.ts rename to packages/opencode-webhooks/src/index.ts index eed8a36..46a4efc 100644 --- a/plugins/github-webhooks.ts +++ b/packages/opencode-webhooks/src/index.ts @@ -1,25 +1,23 @@ -// github-webhooks: receives GitHub webhooks, dispatches to OpenCode +// opencode-webhooks: receives GitHub webhooks, dispatches to OpenCode // agents via the in-process SDK client. Listener on WEBHOOK_PORT // (default 5050). Trigger config in webhooks.json. -// -// Implementation modules live in ./github-webhooks/ — this file is the -// thin orchestration layer that opencode loads. import type { Plugin } from "@opencode-ai/plugin" import { homedir } from "node:os" -import { resolveBotLogin } from "./github-webhooks/bot-identity" -import { configPath, normalizeTrigger, readWebhookConfig } from "./github-webhooks/config" -import { makeDispatcher } from "./github-webhooks/dispatch" -import { makeFetchHandler } from "./github-webhooks/handler" -import { makeDrainCounter, makeSemaphore } from "./github-webhooks/semaphore" -import { openDeliveryStore } from "./github-webhooks/storage" +import { resolveBotLogin } from "./bot-identity" +import { configPath, normalizeTrigger, readWebhookConfig } from "./config" +import { makeDispatcher } from "./dispatch" +import { makeFetchHandler } from "./handler" +import { makeDrainCounter, makeSemaphore } from "./semaphore" +import { openDeliveryStore } from "./storage" +export type { Trigger, WebhookConfig, NormalizedTrigger, SkippedDispatch } from "./types" export const GitHubWebhooksPlugin: Plugin = async (ctx) => { // Don't let a dispatch bug take down the host opencode server. const guard = globalThis as { __ghWebhookGuard?: boolean } if (!guard.__ghWebhookGuard) { process.on("unhandledRejection", (err) => { - console.error("[github-webhooks] unhandledRejection:", err) + console.error("[opencode-webhooks] unhandledRejection:", err) }) guard.__ghWebhookGuard = true } @@ -32,15 +30,18 @@ export const GitHubWebhooksPlugin: Plugin = async (ctx) => { const maxConcurrent = Math.max(1, cfg.max_concurrent ?? 2) const defaultCwd = cfg.default_cwd ?? ctx.directory const retention = cfg.retention ?? 1000 - const dbPath = - cfg.db_path ?? `${homedir()}/dev/.opencode/github-webhooks.sqlite` + // Default DB path follows the XDG data spec; consumers running in + // sandboxed/persistent-volume environments should override `db_path` + // in webhooks.json to point at their persistent location. + const xdgDataHome = process.env.XDG_DATA_HOME || `${homedir()}/.local/share` + const dbPath = cfg.db_path ?? `${xdgDataHome}/opencode-webhooks/deliveries.sqlite` const botLogin = await resolveBotLogin() if (botLogin) { - console.log(`[github-webhooks] bot identity: ${botLogin}`) + console.log(`[opencode-webhooks] bot identity: ${botLogin}`) } else { console.warn( - `[github-webhooks] WARNING: could not resolve bot identity via 'gh api user' — triggers with require_bot_match will be skipped. Set GH_TOKEN to enable identity-gated triggers.`, + `[opencode-webhooks] WARNING: could not resolve bot identity via 'gh api user' — triggers with require_bot_match will be skipped. Set GH_TOKEN to enable identity-gated triggers.`, ) } @@ -48,13 +49,13 @@ export const GitHubWebhooksPlugin: Plugin = async (ctx) => { if (triggers.length === 0) { console.log( - `[github-webhooks] no triggers configured (looked at ${configPath()}) — listener disabled`, + `[opencode-webhooks] no triggers configured (looked at ${configPath()}) — listener disabled`, ) return {} } if (!secret) { console.warn( - `[github-webhooks] WARNING: no HMAC secret configured (set "secret" in ${configPath()} or GITHUB_WEBHOOK_SECRET) — webhooks will be rejected with 503 until you set one`, + `[opencode-webhooks] WARNING: no HMAC secret configured (set "secret" in ${configPath()} or GITHUB_WEBHOOK_SECRET) — webhooks will be rejected with 503 until you set one`, ) } @@ -80,7 +81,7 @@ export const GitHubWebhooksPlugin: Plugin = async (ctx) => { const server = Bun.serve({ port, hostname: "0.0.0.0", fetch }) console.log( - `[github-webhooks] listening on http://0.0.0.0:${port} (db: ${dbPath}, triggers: ${triggers.length})`, + `[opencode-webhooks] listening on http://0.0.0.0:${port} (db: ${dbPath}, triggers: ${triggers.length})`, ) // Graceful shutdown: stop accepting new connections, drain in-flight @@ -90,7 +91,7 @@ export const GitHubWebhooksPlugin: Plugin = async (ctx) => { if (stopping) return stopping = true console.log( - `[github-webhooks] received ${sig}, closing listener (in-flight dispatches: ${drainCounter.inFlight()})`, + `[opencode-webhooks] received ${sig}, closing listener (in-flight dispatches: ${drainCounter.inFlight()})`, ) server.stop(true) const drainTimeoutMs = 25_000 @@ -101,10 +102,10 @@ export const GitHubWebhooksPlugin: Plugin = async (ctx) => { setTimeout(() => reject(new Error("drain timeout")), drainTimeoutMs), ), ]) - console.log(`[github-webhooks] all dispatches drained`) + console.log(`[opencode-webhooks] all dispatches drained`) } catch { console.warn( - `[github-webhooks] drain timeout after ${drainTimeoutMs}ms — ${drainCounter.inFlight()} dispatch(es) still in flight`, + `[opencode-webhooks] drain timeout after ${drainTimeoutMs}ms — ${drainCounter.inFlight()} dispatch(es) still in flight`, ) } } diff --git a/plugins/github-webhooks/matchers.ts b/packages/opencode-webhooks/src/matchers.ts similarity index 100% rename from plugins/github-webhooks/matchers.ts rename to packages/opencode-webhooks/src/matchers.ts diff --git a/plugins/github-webhooks/semaphore.ts b/packages/opencode-webhooks/src/semaphore.ts similarity index 100% rename from plugins/github-webhooks/semaphore.ts rename to packages/opencode-webhooks/src/semaphore.ts diff --git a/plugins/github-webhooks/storage.ts b/packages/opencode-webhooks/src/storage.ts similarity index 100% rename from plugins/github-webhooks/storage.ts rename to packages/opencode-webhooks/src/storage.ts diff --git a/plugins/github-webhooks/template.ts b/packages/opencode-webhooks/src/template.ts similarity index 100% rename from plugins/github-webhooks/template.ts rename to packages/opencode-webhooks/src/template.ts diff --git a/plugins/github-webhooks/types.ts b/packages/opencode-webhooks/src/types.ts similarity index 96% rename from plugins/github-webhooks/types.ts rename to packages/opencode-webhooks/src/types.ts index 639c073..65780d7 100644 --- a/plugins/github-webhooks/types.ts +++ b/packages/opencode-webhooks/src/types.ts @@ -1,4 +1,4 @@ -// Shared types for the github-webhooks plugin. +// Shared types for the opencode-webhooks plugin. export type Trigger = { name: string diff --git a/packages/opencode-webhooks/tsconfig.json b/packages/opencode-webhooks/tsconfig.json new file mode 100644 index 0000000..5613aac --- /dev/null +++ b/packages/opencode-webhooks/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "resolveJsonModule": true, + "isolatedModules": true, + "types": ["bun"] + }, + "include": ["src/**/*"] +} diff --git a/webhooks.json b/webhooks.json index fa2bf95..c5eac32 100644 --- a/webhooks.json +++ b/webhooks.json @@ -2,6 +2,7 @@ "max_concurrent": 2, "timeout_ms": 1800000, "retention": 1000, + "db_path": "/home/developer/dev/.opencode/github-webhooks.sqlite", "triggers": [ { "name": "issue-assigned", From 6dfaed22937653a1201bbc3267b1094edaeb5102 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Fri, 1 May 2026 18:35:41 +0000 Subject: [PATCH 2/3] chore: gitignore .opencode/ workspace state Plans, scratch notes, and OpenCode session data are local-only. The two plan docs that landed in #9 and earlier in this PR are removed from tracking but remain on disk; past commits still carry their content for anyone reading history. --- .gitignore | 5 + .../plans/extract-webhooks-plugin-package.md | 211 ------------ .../plans/session-reuse-and-event-buffer.md | 308 ------------------ 3 files changed, 5 insertions(+), 519 deletions(-) delete mode 100644 .opencode/plans/extract-webhooks-plugin-package.md delete mode 100644 .opencode/plans/session-reuse-and-event-buffer.md diff --git a/.gitignore b/.gitignore index 2cef73e..1a15b0e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ .env .env.* !.env.example + +# Local-only OpenCode workspace state (plans, scratch notes, session +# data). Anything we want to share publicly belongs in repo-root files +# (README, AGENTS.md, etc.), not here. +.opencode/ diff --git a/.opencode/plans/extract-webhooks-plugin-package.md b/.opencode/plans/extract-webhooks-plugin-package.md deleted file mode 100644 index 4024b0f..0000000 --- a/.opencode/plans/extract-webhooks-plugin-package.md +++ /dev/null @@ -1,211 +0,0 @@ -# Plan: extract github-webhooks plugin as a reusable package - -## Goal - -Today the plugin lives at `plugins/github-webhooks.ts` + `plugins/github-webhooks/*.ts` inside this repo. It's tightly scoped to the image's filesystem layout and isn't easily consumable by other OpenCode users. - -Restructure as a publishable npm package under `packages/opencode-webhooks/` (monorepo layout), so: - -1. Other OpenCode users can `bun add opencode-webhooks` and add it to their `plugin: [...]` array, like they do with `@loreai/opencode`. -2. This image continues to consume it locally (via a workspace/file dep) — no behavior change for the existing deployment. -3. Path defaults that assumed `~/dev/.opencode/...` are configurable with sensible non-image-specific fallbacks. - -## Decisions - -| Question | Choice | -|---|---| -| Distribution | npm package, same monorepo (under `packages/`) | -| Decoupling scope | Paths only — keep `gh` CLI as the bot-identity source | -| Layout | Move everything into `packages/opencode-webhooks/` and delete the old `plugins/github-webhooks*` files | -| License | MIT (matches lore) | -| Versioning | `0.1.0` initial; publishing workflow deferred to follow-up | -| Internal consumption | `file:` dep from `opencode-config-package.json` | -| Default DB path | `~/.local/share/opencode-webhooks/deliveries.sqlite` (XDG-ish), still overridable | -| Bot identity | unchanged: `gh api user`, fail-soft if missing | -| Peer deps | `@opencode-ai/plugin` as a peer (consumer pins the OpenCode version) | - -## File layout after extraction - -``` -packages/opencode-webhooks/ -├── package.json # name, version, deps, peer deps, exports -├── tsconfig.json # strict, ESM, bun types -├── README.md # install + config + trigger schema -├── LICENSE # MIT -├── src/ -│ ├── index.ts # exports default GitHubWebhooksPlugin (was plugins/github-webhooks.ts) -│ ├── bot-identity.ts # unchanged -│ ├── config.ts # readWebhookConfig, normalizeTrigger -│ ├── dispatch.ts # makeDispatcher -│ ├── handler.ts # makeFetchHandler -│ ├── hmac.ts # verifyHmac -│ ├── matchers.ts # findMatching, etc. -│ ├── semaphore.ts # makeSemaphore, makeDrainCounter -│ ├── storage.ts # openDeliveryStore -│ ├── template.ts # render(prompt_template, payload) -│ └── types.ts # Trigger, WebhookConfig, etc. -``` - -No build step — package ships raw `.ts` files (matches `@loreai/opencode`'s pattern). `package.json`'s `main`/`exports`/`types` all point at `./src/index.ts`. Bun and OpenCode handle TS natively. - -Repo root after extraction: -- `plugins/github-webhooks.ts` — **deleted** -- `plugins/github-webhooks/` — **deleted** -- `webhooks.json` — **stays** (image's deployment-specific trigger config) -- `agents/*.md` — **stay** (image's bundled agents) -- `opencode-config-package.json` — adds `"opencode-webhooks": "file:../../packages/opencode-webhooks"` (path resolves at install-time relative to the package being installed; we install from `~/.config/opencode/`, so the reference is to `packages/...` in the image's workdir) -- `opencode-user-config.json` — adds `"file:///home/developer/.config/opencode/node_modules/opencode-webhooks"` to the `plugin` array - -## Key changes from current plugin - -### 1. Default DB path - -Was: `~/dev/.opencode/github-webhooks.sqlite` (assumes `~/dev` symlink exists) -Now: `${XDG_DATA_HOME:-$HOME/.local/share}/opencode-webhooks/deliveries.sqlite` - -In this image, the existing `db_path: "/home/developer/dev/.opencode/github-webhooks.sqlite"` (or equivalent) will be set explicitly in `webhooks.json` to preserve persistence on the existing volume layout. New consumers get the XDG default automatically. - -### 2. Default config-file path - -Currently `~/.config/opencode/webhooks.json` (already XDG-aligned). No change. - -### 3. Plugin name in logs - -Was: `[github-webhooks]` -Now: `[opencode-webhooks]` (matches the package name; clearer for consumers reading logs from multiple plugins) - -### 4. Imports in `src/index.ts` - -Currently uses relative imports like `./github-webhooks/bot-identity`. Becomes `./bot-identity` (one level shallower since the package is its own root). - -### 5. Type exports - -Re-export `Trigger`, `WebhookConfig`, `NormalizedTrigger` from `index.ts` so consumers can author config-generators in TS with type safety. - -## What does NOT change - -- `webhooks.json` schema — stays the same. Existing consumers' configs work unchanged. -- HMAC verification, identity gating, payload filters, prompt templating — bit-identical behavior. -- Bot identity resolution via `gh api user` — unchanged. Documented as a soft dependency in the README ("install gh and set GH_TOKEN, or live without identity-gated triggers"). -- Agent dispatch via `client.session.create` + `client.session.prompt` — unchanged. -- All four migrated tests in the existing plugin (none currently — there are no tests; we're not adding any in this PR but the structure makes them addable later). - -## Internal-consumption flow (this image, after the change) - -1. `Dockerfile` `COPY packages/ /home/developer/.config/opencode/packages/` (new line) before the `bun install` step. -2. `opencode-config-package.json` declares `"opencode-webhooks": "file:./packages/opencode-webhooks"`. -3. `bun install` resolves the local file dep into `~/.config/opencode/node_modules/opencode-webhooks/`. -4. `opencode-user-config.json`'s `plugin` array references `file://.../node_modules/opencode-webhooks` (mirrors the existing `@loreai/opencode` entry). -5. Plugin loads at OpenCode startup. Reads `~/.config/opencode/webhooks.json` (unchanged path). Behavior identical. - -## External-consumption flow (other users, after publishing) - -```jsonc -// in their ~/.config/opencode/package.json -{ - "dependencies": { - "@opencode-ai/plugin": "^1.14.30", - "opencode-webhooks": "^0.1.0" - } -} -``` - -```jsonc -// in their ~/.config/opencode/opencode.json -{ - "plugin": [ - "opencode-webhooks" - ] -} -``` - -Plus a `webhooks.json` they author from the README's example config. Plus `GITHUB_WEBHOOK_SECRET` and (optionally) `GH_TOKEN` in env. - -## README outline (new file in the package) - -1. **What it does** — 2-paragraph elevator pitch. Receives GitHub webhooks, dispatches to OpenCode agents. -2. **Install** — `bun add opencode-webhooks`, plus add to `plugin: [...]`. -3. **Config** — `webhooks.json` schema reference. Link to the existing image's `webhooks.json` as a working example. -4. **Env vars** — `GITHUB_WEBHOOK_SECRET` (required), `GH_TOKEN` (recommended), `WEBHOOK_PORT` (default 5050), `WEBHOOKS_CONFIG` (path override). -5. **Triggers** — list of supported `event.action` pairs and what fields are gated on what. -6. **Identity gating** — explain `require_bot_match` + `ignore_authors` + `$BOT_LOGIN` substitution. -7. **Limitations** — soft deps on `gh` CLI; no built-in rate limiting beyond `max_concurrent`; one-process plugin sharing OpenCode's process. -8. **License** — MIT. - -Most of this prose can be lifted from the my-opencode README's existing webhook section (which already documents all the surfaces in a consumer-friendly way). - -## Open questions - -### 1. Should the package include reference agent prompts? - -The agents in `agents/*.md` (pr-reviewer, ci-fixer, etc.) are *examples* of what to put in your OpenCode agents directory. They're not part of the plugin — but external users wanting to replicate this setup will need them. - -**Proposal**: link from the README to `agents/` in this repo as a reference. Don't bundle them in the package — that's scope creep and would force a release to update agent prompts. - -### 2. What about the plugin currently doing `~/dev/.opencode/github-webhooks.sqlite`? - -For the image to keep its existing DB intact across the change, `webhooks.json` will need to gain an explicit `"db_path": "/home/developer/dev/.opencode/github-webhooks.sqlite"` line. Without this, on first boot after the change, the plugin will create a *new* DB at the XDG default and lose dedup history (~1000 entries). - -**Proposal**: include the explicit `db_path` in this PR's `webhooks.json` change so the cutover is seamless. - -### 3. Publishing workflow - -Out of scope for this PR. Track as a follow-up: GitHub Actions on tag push, `npm publish --access public`, version managed via `npm version ` + `git push --follow-tags`. Could use `changesets` if we expect frequent updates. - -### 4. Bun-specific APIs - -The plugin uses `Bun.serve`, `Bun.spawn`, `bun:sqlite`, `Bun.file`. These are runtime-locked to Bun. OpenCode itself runs Bun, so this is fine — but document it in the README so consumers don't try to use this with Node-based forks. - -**Proposal**: README has a "Runtime: Bun ≥ 1.0" line up top. - -### 5. Semantic version commitment - -`0.1.0` signals "API may change without notice." Drop to `1.0.0` once the config schema is committed-to. We're at the right level for now. - -## Out of scope for this PR - -- npm publishing workflow. -- Tests (the original plugin has none; adding now would inflate the diff). -- Schema validation of `webhooks.json` (currently structural; adding zod would change behavior subtly). -- Migration tooling for existing deliveries DB (`db_path` override handles it). -- Decoupling `gh` CLI dependency (your call: keep as-is). -- Replacing `Bun.serve` with a Node-compatible alternative. -- Splitting `bot-identity.ts` into pluggable backends (octokit, gh, env-var, etc.). - -## Follow-up: simplify config after npm publish - -Per OpenCode docs, once `opencode-webhooks` is published to npm, the cleaner config form is: - -```jsonc -{ "plugin": ["opencode-webhooks"] } -``` - -OpenCode auto-installs into `~/.cache/opencode/node_modules/` — no manual `bun install` needed. After publish, we can: - -1. Drop `"opencode-webhooks": "file:./packages/opencode-webhooks"` from `opencode-config-package.json`. -2. Replace the absolute `file://` URL in `opencode-user-config.json` with the bare name `"opencode-webhooks"`. -3. Skip the `COPY packages/` Dockerfile step. - -Out of scope for this PR — needed for testing the in-repo development cycle anyway. Track as a follow-up alongside the npm publish workflow. - -## Rollout - -This is a one-shot move (no feature flag). The path is: - -1. Create `packages/opencode-webhooks/` skeleton with `package.json`, `tsconfig.json`, `LICENSE`, `README.md`. -2. `git mv` the existing `plugins/github-webhooks.ts` → `packages/.../src/index.ts` and `plugins/github-webhooks/*.ts` → `packages/.../src/*.ts`. -3. Update relative imports inside `src/`. -4. Update `opencode-config-package.json` to declare the new dep. -5. Update `opencode-user-config.json`'s `plugin` array. -6. Update `Dockerfile` to copy `packages/` and re-run `bun install`. -7. Update `webhooks.json` to include explicit `db_path` (preserve existing volume). -8. Run a local boot test (build the image, hit the webhook endpoint with a fake delivery, verify the plugin loads from the new path). -9. Update repo README to point at the package's README for the canonical config docs. - -If step 8 reveals issues, fall back to a shim file (Option A from initial design) — but only as a contingency. - -## Risks - -- **OpenCode plugin loader behavior**. Need to verify OpenCode's `plugin: [...]` array correctly resolves a `file://` URL pointing at a `node_modules/...` directory containing both an ESM `dist/index.js` and a `package.json` with `"main"` / `"exports"`. The lore plugin uses this pattern, so it should work — but worth confirming during step 8. -- **bun-install resolution of local deps**. `file:./packages/...` from `~/.config/opencode/` resolves relative to the consumer's package.json. In the image, the relative path needs to be correct *after* the COPY. May need to use an absolute path or restructure. -- **Path persistence on cutover**. Per open question 2 above — without an explicit `db_path` in `webhooks.json`, the existing dedup DB is orphaned. Manageable but worth flagging. diff --git a/.opencode/plans/session-reuse-and-event-buffer.md b/.opencode/plans/session-reuse-and-event-buffer.md deleted file mode 100644 index d193f0e..0000000 --- a/.opencode/plans/session-reuse-and-event-buffer.md +++ /dev/null @@ -1,308 +0,0 @@ -# Plan: per-PR session reuse with event buffering - -## Goal - -Today, every webhook delivery spawns a fresh OpenCode session. Each session -re-explores the codebase from scratch, re-clones the repo, re-reads the -issue. Across the PR lifecycle this means the bot pays the cold-start cost -~10× (open → review → CI fix → comment response → review again → …). - -Replace the one-session-per-event model with a **per-PR session** model so: - -1. Lore, prior reasoning, and codebase context accumulate across events. -2. The agent can correlate "the comment I'm responding to now" with "the - review I posted 20 minutes ago" without re-deriving from GitHub. -3. The cold-start tax amortizes — only the first event on a PR pays full - exploration cost. - -Add an **event buffer** so when an event arrives mid-session, the plugin -queues it as a follow-up prompt rather than spawning a parallel session -or rejecting it. - -## Decisions (proposed; subject to review) - -| Question | Choice | -|---|---| -| Affinity key | PR number (`/#`); fallback to issue number for `issues.assigned`; fallback to `/@` for PR-less `check_suite` | -| Migration | When `issues.assigned` resolves into a PR, the issue's session is renamed/relinked to the PR's key | -| Concurrency | One in-flight `session.prompt` per session; subsequent events queue FIFO | -| Persistence | Reuse existing `bun:sqlite` (the `deliveries` DB); add `session_map` and `event_queue` tables | -| Context bound | Session retired after N consecutive idle hours OR PR merged/closed OR token-budget exceeds threshold (TBD) | -| Agent role isolation | Each enqueued event sets the `agent` field on `session.prompt` per its trigger config; the system prompt switches across roles within one session | -| Failure model | Agent run errors don't drop the session — next event reuses it. Session retired only on explicit retire signal (PR closed, idle expiry, manual purge) | - -## Architecture - -### Components added to `plugins/github-webhooks/` - -``` -session-affinity.ts key resolution: payload → session-key -session-store.ts session_map + event_queue persistence -session-runner.ts replaces dispatch.ts: lookup-or-create + queue + drain -``` - -### Affinity key resolution (`session-affinity.ts`) - -```ts -type SessionKey = string // canonical form - -function resolveSessionKey(event: string, payload: unknown): SessionKey | null -``` - -Logic by event: - -| Event | Key | -|---|---| -| `pull_request.*` | `/#pr` | -| `pull_request_review.*` | `/#pr` (from `payload.pull_request.number`) | -| `pull_request_review_comment.*` | `/#pr` | -| `issue_comment.*` (PR comment) | `/#pr` (from `payload.issue.number`, since on PR-issues these align) | -| `issue_comment.*` (issue-only) | `/#issue` | -| `issues.*` | `/#issue` | -| `check_suite.*` (with PRs) | `/#pr` (from `payload.check_suite.pull_requests[0].number`) | -| `check_suite.*` (no PRs) | `/@` (rare; mostly main-branch CI) | - -Migration: when `pull_request.opened` fires and the body contains -`Closes #N` / `Fixes #N` / `Resolves #N`, the plugin checks whether a -session for `/#issue` exists. If yes, that session's -key is rewritten to the new PR key (single SQLite UPDATE). - -### Session map (`session-store.ts`) - -New tables in the existing `deliveries.db`: - -```sql -CREATE TABLE IF NOT EXISTS session_map ( - key TEXT PRIMARY KEY, -- canonical session key - session_id TEXT NOT NULL UNIQUE, -- OpenCode session id - created_at INTEGER NOT NULL, - last_used_at INTEGER NOT NULL, - retired_at INTEGER -- NULL = active -); - -CREATE INDEX IF NOT EXISTS idx_session_map_last_used - ON session_map(last_used_at DESC); - -CREATE TABLE IF NOT EXISTS event_queue ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_key TEXT NOT NULL, - trigger_name TEXT NOT NULL, - agent TEXT NOT NULL, - prompt TEXT NOT NULL, - delivery_id TEXT NOT NULL, - enqueued_at INTEGER NOT NULL, - status TEXT NOT NULL CHECK (status IN ('queued','running','done','failed')) -); - -CREATE INDEX IF NOT EXISTS idx_event_queue_session_status - ON event_queue(session_key, status, id); -``` - -API: - -```ts -type SessionMap = { - lookup(key: SessionKey): { sessionId: string; lastUsedAt: number } | null - create(key: SessionKey, sessionId: string): void - touch(key: SessionKey): void - retire(key: SessionKey): void - retireIdle(olderThanMs: number): SessionKey[] -} - -type EventQueue = { - enqueue(item: QueueItem): number // returns row id - nextFor(key: SessionKey): QueueItem | null // FIFO - markRunning(id: number): void - markDone(id: number, status: 'done' | 'failed'): void - pendingFor(key: SessionKey): number // count -} -``` - -### Runner (`session-runner.ts`) - -Replaces `dispatch.ts` with a queue-aware variant. One in-memory -mutex per session-key to serialize prompts: - -```ts -function makeSessionRunner(opts: {...}): Dispatcher { - const locks = new Map>() - - return async function dispatch(t, prompt, deliveryId, payload) { - const key = resolveSessionKey(t.event, payload) - if (!key) { - // No correlatable key — fall back to old single-shot model. - return legacyDispatch(t, prompt, deliveryId) - } - eventQueue.enqueue({ - sessionKey: key, - triggerName: t.name, - agent: t.agent, - prompt, - deliveryId, - enqueuedAt: Date.now(), - status: 'queued', - }) - // Kick the drainer (no-op if already running for this key). - drainKey(key) - } - - async function drainKey(key: SessionKey) { - if (locks.has(key)) return // already draining - const p = (async () => { - let item = eventQueue.nextFor(key) - while (item) { - eventQueue.markRunning(item.id) - try { - const sessionId = await ensureSession(key, item) - await client.session.prompt({ - path: { id: sessionId }, - body: { agent: item.agent, parts: [{ type: 'text', text: item.prompt }] }, - }) - eventQueue.markDone(item.id, 'done') - sessionMap.touch(key) - } catch (err) { - eventQueue.markDone(item.id, 'failed') - // continue to next event — don't kill the queue on one failure - console.error(`[session-runner] item ${item.id} failed:`, err) - } - item = eventQueue.nextFor(key) - } - })() - locks.set(key, p) - try { await p } finally { locks.delete(key) } - } - - async function ensureSession(key, item) { - const existing = sessionMap.lookup(key) - if (existing && !await isSessionRetiredRemotely(existing.sessionId)) { - return existing.sessionId - } - const session = await client.session.create({ - body: { title: `[${key}] ${item.triggerName}` }, - query: { directory: deriveCwd(key) }, - }) - sessionMap.create(key, session.data.id) - return session.data.id - } -} -``` - -`isSessionRetiredRemotely` defends against the case where the OpenCode -host purges a session out from under us (e.g. user manually deleted it). - -### Idle reaper - -Background timer every hour: - -```ts -const retired = sessionMap.retireIdle(MAX_IDLE_MS) -for (const key of retired) { - console.log(`[session-runner] retired idle session for ${key}`) -} -``` - -`MAX_IDLE_MS` defaults to **72 hours**. Configurable via `SESSION_IDLE_HOURS` -in the webhook config. - -## Migration: issue → PR - -When `pull_request.opened` arrives and body contains `Closes #N`: - -```ts -const issueKey = `${owner}/${repo}#issue${N}` -const prKey = `${owner}/${repo}#pr${number}` -if (sessionMap.lookup(issueKey) && !sessionMap.lookup(prKey)) { - sessionMap.rename(issueKey, prKey) // UPDATE session_map SET key=? WHERE key=? -} -``` - -The session that resolved the issue continues seamlessly into reviewing -the PR it produced. This is the highest-value continuity case. - -## Open questions / risks - -### 1. Context bloat - -A long-lived PR with 10+ review rounds can blow past the model's context -window. Options: - -- **Auto-summarize** at threshold via OpenCode's existing `session.summarize` API call (need to verify availability). -- **Hard cut**: retire the session at N tokens, start fresh on next event. -- **Manual purge**: agent itself emits a `RETIRE` directive when it senses context is stale, plugin retires the session. - -**Proposal**: start with hard-cut at, say, 80% of model max context as -reported by OpenCode. Add summarization as a follow-up if hard-cut proves -disruptive. - -### 2. Cross-agent reasoning bias - -The same session runs `pr-reviewer` then `pr-comment-responder` on the -same PR. The comment-responder's first turn already has the reviewer's -findings in context. This can be: - -- **Good**: the bot doesn't contradict its own prior review. -- **Bad**: the bot agrees with itself even when a human comment surfaces a - blind spot the reviewer missed. - -**Mitigation**: agent prompts already say "this comment may surface things -the prior review missed; treat it on its merits." We rely on the LLM's -own awareness here. If we observe the bias in practice, the escape hatch -is to spawn a fresh sub-agent (via `task` tool) for the comment triage -within the same session, isolating its read pass from the parent's -history. - -### 3. Concurrency on the same PR - -Two near-simultaneous events on the same PR (e.g. comment + check_suite) -both target the same session-key. The mutex serializes them, but: - -- The first event might take 5 minutes (review). The second waits in the - queue. By the time the second runs, the PR state may have moved. -- Consequence: the agent should always re-read PR state at step 0, not - trust queued context. Existing agent prompts already do `gh pr view` - at step 0; adequate. - -### 4. Session storage is on the OpenCode host, not in our SQLite - -We're storing `(key → sessionId)` mappings, but the session's actual -content lives in OpenCode's per-session storage on disk. A redeploy that -wipes `~/.local/share/opencode/` (e.g. ephemeral rootfs without the -volume) would orphan all our mappings. Need to verify session data is -under `~/dev/.opencode/` (the persistent symlink target — yes, per -docker-entrypoint.sh:21) before relying on persistence claims. - -### 5. Manual override - -A maintainer should be able to force a fresh session — e.g. when -historical context has gone bad. Add an `X-OpenCode-Session-Reset: true` -header on the webhook delivery? Or a `RETIRE` magic comment on the PR? - -**Proposal**: `RETIRE` magic comment is more discoverable. The plugin -checks for `pr-retire-session` in the comment body; if present, retires -the session and dispatches the current event as the start of a fresh one. - -## Rollout plan - -1. **Phase 1**: ship the session-map + queue infrastructure with a feature - flag (`SESSION_REUSE_ENABLED=false` by default). Existing single-shot - dispatch path unchanged. -2. **Phase 2**: enable for `pull_request_review_comment` only. Lowest-risk - event class — comment-responder already does step-0 state refresh. -3. **Phase 3**: enable for the full PR lifecycle (`pull_request.*`, - `check_suite.*`, `pull_request_review.*`). -4. **Phase 4**: enable issue→PR migration. - -Each phase observes for: context-bloat reports (sessions > 100k tokens), -cross-agent bias incidents, concurrency stalls. Rollback is one env var. - -## Out of scope - -- Cross-repo session correlation (a PR in repo A that references repo B). -- Sessions surviving across container redeploys *without* the persistent - volume. The `~/dev` mount is the assumption; without it, this whole - feature degrades to single-shot. -- Streaming events into a session that's already mid-prompt. Bun's - `client.session.prompt` is request/response; injecting mid-prompt - would need OpenCode SDK changes upstream. -- Per-event-class agent-bias mitigation (sub-agent isolation). Deferred - to follow-up if observed in Phase 2. From aec1f71a0241d6ffabd8317676907f38911afe75 Mon Sep 17 00:00:00 2001 From: Aditya Mathur <57684218+MathurAditya724@users.noreply.github.com> Date: Fri, 1 May 2026 18:48:49 +0000 Subject: [PATCH 3/3] fix(plugin): bump peer dep floor + fail loudly on non-Bun runtimes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two minor review-followup items on PR #10: 1. peerDependencies floor was `@opencode-ai/plugin: >=1.1.0` but version 1.1.0 was never published to npm. The first existing version at-or-above the stated floor is 1.1.1. Range was still semver-valid so nothing broke, just an arbitrary floor — bump to >=1.1.1 so the stated floor is also resolvable. 2. Source-only TS distribution requires Bun at runtime (uses Bun.serve, Bun.spawn, Bun.file, bun:sqlite). README documents this and engines.bun is declared, but engines is advisory and a Node-on-Bun- fork consumer would have hit a cryptic ReferenceError on first dispatch. Add an explicit guard at the top of the plugin function that throws a useful message pointing at bun.sh. --- packages/opencode-webhooks/package.json | 2 +- packages/opencode-webhooks/src/index.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/opencode-webhooks/package.json b/packages/opencode-webhooks/package.json index 863dc2a..fd6a817 100644 --- a/packages/opencode-webhooks/package.json +++ b/packages/opencode-webhooks/package.json @@ -18,7 +18,7 @@ "build": "echo 'opencode-webhooks ships raw TS — no build step needed'" }, "peerDependencies": { - "@opencode-ai/plugin": ">=1.1.0" + "@opencode-ai/plugin": ">=1.1.1" }, "devDependencies": { "@opencode-ai/plugin": "^1.14.30", diff --git a/packages/opencode-webhooks/src/index.ts b/packages/opencode-webhooks/src/index.ts index 46a4efc..e02e45b 100644 --- a/packages/opencode-webhooks/src/index.ts +++ b/packages/opencode-webhooks/src/index.ts @@ -13,6 +13,17 @@ import { openDeliveryStore } from "./storage" export type { Trigger, WebhookConfig, NormalizedTrigger, SkippedDispatch } from "./types" export const GitHubWebhooksPlugin: Plugin = async (ctx) => { + // Bun-only: we use Bun.serve, Bun.spawn, Bun.file, and bun:sqlite. + // OpenCode runs on Bun by default so this is normally a no-op; the + // check exists so consumers running an unofficial Node-based fork + // get a useful error instead of a cryptic ReferenceError on first + // dispatch. + if (typeof Bun === "undefined") { + throw new Error( + "opencode-webhooks requires Bun (uses Bun.serve, Bun.spawn, Bun.file, bun:sqlite). Install Bun >=1.2.0: https://bun.sh", + ) + } + // Don't let a dispatch bug take down the host opencode server. const guard = globalThis as { __ghWebhookGuard?: boolean } if (!guard.__ghWebhookGuard) {