New:Introducing Socket Scanning for VS Code Marketplace Extensions.Learn more →
Get Started

opencode-courier

Package Overview
Dependencies
Maintainers
1
Versions
12
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install
Package version was removed
This package version has been unpublished, mostly likely due to security reasons

opencode-courier

OpenCode V2 plugin: spawn sessions, message them, and wake idle sessions without polling.

unpublished
Source
npmnpm
Version
0.1.1
Version published
Weekly downloads
2K
Maintainers
1
Weekly downloads
 
Created
Source

opencode-courier

CI

An OpenCode V2 plugin that lets one session start other sessions, message them, and be woken by them, without polling.

A parent session calls courier_spawn, gets a session id back immediately and ends its turn. The child works on its own and, when it is done or stuck, calls courier_send with the parent's id. That message lands in the parent's inbox and OpenCode starts a new turn for the parent if it is idle.

Status: early. Passes an end-to-end test inside a live OpenCode V2 server (opencode2 v0.0.0-beta-19271) driven by a scripted stand-in model (e2e/run.sh); not yet tried with a real model.

How the wake works

There is no polling anywhere. courier_send calls the plugin API's session.synthetic, which admits a message into the target session's inbox and, unless resume: false is passed, calls execution.wake on it (packages/core/src/session/session.ts on OpenCode's beta branch). OpenCode's own background subagents report to their parent the same way (packages/core/src/session/subagent-completion.ts).

Delivery is steer by default (injected into the target's running turn, or starts one if idle); queue: true waits until the current turn ends.

Tools

ToolDoes
courier_spawnCreates a session (optionally in its own git worktree with isolate: true), sends it the task plus a brief naming the parent and how to report back, and returns at once.
courier_sendDelivers a message to a session, signed with the sender's id, waking it if idle.
courier_statusOne look at a session: outcome, idle time and last reply. For check-ins, not for waiting.
courier_childrenLists the sessions this one (or a given sessionID) started with courier_spawn, each with what courier_status reports plus its directory, whether it is isolated and when it was started.
courier_cleanupRemoves the git worktree of a child started with isolate: true and drops the child from courier_children. Keeps a worktree with uncommitted changes or commits on no branch, tag or remote and lists them, unless force: true is passed.
courier_laterSchedules a message for a session (this one by default) in delayMinutes or at an ISO time, and returns an id. When due it is delivered like courier_send, queued behind any running turn and waking the session if idle.
courier_cancelDrops a message scheduled with courier_later, e.g. because the child it was waiting for reported first.
courier_subscribeSubscribes a session (this one by default) to webhook deliveries for a topic: owner/repo, owner/repo#12 (one pull request or issue) or a generic name. Each matching delivery arrives as a message, queued behind any running turn and waking the session if idle. Needs the webhook receiver.
courier_unsubscribeDrops one topic, or all of a session's, e.g. once its pull request is merged.

Roster

courier_spawn records each child under its parent in the plugin's storage, so a parent that has lost track after a compaction or a server restart can call courier_children to find them again. A child that can no longer be looked up is still listed, with the error instead of its state. Entries are dropped 14 days after the child was started, when that parent's roster is read or the plugin is next loaded, except isolated children whose worktree is still there (see Worktree cleanup). If the roster cannot be written, the child still gets its task and courier_spawn says it is not on the list.

Worktree cleanup

An isolated child works in a git worktree under OpenCode's data directory (…/opencode/worktree/<project>/<name>, on a detached HEAD), and nothing removes it on its own. When the parent has what it needs from the child, it calls courier_cleanup { sessionID }, which removes the worktree through the plugin API's worktree.remove and drops the child from courier_children.

The worktree is kept, and the result says why, when it holds work that would otherwise be lost:

  • uncommitted changes, untracked files included (ignored files, such as node_modules, are not work and go with the worktree);
  • commits that are on no branch, tag or remote-tracking ref, which is where a child's commits on its detached HEAD end up. A commit on a branch survives the removal, so it does not count, and neither do commits the worktree was made from (courier_spawn records that commit), such as a parent's own unbranched work when an isolated child spawns isolated children of its own.

The result lists up to 50 changed paths (an untracked directory counts once) and 50 commits. Commit or branch what you want to keep (git -C <worktree> branch <name> keeps its commits), or call courier_cleanup again with force: true to discard it; force also removes a worktree git can no longer read. A worktree whose directory is already gone is just dropped from the list; git forgets its registration on its next git worktree prune or git gc.

Cleanup is explicit only. A child reporting back does not mean the parent has merged, reviewed or even read its work, and the parent may still send it more to do in the same worktree, so the plugin never removes one on its own. Isolated children whose worktree still exists are kept on courier_children past the 14 days, so they can still be found and cleaned up.

courier_cleanup cannot tell whether the child is still running, so call it after the child has reported. It works on the calling session's own children.

Scheduled messages

Pending courier_later messages are kept in the plugin's storage, and every loaded copy of the plugin checks for due ones every 15 seconds, so a message can arrive up to about 15 seconds late. OpenCode loads the plugin once per project location; the copies share one claim set, so each message is delivered once.

They survive a server restart. After a start, OpenCode loads plugins for a project the first time that project is used, so messages that fell due while it was down are delivered then, not at the moment the server comes back. A crash between delivering a message and forgetting it can deliver it twice after the restart; a lost check-in would be worse.

Webhooks

With the webhook option set (see Receiving webhooks), the plugin listens for HTTP deliveries and turns them into messages for subscribed sessions:

  • POST /github takes GitHub webhook deliveries. A pull request review, a review comment, a comment, a pull request or issue being opened, reopened, closed (or merged) or marked ready for review, or a completed check run, check suite or workflow run on a pull request goes to the sessions subscribed to owner/repo#N and to owner/repo; anything else with a repository (a push, a release) goes to owner/repo only. Pings, CI runs that have not completed, and other pull request and issue actions (pushes to the branch, edits, labels, assignments, review requests) wake nobody.
  • POST /hook/<name> takes anything else, for sessions subscribed to <name>. A JSON body's text, summary or message field is delivered, otherwise the body itself.

Every delivery must carry an X-Hub-Signature-256 header: sha256= followed by exactly 64 hex digits, the HMAC-SHA256 under the shared secret. For GitHub that is of the raw body, as GitHub sends it. For /hook/<name> it is of the name, a newline and the body, so a captured delivery cannot be sent to another topic:

sig=$(printf '%s\n%s' deploys "$body" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)
curl -X POST -H "x-hub-signature-256: sha256=$sig" --data-binary "$body" http://127.0.0.1:4097/hook/deploys

A missing or wrong signature gets 401, and the body is not parsed. The check is constant-time. Bodies over 1 MiB (maxBytes) get 413. A delivered event gets 202, with the number of sessions it reached, which can be 0. The digests of the last 1000 accepted deliveries are remembered in memory (as lowercase hex, so re-casing the header does not get around it), and a delivery already accepted gets 200 already delivered. One that reached nobody because every delivery to a session failed is forgotten again, so it can be retried. That stops replays of a captured delivery, and it also means a GitHub Redeliver of a delivery that already arrived is ignored. Redelivering one that failed works. Generic senders that post the same text twice should add something unique, such as a timestamp, to the body.

A session that OpenCode no longer knows loses its subscriptions the next time a delivery for it fails, and courier_subscribe refuses a session id that does not exist.

A session sees a short summary (event, repository and number, who, state or conclusion, link, and at most 1500 characters of a review or comment body), wrapped in <courier from="github" event="..."> and followed by a note that it is outside text, to be treated as data. Review and comment bodies are written by whoever can comment on the repository, so subscribe sessions only to repositories whose commenters you trust with your agent's attention. The server log gets one line per delivery (event, delivery id, number of sessions), never the payload or the secret.

GitHub does not report check suites on pull requests from forks (pull_requests is empty), so CI results for those reach owner/repo subscribers only. There is no GitHub event for a merge conflict.

Install

Requires OpenCode V2, command opencode2. Its plugin API is still beta, and each release is built and tested against one version of it: the @opencode-ai/plugin peer dependency in package.json. The CLI of that version is the one known to work:

npm install -g @opencode-ai/cli@0.0.0-beta-19271

Then install the plugin:

opencode2 plugin add opencode-courier

This installs the package from npm and adds "opencode-courier" to plugins in the global configuration (~/.config/opencode/opencode.json). To receive webhooks, replace that entry with the object form shown below, which carries a webhook option.

From a local clone

git clone <this repo> && cd opencode-courier
bun install && npm run build

Then list it in opencode.json (V2 uses plugins, plural). A local plugin path must be a directory; OpenCode loads its index.js, and ignores a path to a file with a warning:

{
  "plugins": ["/absolute/path/to/opencode-courier/dist"]
}

Receiving webhooks

The receiver is off unless the plugin has a webhook option. Put it in the global config (~/.config/opencode/opencode.json), since there is one receiver per OpenCode server:

{
  "plugins": [
    {
      "package": "opencode-courier",
      "options": { "webhook": { "port": 4097, "secretFile": "~/.config/opencode/courier-webhook-secret" } }
    }
  ]
}

From a local clone, package is the path to its dist directory instead. "webhook": true takes every default. If the option is given more than once, for example in a project's config as well, the first location to load wins, and the others log that their settings are ignored.

OptionDefault
port4097Port to listen on.
host127.0.0.1Address to bind. Only this machine can reach the default.
secretFileFile holding the shared secret (~ is expanded).
secretEnvCOURIER_WEBHOOK_SECRETEnvironment variable holding it, when there is no secretFile.
maxBytes1048576Largest body accepted.

The secret is never read from opencode.json itself (a secret key is refused), so the config can be committed. Make one with openssl rand -hex 32 > ~/.config/opencode/courier-webhook-secret and chmod 600 it. A file is the safer choice with opencode2 service start, whose environment may not be your shell's. Without a usable secret the receiver does not start, and the server log says why.

On GitHub, add a webhook to the repository (Settings → Webhooks) with content type application/json, the same secret, and the events you want (pull request reviews, review comments, issue comments, pull requests, check suites or workflow runs). GitHub must reach the receiver, and by default it only listens on 127.0.0.1: forward a public URL to it with a tunnel you trust (cloudflared tunnel --url http://127.0.0.1:4097, ngrok http 4097, or smee --url https://smee.io/<channel> --target http://127.0.0.1:4097/github, which needs no inbound port at all) and use <public URL>/github as the payload URL. Whatever you expose, only signed deliveries are acted on.

The receiver starts when OpenCode loads the plugin, which after a server start happens the first time a project is used. Until then deliveries fail; GitHub does not retry them on its own, but lists them under Recent Deliveries with a Redeliver button.

Using it

  • Keep the background server running so sessions can be woken while you are away (opencode2 service start; opencode2 service status to check).
  • Give the agents that run children permissions that don't need a human; a child waiting on an approval prompt never reports back.
  • Use isolate: true whenever children edit files in parallel. The child's worktree is made from the last commit, so an uncommitted opencode.json is not there and the child falls back to your global config: keep providers and models in the global config, or commit the file. When you are done with an isolated child, courier_cleanup it so its worktree does not linger.
  • A child that crashes before calling courier_send never wakes the parent. When you spawn a long-running child, also courier_later a check-in for yourself, and courier_cancel it when the child reports.

Roadmap

Tracked as issues:

  • #5 Smoke test with a real model.

Development

bun install
bun test           # unit tests, with a fake plugin context
npm run typecheck
npm run build      # emits dist/
OPENCODE_BIN=$(which opencode2) npm run test:e2e   # live test, see below

e2e/run.sh starts a real OpenCode V2 server in a throwaway project and home directory, with this plugin loaded and e2e/mock-model.mjs as the model: an OpenAI-compatible server that replies from a fixed script, so no API key is needed. It checks that a parent's spawn completes, that the parent gets a new turn after its own has ended once the child reports (shared and isolate: true), that courier_status reports and fails readably, that a courier_later message wakes an idle parent, that a cancelled one never arrives, that a pending one is delivered after a server restart, that courier_children lists the two children a parent spawned, before and after that restart, that a recorded GitHub review delivery (e2e/fixtures/pull_request_review.json), signed, wakes an idle session subscribed with courier_subscribe, once, while unsigned and wrongly signed ones are refused, and that courier_cleanup removes an isolated child's clean worktree but keeps one with an uncommitted file until asked with force. Last, it packs the package with npm pack, serves the tarball from a stand-in registry (e2e/registry.mjs), installs it with opencode2 plugin add opencode-courier and checks that its tools load from the installed copy. It takes about two minutes and needs node, npm, bun, git, curl, jq and openssl.

CI (.github/workflows/ci.yml) runs both on every push to main and every pull request, with the OpenCode CLI at the same version as the pinned plugin API.

Releasing

.github/workflows/release.yml stages a release on npm when a v* tag is pushed; a maintainer then approves it. No token is involved anywhere.

npm version patch   # bumps package.json, commits, tags vX.Y.Z
git push --follow-tags
  • The workflow runs the CI workflow, checks that the tag matches the version in package.json, builds, and runs npm stage publish from the npm environment. It authenticates with npm trusted publishing (OIDC), which also adds a provenance attestation. On npmjs.com, the package's trusted publisher is this repository, workflow release.yml, environment npm, and it may only stage. Before staging, the job logs the claims of its OIDC token (repository, workflow, environment, ref) so a mismatch with the trusted publisher shows in the log, and it stages with --loglevel verbose because npm reports a failed OIDC exchange only there.
  • It then creates a draft GitHub release with generated notes, so nothing is announced yet.
  • A maintainer reviews the staged version and approves it with 2FA: on npmjs.com under Staged Packages, or with npm stage list and npm stage approve <id>. The version is live from then.
  • Publish the draft release: gh release edit vX.Y.Z --draft=false, or Publish release on GitHub.

If staging fails, nothing reached npm and the version is still free. Re-running the job reuses the workflow file at the tag, so after fixing release.yml move the tag to the fixed commit instead: git push origin :refs/tags/vX.Y.Z, then tag and push again.

npm cannot use trusted publishing for this repository yet. The registry rejects the immutable OIDC subject claims GitHub issues for repositories created after 2026-07-15 (npm/cli#9969), so release.yml fails at Stage with OIDC token exchange error - package not found in its verbose log, although the claims it prints match the trusted publisher. Until npm fixes this, stage by hand from the tag, still without a stored token (npm 11.15.0 or later, Node 22.14 or later):

git checkout vX.Y.Z
npm install && npm run build
npm login
npm stage publish --access public   # add --tag next for a prerelease

Approve the staged version with 2FA as above, then create the release: gh release create vX.Y.Z --verify-tag --generate-notes (add --prerelease for a prerelease). A version staged by hand has no provenance attestation.

A prerelease version (1.2.0-beta.1) is staged for the next dist-tag and its release is marked as a prerelease.

CI also checks the package as published: publint for package.json and exports, and @arethetypeswrong/cli for the type declarations.

The plugin API is still beta and pinned to an exact version in package.json; bump it deliberately and re-run both test suites.

Notes on the V2 plugin API

Found while testing against 0.0.0-beta-19271:

  • A plugin tool is only reachable through code mode's execute tool unless it is registered with options: { codemode: false }. The courier tools are direct tools.
  • A tool whose result metadata holds an undefined value never completes: the call stays running and no error is reported. Results here drop undefined keys.
  • A plugin cannot add an HTTP route to OpenCode's own server. The nearest thing, rpc.register, is reached through the authenticated /api/rpc endpoint with a JSON envelope, so neither GitHub's headers nor the raw body its signature covers would get through. The webhook receiver is therefore its own small listener inside the OpenCode process, shared by the plugin's per-location instances. It waits for the previous listener to finish closing before it binds, as after a plugin reload, and if binding fails, the next instance to load tries again. A plugin's options come from a { "package", "options" } entry in plugins, which takes a local directory as package too.
  • OpenCode errors such as Session.NotFoundError can arrive with an empty message, so the tools rethrow them with the tag and session id.

License

MIT, see LICENSE.

Keywords

opencode

FAQs

Package last updated on 03 Oct 2026

Related posts