feat: GitHub webhooks → OpenCode agent sessions (plugin + bundled config + agent) - #5
Merged
Conversation
MathurAditya724
force-pushed
the
add-hono-sidecar
branch
from
April 30, 2026 17:34
b85fce1 to
bc824c6
Compare
Replaces what was the Hono sidecar in this branch with an OpenCode
plugin that runs INSIDE the long-lived opencode server process.
What's bundled
--------------
- plugins/github-webhooks.ts: opens its own Bun.serve listener on
WEBHOOK_PORT (default 5050). Verifies X-Hub-Signature-256, dedups
on X-GitHub-Delivery (redeliveries are ack'd as duplicate, no
re-fire), matches deliveries against triggers, and dispatches
agent sessions via the in-process SDK client.
- agents/github-issue-resolver.md: autonomous 'issue assigned →
branch → plan → implement → push → gh pr create' workflow.
Authenticated via GH_TOKEN.
Architecture
------------
Originally implemented as a separate Bun+Hono sidecar process
supervised alongside opencode-web. That worked but had real downsides:
a cold-boot race against opencode's HTTP server, a second process to
supervise, loopback HTTP for every SDK call, and an opencode_sessions
mirror table. None of that is necessary now that we know plugins load
into the long-lived opencode server process — they get an SDK client
bound to the host server in their context, so client.session.create()
and client.session.prompt() are direct in-process calls.
The plugin still opens its own port (5050) for the webhook receiver
because OpenCode's plugin API doesn't expose a hook for adding routes
to its existing HTTP server. Process-level isolation is gone — an
unhandled rejection here can crash opencode-web — so we install a
top-level unhandledRejection guard and catch aggressively at the
dispatch boundary.
Trigger config
--------------
Stored in a JSON file (default ~/.config/opencode/webhooks.json,
overridable with WEBHOOKS_CONFIG). Not in opencode.json, because that
file's published schema declares experimental.additionalProperties:false
and would reject our extension. The plugin stays dormant until the file
exists with at least one trigger, so unused images don't open ports
nobody asked for.
Dependencies
------------
Plugin uses only built-ins: Bun.serve, bun:sqlite, node:crypto. The
sole npm dep is @opencode-ai/plugin (for the Plugin type). Declared
in opencode-config-package.json which is copied to
~/.config/opencode/package.json and bun-installed at build time.
Idempotency
-----------
SQLite (~/dev/.opencode/github-webhooks.sqlite by default) keyed on
delivery_id with ON CONFLICT DO NOTHING. The host opencode server is
the system of record for sessions; we don't mirror them.
Verified
--------
Standalone harness exercising:
- missing X-GitHub-Event/Delivery headers → 400
- bad HMAC → 401
- valid first delivery → 200, dispatch fires
(session.create + session.prompt called with rendered prompt)
- same delivery_id replayed → duplicate:true, no
dispatch, no extra session.create call
tsc --noEmit clean.
MathurAditya724
force-pushed
the
add-hono-sidecar
branch
from
April 30, 2026 18:14
c5a4747 to
fd07425
Compare
…Path, log clarity
- Trigger matching: drop the misleading priority/sort logic and
document the actual behavior (all matching enabled triggers fire).
Catches the previously-broken case of a trigger with omitted
'action' field (undefined) being strict-equal-compared to a payload
with action: null. Triggers are now normalized at load time so
t.action is always 'string | null' (never undefined). Verified end
to end: { event: 'issues' } now correctly matches issues.assigned
alongside { event: 'issues', action: 'assigned' } and { event: '*' }.
- Graceful SIGTERM/SIGINT: count in-flight dispatches, close the
listener immediately on signal (Bun.serve.stop(true)) so new
connections are refused, then await drain (with 25s ceiling). This
prevents Railway redeploys from leaving half-baked sessions on the
host — a webhook arriving during the kill window now gets refused
cleanly instead of being acked and then dying mid-dispatch. Verified
in harness: post-SIGTERM fetch gets ConnectionRefused.
- dbPath default: ${homedir()}/dev/.opencode/github-webhooks.sqlite
instead of ${ctx.directory}/.opencode/... — ctx.directory shifts
with the active session/project, which would split the dedup table
across directories and re-fire agents on redeliveries that arrive
while a different project is active. The new default lives on the
Railway-persistent ~/dev volume alongside opencode's own session
data.
- 'no triggers configured' log message: replaced the stale
'experimental.webhook.triggers' reference (an earlier abandoned
config layout) with the actual path being checked
($WEBHOOKS_CONFIG or ~/.config/opencode/webhooks.json). Same fix
applied to the 'no HMAC secret' warning.
- Dockerfile: comment warning against mounting a runtime volume over
~/.config/opencode (would mask the baked-in node_modules and break
the plugin loader at startup).
- .dockerignore: 'node_modules' was redundant with '**/node_modules'.
- Default-export comment: clarified that 'default' is ergonomic, not
required by OpenCode's plugin loader (any exported function works).
…olver)
Ship a working webhooks.json baked into the image so the
github-webhooks plugin activates the moment GITHUB_WEBHOOK_SECRET is
set — no further setup needed for the headline 'issue assigned →
PR opened' flow.
The bundled config has one trigger:
issues.assigned → github-issue-resolver
The prompt_template renders the repo, issue number/title/body/url,
assignee, author, and labels into a context-rich prompt for the agent.
Override mechanics:
- Edit webhooks.json in this repo and rebuild — triggers stay
version-controlled.
- Set WEBHOOKS_CONFIG=/home/developer/dev/.opencode/webhooks.json
(or any path on the persistent volume) to point at a runtime-
editable config without rebuilding.
The HMAC secret is intentionally NOT in the file — it stays as the
GITHUB_WEBHOOK_SECRET env var so it isn't baked into image layers.
Verified end-to-end with a realistic GitHub issues.assigned payload:
plugin loads the baked-in file, listener binds, the trigger matches,
session.create + session.prompt are called with the right agent
('github-issue-resolver'), and the rendered prompt correctly
interpolates repo/issue/assignee/author from the payload.
…handlers, lockfile Correctness ----------- - P1: timer.unref() on the per-dispatch abort setTimeout. The 30-min timer was keeping Bun's event loop alive past intentional shutdown, which made the process appear hung in logs even after graceful drain completed. unref() lets the loop exit naturally once the dispatch finishes (or is canceled by the drain logic). - A2: 25 MB body size cap on POST /webhooks/github, enforced both via Content-Length and on actual rawBody.length (defense in depth — Bun normalizes Content-Length to actual byte length, but a future runtime change could break that assumption). GitHub caps webhook payloads at 25 MB, so this only refuses pathological/attacker traffic. Verified: 26 MB body returns 413 before HMAC verification. - P3: process.once for SIGTERM/SIGINT instead of process.on. Prevents listener accumulation if the plugin is ever re-initialized in the same process. Bonus: a second SIGTERM after shutdown is initiated hits Node's default handler (force exit), which is what an operator pressing Ctrl-C twice usually wants. Reproducibility --------------- - O1: committed opencode-config-bun.lock alongside the package.json, and switched the Dockerfile to bun install --frozen-lockfile so builds resolve to the same @opencode-ai/plugin transitive tree on every rebuild. Without this, a caret-ranged dep could silently bump on the next image build. Documentation ------------- - A7: agent prompt now includes a defensive 'reset before re-using a cloned repo' block (git reset --hard origin/<default> + git clean -fd) so a previous run's leftover branch/dirty tree doesn't wedge the new run with a 'checkout failed' error. - R1: README example trigger is now labelled 'minimum-viable trigger' with an explicit pointer to webhooks.json as the working reference. Was confusing because the README example was simpler than the bundled file. - R4/R5: clarified the health check is on the plugin's port (not OpenCode's 4096), and that the env var + bundled webhooks.json TOGETHER are what activates the listener (not the env var alone). - S1/S2/S3/S5: stale comments in plugin module-doc and types updated to match current behavior. Repeated globalThis cast extracted to a single 'guard' const.
MathurAditya724
added a commit
that referenced
this pull request
May 1, 2026
- opencode-webhooks README: replace stale `x-email-from` header reference with JSON `from` field (finding #1). - http.ts: rewrite MAX_BODY_BYTES comment (no longer about RFC822); add MAX_EMAIL_BODY_BYTES = 64 KB and an optional maxBytes arg to readBodyBytes; email handler now uses the tighter cap (finding #2). - cloudflare-email-worker README: note that a malformed regex literal in ALLOWED_SENDERS will throw at module init and the worker won't start (finding #3). - email handler: parseEmailEvent's `str` now throws on non-string for required fields (from/to/subject/message_id) so the 400 detail names the offending field instead of misleading 'missing' messages (finding #4). - Test gap for email identity fallback chain tracked in #14; no tests added in this commit (finding #5).
MathurAditya724
added a commit
that referenced
this pull request
May 1, 2026
… SIDECAR_URL → WEBHOOK_URL (#13) * refactor(email-worker): wrangler.json, inline ALLOWED_SENDERS, rename SIDECAR_URL → WEBHOOK_URL - Replace wrangler.toml with wrangler.json (with $schema reference for editor validation). TOML and JSON are both first-class wrangler config formats; JSON keeps the worker package consistent with the rest of the repo's config files (package.json, tsconfig.json, webhooks.json). - Move ALLOWED_SENDERS out of wrangler vars and into a top-level TypeScript const at the head of src/index.ts. The allowlist is PR-reviewed code now (typed as readonly string[]), compiled once at module load (zero per-request parse overhead), and a malformed regex fails the deploy instead of silently dropping at runtime. The Env shape no longer needs an ALLOWED_SENDERS field. - Rename SIDECAR_URL → WEBHOOK_URL in wrangler.json vars, the Env interface, and the fetch call. 'Sidecar' was misleading anyway — the plugin is in-process with opencode, not a sidecar process. WEBHOOK_URL clearly names what it points at: the plugin's /webhooks/email endpoint. * feat(email): worker becomes dumb pipe — unconditional forward + JSON event The Cloudflare Email Worker now does two things per inbound email: 1. message.forward(env.FORWARD_TO) unconditionally (if set), so every email reaches the operator's real inbox preserving DKIM. Wrapped in try/catch so a misconfigured FORWARD_TO doesn't block webhook dispatch — failure is logged loudly and we continue. 2. If the From address is in ALLOWED_SENDERS, build a small JSON event from the headers we route on (from, to, subject, message_id, in_reply_to, references, list_id, x_github_reason, x_github_sender), HMAC-sign it, and POST as application/json to WEBHOOK_URL. The worker no longer parses RFC822 in the plugin path — it just hands Cloudflare's already-parsed headers to the plugin verbatim. The body is never sent at all (it never was used; canonical state comes from the GitHub API). Plugin side: - handlers/email.ts: reads req.json() instead of parsing RFC822; validates the event shape; everything downstream (identity, synth, dispatch) is unchanged. - email/identity.ts: accepts the EmailEvent JSON shape instead of an EmailHeaders object. Same regex matchers; same in-reply-to / references fallback chain. - email/synthesize.ts: reads metadata directly from the event object. - email/parse.ts: deleted (no more RFC822 parsing). Other changes: - wrangler.json: added FORWARD_TO var (optional) and observability.logs.enabled = true so 'wrangler tail' and the Cloudflare dashboard show structured logs. - READMEs: updated wire format, architecture diagrams, failure-mode table on the worker side. Verified: bun run typecheck passes for both packages; wrangler deploy --dry-run accepts the new config with both vars bound. * fix: review findings on PR #13 (dumb-pipe + wrangler.json) - opencode-webhooks README: replace stale `x-email-from` header reference with JSON `from` field (finding #1). - http.ts: rewrite MAX_BODY_BYTES comment (no longer about RFC822); add MAX_EMAIL_BODY_BYTES = 64 KB and an optional maxBytes arg to readBodyBytes; email handler now uses the tighter cap (finding #2). - cloudflare-email-worker README: note that a malformed regex literal in ALLOWED_SENDERS will throw at module init and the worker won't start (finding #3). - email handler: parseEmailEvent's `str` now throws on non-string for required fields (from/to/subject/message_id) so the 400 detail names the offending field instead of misleading 'missing' messages (finding #4). - Test gap for email identity fallback chain tracked in #14; no tests added in this commit (finding #5).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
End-to-end automation for "GitHub issue gets assigned → OpenCode resolves it → opens PR", implemented as an OpenCode plugin that loads inside the long-lived
opencodeserver process.Once you set
GITHUB_WEBHOOK_SECRETon a deploy, this image:5050issues.assigneddeliveries against your HMAC secretopencodeagent session runninggithub-issue-resolveragainst the right repoWhat's bundled
plugins/github-webhooks.ts(~510 lines)Bun.servelistener onWEBHOOK_PORT(default 5050). VerifiesX-Hub-Signature-256, dedups onX-GitHub-Delivery, dispatches matching triggers viactx.client.session.create+ctx.client.session.prompt. Concurrency cap (default 2), per-session timeout (default 30 min), retention cap (default 1000 dedup rows).agents/github-issue-resolver.mdgh+gitCLIs. Authenticated viaGH_TOKEN. Includes defensive working-tree reset for re-using cloned repos across runs.webhooks.jsonissues.assigned→github-issue-resolver. The prompt template interpolates repo, issue number/title/body/url, assignee, author, and labels.opencode-config-package.json+opencode-config-bun.lock~/.config/opencode/{package.json,bun.lock}so the plugin'simport type { Plugin }resolves at startup. Lockfile committed for reproducible builds.Dockerfile(+44 LOC)~/.config/opencode/at build time. Runsbun install --frozen-lockfile --productiononce. Exposes port 5050..env.example(+24 LOC)GITHUB_WEBHOOK_SECRET(the activation switch),WEBHOOKS_CONFIG,WEBHOOK_PORT.README.md(+103 LOC)Plugin uses zero npm runtime deps — only Bun built-ins (
Bun.serve,bun:sqlite,node:crypto).@opencode-ai/pluginisimport type-only.Architecture
The plugin runs inside opencode's process and uses
ctx.client(the in-process SDK client provided by the plugin context) to drive sessions directly. No loopback HTTP, no cold-boot race against opencode's HTTP server, no second process to supervise.Trade-off: an unhandled rejection in the plugin can crash opencode-web. Mitigated with a top-level
unhandledRejectionguard, aggressive try/catch at the dispatch boundary, body-size caps, and graceful SIGTERM handling that lets in-flight dispatches drain (with a 25s ceiling).Trigger config
Stored as JSON. Default path
~/.config/opencode/webhooks.json(the baked-in file); overridable with theWEBHOOKS_CONFIGenv var pointing to a path on the persistent~/devvolume. Not stored inopencode.jsonbecause that file's published schema declaresexperimental.additionalProperties: false.Multiple triggers can match the same delivery — all matching enabled triggers fire concurrently, capped by
max_concurrent. The HMAC secret is intentionally not in the JSON file — kept asGITHUB_WEBHOOK_SECRETenv var only.How it works end-to-end
issues.assigned→POST /webhooks/githubon port 5050.GITHUB_WEBHOOK_SECRETusingtimingSafeEqual.X-GitHub-Deliveryvia SQLite. Redeliveries are ack'd asduplicate: trueand don't re-fire.(event, action)against the trigger list. Every enabled trigger that matches fires.client.session.create+client.session.prompt.git fetch --all --prune→git reset --hard origin/<default>(defensive) → branch → plan → implement → push →gh pr create.The host opencode server is the system of record for the resulting session — view it in the web UI like any other.
Railway deploy
Railway only generates one HTTP domain per service, so port 5050 needs separate routing — second Railway service pointing at the same image, a TCP proxy, or Cloudflare. The opencode web UI on
4096/$PORTis independent.Security model
sha256=, length-equality beforetimingSafeEqual.rawBody.length(defense in depth).X-GitHub-Event,X-GitHub-Delivery) enforced before HMAC, so non-GitHub sources can't synthesize delivery IDs to flood the dedup table or matchevent: "*"rules.INSERT...ON CONFLICT DO NOTHINGactually inserted.setTimeoutisunref()'d so it doesn't hold the event loop past intentional shutdown.process.onceto avoid listener accumulation): closes the listening socket immediately, drains in-flight dispatches with a 25s ceiling.Verified
Standalone harness with a mock SDK client. Tests exercise:
X-GitHub-Event/X-GitHub-Delivery→ 400 ✓delivery_id→ 200,duplicate:true,dispatched:[], no extra SDK calls ✓Bun.servesocket closes, post-SIGTERMfetchgetsConnectionRefused, process exits cleanly despite pending 30-min abort timers ✓webhooks.json+ agentgithub-issue-resolver: realisticissues.assignedpayload renders correctly into the prompt template ✓tsc --noEmitclean.Commits
fd07425cff9a3d~/dev/.opencode/, log message clarity, graceful shutdown3ee264cwebhooks.jsonso the feature works the momentGITHUB_WEBHOOK_SECRETis set9b0769btimer.unref()for clean exit, 25 MB body cap,process.oncehandlers, frozen lockfile, defensive working-tree reset in agent, README example clarification, comment cleanupOut of scope / follow-ups
WEBHOOKS_CONFIGat a writable path on the volume + restart.session.promptto completion; for very long-running agents you'd wantclient.event.subscribeand an SSE relay.add-hono-sidecar(legacy from prior design). Rename out-of-scope; PR URL stays.