opencode-courier 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # opencode-courier
2
2
 
3
3
  [![CI](https://github.com/ivopogace/opencode-courier/actions/workflows/ci.yml/badge.svg)](https://github.com/ivopogace/opencode-courier/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/opencode-courier)](https://www.npmjs.com/package/opencode-courier)
5
+ [![Socket Badge](https://badge.socket.dev/npm/package/opencode-courier)](https://socket.dev/npm/package/opencode-courier)
6
+ [![License: MIT](https://img.shields.io/npm/l/opencode-courier)](LICENSE)
7
+ [![Supported OpenCode V2 version](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fivopogace%2Fopencode-courier%2Fmain%2Fpackage.json&query=%24.peerDependencies%5B%27%40opencode%2Fplugin%27%5D&label=opencode&color=blue)](#supported-opencode-version)
4
8
 
5
9
  An [OpenCode](https://github.com/anomalyco/opencode) V2 plugin that lets one session start other
6
10
  sessions, message them, and be woken by them, without polling.
@@ -12,304 +16,102 @@ idle. A child whose turn fails instead, so that it cannot report, is reported by
12
16
  child that waits for a permission or asks a question has it passed to the parent, who asks you and
13
17
  passes your answer back.
14
18
 
15
- The parent does not wait. Prompt: *"Spawn two children: one runs `sleep 30` then lists the exports
16
- of math.js, the other runs `sleep 45` then lists the exports of text.js. Don't wait for them:
17
- meanwhile write a short README.md for this folder yourself, and add their reports to it when they
18
- arrive."*
19
+ > **Status: early.** Passes an end-to-end test inside a live OpenCode V2 server driven by a
20
+ > scripted stand-in model, and a smoke test with real (free) models: see
21
+ > [Development](#development).
19
22
 
20
- ![A parent spawns two slow children, writes a README itself, and is woken by each report](docs/demo-async.gif)
23
+ **The parent does not wait.** Prompt: *"Spawn two children: one runs `sleep 30` then lists the
24
+ exports of math.js, the other runs `sleep 45` then lists the exports of text.js. Don't wait for
25
+ them: meanwhile write a short README.md for this folder yourself, and add their reports to it when
26
+ they arrive."*
21
27
 
22
- Watch the parent write its README and finish its turn 13 seconds in, while both children still
23
- sleep. Each report then starts a new turn on its own (the `Details` line is the child's message):
24
- the first after about 30 seconds, the second after about 45, and the parent folds each into the
25
- README.
28
+ ![A parent spawns two slow children, writes a README itself, and is woken by each report](docs/demo-async-tui.gif)
26
29
 
27
- A child's question reaches you. Prompt: *"Spawn a child session to add a multiply function to
30
+ Watch the parent write its README and finish its turn 16 seconds in, while both children still
31
+ sleep. Each report then starts a new turn on its own (the `Message from ses_…` line is the child's
32
+ message): the first after about 30 seconds, the second after about 45, and the parent folds each
33
+ into the README and cancels the check-in it had scheduled for itself.
34
+
35
+ **A child's question reaches you.** Prompt: *"Spawn a child session to add a multiply function to
28
36
  math.js. It must ask me first whether to name it multiply, times or product."*
29
37
 
30
- ![A child's question appears in the parent, is answered there, and the child carries on](docs/demo-question.gif)
38
+ ![A child's question opens as a form in the parent's session, times is picked, and the child carries on](docs/demo-question-tui.gif)
39
+
40
+ Watch the child's question open in the parent's session as OpenCode's own question form, where
41
+ `times` is picked. The parent passes it back, and the child adds `times` to math.js and reports.
42
+
43
+ <details>
44
+ <summary>The same two runs in OpenCode's web UI</summary>
45
+
46
+ ![The async demo in the web UI: the parent writes its README, ends its turn, and is woken by each report](docs/demo-async.gif)
47
+
48
+ ![The question demo in the web UI: the child's question appears in the parent, is answered there, and the child carries on](docs/demo-question.gif)
31
49
 
32
- Watch the child's question open in the parent's session, where the answer `times` is picked. The
33
- parent passes it back, and the child adds `times` to math.js and reports.
50
+ </details>
34
51
 
35
- > **Status: early.** Passes an end-to-end test inside a live OpenCode V2 server
36
- > (`opencode2 v0.0.0-beta-19271`) driven by a scripted stand-in model (`e2e/run.sh`), and a smoke
37
- > test with real (free) models: see [Real models](#real-models).
52
+ Both recordings are real runs on `opencode2 v0.0.0-beta-19271` with a free model on [OpenCode
53
+ Zen](https://opencode.ai/zen) (Muse Spark 1.3).
38
54
 
39
55
  ## How the wake works
40
56
 
41
57
  There is no polling anywhere. `courier_send` calls the plugin API's `session.synthetic`, which
42
58
  admits a message into the target session's inbox and, unless `resume: false` is passed, calls
43
- `execution.wake` on it (`packages/core/src/session/session.ts` on OpenCode's `beta` branch).
59
+ `execution.wake` on it (`packages/core/src/session/session.ts` on OpenCode's `v2` branch).
44
60
  OpenCode's own background subagents report to their parent the same way
45
61
  (`packages/core/src/session/subagent-completion.ts`).
46
62
 
47
63
  Delivery is `steer` by default (injected into the target's running turn, or starts one if idle);
48
64
  `queue: true` waits until the current turn ends.
49
65
 
50
- ## Tools
66
+ ## Install
51
67
 
52
- | Tool | Does |
68
+ ### Supported OpenCode version
69
+
70
+ Requires OpenCode V2: the `opencode` command from `@opencode/cli` (the same binary is also installed
71
+ as `opencode2`, the name of the beta line, so a shell that still calls that keeps working). Each
72
+ release of this plugin is built and tested against exactly one OpenCode V2 version, the
73
+ `@opencode/plugin` version pinned in `package.json` (the CLI and the plugin API share a version).
74
+ The end-to-end suite runs on that version with every change, and once more on the newest
75
+ `@opencode/cli` release, where a failure is a warning rather than a red build, so a host release
76
+ that breaks the plugin shows up in CI first. A newer OpenCode may still break tools;
77
+ [docs/plugin-api-notes.md](docs/plugin-api-notes.md) lists what the pinned version already needed
78
+ working around, and what changed the last time the pin moved. A release that moves the pin adds a
79
+ row here. When the plugin loads on an OpenCode whose version is not the pinned one, it writes one
80
+ line to the server log naming both versions, so a mismatch is named before a tool fails.
81
+
82
+ | opencode-courier | OpenCode V2 (`opencode` and `@opencode/plugin`) |
53
83
  |---|---|
54
- | `courier_spawn` | Creates a session (optionally in its own git worktree with `isolate: true`) on the parent's model, sends it the task plus a brief naming the parent and how to report back, and returns at once. |
55
- | `courier_send` | Delivers a message to a session, signed with the sender's id, waking it if idle. |
56
- | `courier_status` | One look at a session: outcome, idle time, last reply and the permission requests and questions it waits on. For check-ins, not for waiting. |
57
- | `courier_children` | Lists 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. |
58
- | `courier_cleanup` | Removes 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. |
59
- | `courier_answer` | Passes the person's answer to a permission request or a question that a session started from this one waits on, after the plugin relayed it here: `reply` (`once`, `always` or `reject`, with an optional `message`) for a permission request, `answers` for a question. See [A child that asks for permission](#a-child-that-asks-for-permission) and [A child that asks a question](#a-child-that-asks-a-question). |
60
- | `courier_later` | Schedules 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. |
61
- | `courier_cancel` | Drops a message scheduled with `courier_later`, e.g. because the child it was waiting for reported first. |
62
- | `courier_subscribe` | Subscribes 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](#webhooks). |
63
- | `courier_unsubscribe` | Drops one topic, or all of a session's, e.g. once its pull request is merged. |
64
-
65
- ### The child's model
66
-
67
- A child runs on the model its parent is using, not on OpenCode's default, so a parent you moved to
68
- another model starts children that can reach theirs too. The exception is a child given an `agent`
69
- that names a model of its own: that agent's model is kept. If the parent's model cannot be looked
70
- up, the child is started anyway, on OpenCode's default.
71
-
72
- ### A child that fails
73
-
74
- A child reports with `courier_send`, which it cannot do when its turn fails: the model is not
75
- available to the account, the credentials are missing, the provider is down. The plugin follows
76
- OpenCode's events, and for every `session.execution.failed` of a session on a roster it sends the
77
- parent a message from that child, marked `failed="<error type>"`, with the child's title and the
78
- error, waking the parent if it is idle. The parent then decides: message the child to have it try
79
- again, start a replacement, or carry on without it.
80
-
81
- Every failed turn of a child is reported, also one that fails after the child has reported. A turn
82
- that was interrupted is not a failure and is not reported, and neither is a failure that happens
83
- while the OpenCode server is down or the plugin is not loaded; a `courier_later` check-in still
84
- covers those.
85
-
86
- ### A child that asks for permission
87
-
88
- When a child's tool call needs an approval (a permission rule with `"effect": "ask"`, or no rule
89
- for it), OpenCode holds the call until someone answers in the child's session, which the person
90
- working in the parent's session does not see. The plugin follows `permission.asked`, and for a
91
- session on a roster it sends the parent a message from that child, marked `asks="permission"` and
92
- `request="<id>"`, waking the parent if it is idle. The message says what the child asks for (the
93
- action, such as `shell` or `edit`, and its resources, such as the command or the paths), lists
94
- the choices OpenCode's own prompt offers, and tells the parent to ask the person rather than
95
- decide:
96
-
97
- - `once`: allow this request only;
98
- - `always`: allow it and save the rule for the project; offered only when the request says what
99
- to save, as in OpenCode's prompt;
100
- - `reject`: refuse it, with a reason if the person gives one.
101
-
102
- The parent asks you, with its question tool if it has one, and calls
103
- `courier_answer { sessionID, requestID, reply, message? }` with your choice. The plugin passes it
104
- on with the plugin API's `permission.reply`, and the child carries on.
105
-
106
- A request of a child's child goes to the session at the top, the one you started the first child
107
- from, and so on down any number of levels, since that is where you are; the message names the
108
- session that started the asking one. Only that top session can answer it. A session started with
109
- `courier_spawn` cannot answer what its own children ask, so it cannot get around a rule that makes
110
- it ask by starting a child to do the job and approving it.
111
-
112
- OpenCode ends the child's turn when a request is rejected without a message, and the child would
113
- then never report back. So `courier_answer` always sends a message with a rejection, the person's
114
- reason or a default one; the child's call fails and it carries on, and can report. At
115
- `0.0.0-beta-19271` the child's model is told that the call could not be run, not the reason.
116
-
117
- A request can also be answered without the parent: in the child's own session, or along with
118
- another answer (an `always` that covers it, or a rejection, which rejects the session's other
119
- pending requests as well). For a request the parent was told about, the plugin then sends it a
120
- short message marked `answered="<reply>"` saying the request is settled, so it does not pass on a
121
- stale question; after a rejection, the message adds that the child may have stopped, and that
122
- `courier_send` gets it going again. A `courier_answer` that comes later anyway passes nothing on and
123
- says so.
124
-
125
- `courier_status` and `courier_children` list the requests a session waits on under `pending`, so a
126
- parent that has lost the message, after a compaction for example, can still find them. Whenever
127
- the plugin starts following OpenCode's events, on loading and after its event stream broke, it
128
- also relays the requests that spawned sessions already wait on, so one asked in the gap is not
129
- missed.
130
-
131
- ### A child that asks a question
132
-
133
- A child that calls OpenCode's question tool shows its question in its own session only. The plugin
134
- wraps that tool, and once the question of a session on a roster is on screen, it sends the session
135
- at the top, the one its permission requests go to, a message from that child marked
136
- `asks="question"` and `request="question_<id>"`, waking it if it is idle. The message lists the
137
- questions and their options, gives them again as one line of JSON in the question tool's own input
138
- shape, and tells the parent to ask the person, with its question tool and exactly those questions,
139
- rather than answer.
140
-
141
- When the parent asks you the same questions with the same options, the two are linked: what you
142
- pick in the parent's session is passed to the child's waiting call, which returns it as if you had
143
- answered there, and the child carries on in the same turn. The parent's tool result says so. Only
144
- the same wording links (spacing and case aside; the options may come in any order), so a question
145
- of the parent's own with the same yes-or-no choices never answers a child's; when a waiting question has the same choices, the
146
- parent's tool result says the answers were not passed on and names the request. A parent that asked you some
147
- other way, in text or reworded, calls
148
- `courier_answer { sessionID, requestID, answers }`: one entry per question, in order, each the
149
- label you chose or the text you gave, or a list of labels where a question allows several. As with
150
- permission requests, only the session at the top can answer. Answers to one question are passed on
151
- one at a time, and once: a second waits for the first, and is told the question no longer waits once
152
- the first got through. A first that has not got through after 30 seconds gives way, and the second
153
- goes on; should the first still get through, the child is told twice.
154
-
155
- The question stays in the child's session too, and you can answer it there instead. Whichever
156
- answer comes first counts, and the other side's question is withdrawn:
157
-
158
- - answered or dismissed in the child's session: the parent's open question disappears and its
159
- tool result says why, or, with none open, the parent gets a short message marked
160
- `answered="elsewhere"` or `answered="dismissed"` (or `answered="failed"`, when the child's
161
- question call failed, which ends its turn too). A dismissal there ends the child's turn, as
162
- OpenCode's question tool does, and the message says to `courier_send` it if it should carry on;
163
- - answered in the parent's session: the child's question disappears;
164
- - dismissed in the parent's session: the parent's turn ends, as OpenCode's tool does, and the
165
- child's call returns that you dismissed the question, so the child carries on without the answer
166
- and can report.
167
-
168
- A question whose call is cut off stays answerable. When the child's turn is stopped (interrupted,
169
- or by OpenCode itself, which stops every turn in a project location after 60 minutes without
170
- activity there), or the server restarts or closes the project, the question is gone from the
171
- screen. The plugin keeps it in its storage, sends the parent a message marked `stopped="true"`, or
172
- `restarted="true"` when the plugin is next loaded (or half a minute later, when
173
- OpenCode closed only that project, keeps running, and has the plugin loaded for another), with the
174
- questions, and passes
175
- the answer on as a message to the child, which wakes it. If the parent is asking you at that
176
- moment, your answer goes that way without a new message. When the plugin loads, stored questions
177
- are dropped after 14 days, once the child is off its parent's roster, or beyond the 100 newest.
178
-
179
- `courier_status` and `courier_children` list a session's questions under `pending`, with
180
- `type: "question"`, the questions, and `stopped: true` for one that was cut off. A question whose
181
- message to the parent could not be sent, or whose record could not be stored, is listed there too,
182
- and can be answered all the same.
183
-
184
- A question is relayed only once OpenCode's permission check for it has passed: a child whose agent
185
- may not ask questions (OpenCode's `general` agent, or a `question` rule with `"effect": "deny"`)
186
- is refused as before, and the parent hears nothing. The plugin learns that a question is on screen
187
- from OpenCode's events; when no instance of it followed them for a while and one does again, it
188
- relays every question still waiting for that, since one shown meanwhile was not seen. The child brief tells children to use the
189
- question tool when the person must decide; a child can still send its question with
190
- `courier_send` instead, and the parent then passes your answer back with `courier_send`.
191
-
192
- ### Roster
193
-
194
- `courier_spawn` records each child under its parent in the plugin's storage, so a parent that has
195
- lost track after a compaction or a server restart can call `courier_children` to find them again.
196
- A child that can no longer be looked up is still listed, with the error instead of its state.
197
- Entries are dropped 14 days after the child was started, when that parent's roster is read or
198
- the plugin is next loaded, except isolated children whose worktree is still there (see
199
- [Worktree cleanup](#worktree-cleanup)). If the roster cannot be written, the child still gets its task and
200
- `courier_spawn` says it is not on the list.
201
-
202
- ### Worktree cleanup
203
-
204
- An isolated child works in a git worktree under OpenCode's data directory
205
- (`…/opencode/worktree/<project>/<name>`, on a detached HEAD), and nothing removes it on its own.
206
- When the parent has what it needs from the child, it calls `courier_cleanup { sessionID }`, which
207
- removes the worktree through the plugin API's `worktree.remove` and drops the child from
208
- `courier_children`.
209
-
210
- The worktree is kept, and the result says why, when it holds work that would otherwise be lost:
211
-
212
- - uncommitted changes, untracked files included (ignored files, such as `node_modules`, are not
213
- work and go with the worktree);
214
- - commits that are on no branch, tag or remote-tracking ref, which is where a child's commits on
215
- its detached HEAD end up. A commit on a branch survives the removal, so it does not count, and
216
- neither do commits the worktree was made from (`courier_spawn` records that commit), such as a
217
- parent's own unbranched work when an isolated child spawns isolated children of its own.
218
-
219
- The result lists up to 50 changed paths (an untracked directory counts once) and 50 commits. Commit
220
- or branch what you want to keep (`git -C <worktree> branch <name>` keeps its commits), or call
221
- `courier_cleanup` again with `force: true` to discard it; `force` also removes a worktree git can
222
- no longer read. A worktree whose directory is already gone is just dropped from the list; git
223
- forgets its registration on its next `git worktree prune` or `git gc`.
224
-
225
- Cleanup is explicit only. A child reporting back does not mean the parent has merged, reviewed or
226
- even read its work, and the parent may still send it more to do in the same worktree, so the
227
- plugin never removes one on its own. Isolated children whose worktree still exists are kept on
228
- `courier_children` past the 14 days, so they can still be found and cleaned up.
229
-
230
- `courier_cleanup` cannot tell whether the child is still running, so call it after the child has
231
- reported. It works on the calling session's own children.
232
-
233
- ### Scheduled messages
234
-
235
- Pending `courier_later` messages are kept in the plugin's storage, and every loaded copy of the
236
- plugin checks for due ones every 15 seconds, so a message can arrive up to about 15 seconds late.
237
- OpenCode loads the plugin once per project location; the copies share one claim set, so each
238
- message is delivered once.
239
-
240
- They survive a server restart. After a start, OpenCode loads plugins for a project the first time
241
- that project is used, so messages that fell due while it was down are delivered then, not at the
242
- moment the server comes back. A crash between delivering a message and forgetting it can deliver
243
- it twice after the restart; a lost check-in would be worse.
244
-
245
- ### Webhooks
246
-
247
- With the `webhook` option set (see [Receiving webhooks](#receiving-webhooks)), the plugin listens
248
- for HTTP deliveries and turns them into messages for subscribed sessions:
249
-
250
- - `POST /github` takes GitHub webhook deliveries. A pull request review, a review comment, a
251
- comment, a pull request or issue being opened, reopened, closed (or merged) or marked ready for
252
- review, or a completed check run, check suite or workflow run on a pull request goes to the
253
- sessions subscribed to `owner/repo#N` and to `owner/repo`; anything else with a repository (a
254
- push, a release) goes to `owner/repo` only. Pings, CI runs that have not completed, and other
255
- pull request and issue actions (pushes to the branch, edits, labels, assignments, review
256
- requests) wake nobody.
257
- - `POST /hook/<name>` takes anything else, for sessions subscribed to `<name>`. A JSON body's
258
- `text`, `summary` or `message` field is delivered, otherwise the body itself.
259
-
260
- Every delivery must carry an `X-Hub-Signature-256` header: `sha256=` followed by exactly 64 hex
261
- digits, the HMAC-SHA256 under the shared secret. For GitHub that is of the raw body, as GitHub sends it. For
262
- `/hook/<name>` it is of the name, a newline and the body, so a captured delivery cannot be sent to
263
- another topic:
84
+ | 0.2.0 | 2.0.22 |
85
+ | 0.1.6 | 0.0.0-beta-19271 (the beta line: `opencode2` from `@opencode-ai/cli`, and `@opencode-ai/plugin`) |
86
+
87
+ 0.2.0 was tested on 2.0.22 and on the `dev` build 0.0.0-dev-20534 of 2026-10-04, the newest build
88
+ then (no 2.x release above 2.0.22 existed), where the suite passed too. Of the older hosts tried, it
89
+ loads on 2.0.4 and 2.0.21 (nothing in between was run, and the suite was not), and fails to load on
90
+ 2.0.0 and 2.0.3, which lack the `model` domain the plugin API gained in 2.0.4. The version in
91
+ `package.json` protects nobody on its own: OpenCode says nothing about it, since
92
+ `opencode plugin add` installs the plugin whatever your OpenCode version and its loader warns about
93
+ nothing either (a recorded experiment, in
94
+ [docs/plugin-api-notes.md](docs/plugin-api-notes.md#what-plugin-add-and-loading-do-with-the-peer-dependency-2026-10-04),
95
+ which also says why the peer dependency stays exact rather than a range: it picks the copy of the
96
+ plugin API the plugin runs on). The plugin's own log line above is the only runtime signal, apart
97
+ from the load failure on those hosts before 2.0.4. Check yours with `opencode --version`, and
98
+ install the matching CLI with:
264
99
 
265
100
  ```bash
266
- sig=$(printf '%s\n%s' deploys "$body" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)
267
- curl -X POST -H "x-hub-signature-256: sha256=$sig" --data-binary "$body" http://127.0.0.1:4097/hook/deploys
101
+ npm install -g @opencode/cli@2.0.22
268
102
  ```
269
103
 
270
- A missing or wrong signature gets `401`, and the body is not parsed. The check is constant-time.
271
- Bodies over 1 MiB (`maxBytes`) get `413`. A delivered event gets `202`, with the number of
272
- sessions it reached, which can be 0. The digests of the last 1000 accepted deliveries are remembered in
273
- memory (as lowercase hex, so re-casing the header does not get around it), and a delivery already
274
- accepted gets `200 already delivered`. One that reached nobody because every delivery to a session
275
- failed is forgotten again, so it can be retried. That stops replays of
276
- a captured delivery, and it also means a GitHub Redeliver of a delivery that already arrived is
277
- ignored. Redelivering one that failed works. Generic senders that post the same text twice should
278
- add something unique, such as a timestamp, to the body.
279
-
280
- A session that OpenCode no longer knows loses its subscriptions the next time a delivery for it
281
- fails, and `courier_subscribe` refuses a session id that does not exist.
282
-
283
- A session sees a short summary (event, repository and number, who, state or conclusion, link, and
284
- at most 1500 characters of a review or comment body), wrapped in `<courier from="github"
285
- event="...">` and followed by a note that it is outside text, to be treated as data. Review and
286
- comment bodies are written by whoever can comment on the repository, so subscribe sessions only to
287
- repositories whose commenters you trust with your agent's attention. The server log gets one line
288
- per delivery (event, delivery id, number of sessions), never the payload or the secret.
289
-
290
- GitHub does not report check suites on pull requests from forks (`pull_requests` is empty), so CI
291
- results for those reach `owner/repo` subscribers only. There is no GitHub event for a merge
292
- conflict.
293
-
294
- ## Install
295
-
296
- Requires OpenCode V2, command `opencode2`. Its plugin API is still beta, and each release is built
297
- and tested against one version of it: the `@opencode-ai/plugin` peer dependency in `package.json`.
298
- The CLI of that version is the one known to work:
299
-
300
- ```bash
301
- npm install -g @opencode-ai/cli@0.0.0-beta-19271
302
- ```
104
+ ### The plugin
303
105
 
304
106
  Then install the plugin:
305
107
 
306
108
  ```bash
307
- opencode2 plugin add opencode-courier
109
+ opencode plugin add opencode-courier
308
110
  ```
309
111
 
310
112
  This installs the package from npm and adds `"opencode-courier"` to `plugins` in the global
311
113
  configuration (`~/.config/opencode/opencode.json`). To receive webhooks, replace that entry with the
312
- object form shown below, which carries a `webhook` option.
114
+ object form shown under [Webhooks](#webhooks), which carries a `webhook` option.
313
115
 
314
116
  ### From a local clone
315
117
 
@@ -327,7 +129,66 @@ Then list it in `opencode.json` (V2 uses `plugins`, plural). A local plugin path
327
129
  }
328
130
  ```
329
131
 
330
- ## Receiving webhooks
132
+ ## Using it
133
+
134
+ 1. Keep the background server running so sessions can be woken while you are away (`opencode
135
+ service start`; `opencode service status` to check).
136
+ 2. Give the agents that run children the permissions their work needs. A child that hits an approval
137
+ prompt has it passed to its parent, which asks you (see [A child that asks for
138
+ permission](docs/reference.md#a-child-that-asks-for-permission)), but the child waits until you
139
+ answer, so keep prompts for what you want to decide yourself. A child's question goes to the
140
+ parent the same way (see [A child that asks a
141
+ question](docs/reference.md#a-child-that-asks-a-question)), so let the agents that run children
142
+ use the `question` tool if they should be able to ask you; OpenCode's default agent may.
143
+ 3. Use `isolate: true` whenever children edit files in parallel. The child's worktree is made from
144
+ the last commit, so an uncommitted `opencode.json` is not there and the child falls back to your
145
+ global config: keep providers and models in the global config, or commit the file. When you are
146
+ done with an isolated child, `courier_cleanup` it so its worktree does not linger.
147
+ 4. A child that crashes before calling `courier_send` never wakes the parent. When you spawn a
148
+ long-running child, also `courier_later` a check-in for yourself, and `courier_cancel` it when
149
+ the child reports.
150
+
151
+ ## Tools
152
+
153
+ | Tool | Does |
154
+ |---|---|
155
+ | `courier_spawn` | Creates a session (optionally in its own git worktree with `isolate: true`) on the parent's model, sends it the task plus a brief naming the parent and how to report back, and returns at once. |
156
+ | `courier_send` | Delivers a message to a session, signed with the sender's id, waking it if idle. |
157
+ | `courier_status` | One look at a session: outcome, idle time, last reply and the permission requests and questions it waits on. For check-ins, not for waiting. |
158
+ | `courier_children` | Lists 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. |
159
+ | `courier_cleanup` | Removes 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. |
160
+ | `courier_answer` | Passes the person's answer to a permission request or a question that a session started from this one waits on, after the plugin relayed it here: `reply` (`once`, `always` or `reject`, with an optional `message`) for a permission request, `answers` for a question. See [A child that asks for permission](docs/reference.md#a-child-that-asks-for-permission) and [A child that asks a question](docs/reference.md#a-child-that-asks-a-question). |
161
+ | `courier_later` | Schedules 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. |
162
+ | `courier_cancel` | Drops a message scheduled with `courier_later`, e.g. because the child it was waiting for reported first. |
163
+ | `courier_subscribe` | Subscribes 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](#webhooks). |
164
+ | `courier_unsubscribe` | Drops one topic, or all of a session's, e.g. once its pull request is merged. |
165
+
166
+ ## How children reach you
167
+
168
+ The short version; the long one, with every edge, is [docs/reference.md](docs/reference.md).
169
+
170
+ - **A child runs on its parent's model**, not on OpenCode's default, so a parent you moved to
171
+ another model starts children that can reach theirs too. An `agent` with a model of its own keeps
172
+ it. [More](docs/reference.md#the-childs-model).
173
+ - **A child that fails** cannot report, so the plugin does: every failed turn of a spawned session
174
+ sends its parent a message marked `failed="<error type>"`, with the error, waking it if idle.
175
+ [More](docs/reference.md#a-child-that-fails).
176
+ - **A child that asks for permission** has the request passed to the session at the top, with what
177
+ it asks for and the choices OpenCode offers (`once`, `always`, `reject`). That session asks you
178
+ and answers with `courier_answer`; the child carries on.
179
+ [More](docs/reference.md#a-child-that-asks-for-permission).
180
+ - **A child that asks a question** with OpenCode's question tool has it shown in the top session.
181
+ When that session asks you the same question, your answer goes to the child's waiting call as if
182
+ you had answered there; a question stays answerable across an interrupted turn or a server
183
+ restart. [More](docs/reference.md#a-child-that-asks-a-question).
184
+ - **The plugin remembers.** Each parent's children (`courier_children`), pending `courier_later`
185
+ messages and open questions survive a compaction or a restart; entries are dropped after 14 days.
186
+ [Roster](docs/reference.md#roster), [Scheduled messages](docs/reference.md#scheduled-messages).
187
+ - **Worktrees are yours to remove.** An isolated child's worktree is kept until `courier_cleanup`,
188
+ which refuses to drop uncommitted changes or unbranched commits unless told to.
189
+ [More](docs/reference.md#worktree-cleanup).
190
+
191
+ ## Webhooks
331
192
 
332
193
  The receiver is off unless the plugin has a `webhook` option. Put it in the **global** config
333
194
  (`~/.config/opencode/opencode.json`), since there is one receiver per OpenCode server:
@@ -343,9 +204,9 @@ The receiver is off unless the plugin has a `webhook` option. Put it in the **gl
343
204
  }
344
205
  ```
345
206
 
346
- From a local clone, `package` is the path to its `dist` directory instead. `"webhook": true`
347
- takes every default. If the option is given more than once, for example in a project's config as
348
- well, the first location to load wins, and the others log that their settings are ignored.
207
+ From a local clone, `package` is the path to its `dist` directory instead. `"webhook": true` takes
208
+ every default. If the option is given more than once, for example in a project's config as well, the
209
+ first location to load wins, and the others log that their settings are ignored.
349
210
 
350
211
  | Option | Default | |
351
212
  |---|---|---|
@@ -355,79 +216,37 @@ well, the first location to load wins, and the others log that their settings ar
355
216
  | `secretEnv` | `COURIER_WEBHOOK_SECRET` | Environment variable holding it, when there is no `secretFile`. |
356
217
  | `maxBytes` | `1048576` | Largest body accepted. |
357
218
 
358
- The secret is never read from `opencode.json` itself (a `secret` key is refused), so the config
359
- can be committed. Make one with `openssl rand -hex 32 > ~/.config/opencode/courier-webhook-secret`
360
- and `chmod 600` it. A file is the safer choice with `opencode2 service start`, whose environment
361
- may not be your shell's. Without a usable secret the receiver does not start, and the server log
362
- says why.
219
+ The secret is never read from `opencode.json` itself (a `secret` key is refused), so the config can
220
+ be committed. Make one with `openssl rand -hex 32 > ~/.config/opencode/courier-webhook-secret` and
221
+ `chmod 600` it. A file is the safer choice with `opencode service start`, whose environment may not
222
+ be your shell's. Without a usable secret the receiver does not start, and the server log says why.
363
223
 
364
224
  On GitHub, add a webhook to the repository (Settings → Webhooks) with content type
365
- `application/json`, the same secret, and the events you want (pull request reviews, review
366
- comments, issue comments, pull requests, check suites or workflow runs). GitHub must reach the
367
- receiver, and by default it only listens on `127.0.0.1`: forward a public URL to it with a tunnel
368
- you trust (`cloudflared tunnel --url http://127.0.0.1:4097`, `ngrok http 4097`, or
369
- `smee --url https://smee.io/<channel> --target http://127.0.0.1:4097/github`, which needs no
370
- inbound port at all) and use `<public URL>/github` as the payload URL. Whatever you expose, only
371
- signed deliveries are acted on.
225
+ `application/json`, the same secret, and the events you want (pull request reviews, review comments,
226
+ issue comments, pull requests, check suites or workflow runs). GitHub must reach the receiver, and
227
+ by default it only listens on `127.0.0.1`: forward a public URL to it with a tunnel you trust
228
+ (`cloudflared tunnel --url http://127.0.0.1:4097`, `ngrok http 4097`, or `smee --url
229
+ https://smee.io/<channel> --target http://127.0.0.1:4097/github`, which needs no inbound port at
230
+ all) and use `<public URL>/github` as the payload URL. Whatever you expose, only signed deliveries
231
+ are acted on.
372
232
 
373
233
  The receiver starts when OpenCode loads the plugin, which after a server start happens the first
374
- time a project is used. Until then deliveries fail; GitHub does not retry them on its own, but
375
- lists them under Recent Deliveries with a Redeliver button.
376
-
377
- ## Using it
378
-
379
- 1. Keep the background server running so sessions can be woken while you are away
380
- (`opencode2 service start`; `opencode2 service status` to check).
381
- 2. Give the agents that run children the permissions their work needs. A child that hits an
382
- approval prompt has it passed to its parent, which asks you (see
383
- [A child that asks for permission](#a-child-that-asks-for-permission)), but the child waits
384
- until you answer, so keep prompts for what you want to decide yourself. A child's question goes
385
- to the parent the same way (see [A child that asks a question](#a-child-that-asks-a-question)),
386
- so let the agents that run children use the `question` tool if they should be able to ask you;
387
- OpenCode's default agent may.
388
- 3. Use `isolate: true` whenever children edit files in parallel. The child's worktree is made
389
- from the last commit, so an uncommitted `opencode.json` is not there and the child falls back
390
- to your global config: keep providers and models in the global config, or commit the file.
391
- When you are done with an isolated child, `courier_cleanup` it so its worktree does not linger.
392
- 4. A child that crashes before calling `courier_send` never wakes the parent. When you spawn a
393
- long-running child, also `courier_later` a check-in for yourself, and `courier_cancel` it when
394
- the child reports.
395
-
396
- ## Real models
397
-
398
- `e2e/real-model.sh` runs the same live server with a real model and asks the parent to fan a small
399
- task out to two children. It reports which tools the parent called and in what order, whether it
400
- ended its turn instead of polling, whether each child called `courier_send`, and whether each report
401
- woke the idle parent. By default it uses a free model on OpenCode Zen, which needs no key:
402
-
403
- ```bash
404
- OPENCODE_BIN=$(which opencode2) e2e/real-model.sh
405
- COURIER_MODEL=muse-spark-1.3-contributor-free OPENCODE_BIN=$(which opencode2) e2e/real-model.sh
406
- COURIER_SCENARIO=permission OPENCODE_BIN=$(which opencode2) e2e/real-model.sh
407
- COURIER_SCENARIO=question OPENCODE_BIN=$(which opencode2) e2e/real-model.sh
408
- ```
409
-
410
- With `COURIER_SCENARIO=permission` it runs the permission relay instead: one child whose command
411
- needs an approval, and the script in the person's place. It checks that the parent asks rather
412
- than answering by itself, answers its question with `once`, and checks that the parent passes that
413
- on with `courier_answer` and the child runs its command and reports.
414
-
415
- With `COURIER_SCENARIO=question` it runs the question relay: one child whose task is to find out
416
- from the person which of three greetings to use, without being told how to ask. The script plays
417
- the person in the parent's session. It checks that the child asks with its question tool (or
418
- records that it sent its question with `courier_send` instead), that the parent asks the person
419
- rather than answering by itself, with the child's options, and that the person's answer reaches the
420
- child, which reports it.
234
+ time a project is used. Until then deliveries fail; GitHub does not retry them on its own, but lists
235
+ them under Recent Deliveries with a Redeliver button.
421
236
 
422
- On `opencode2 v0.0.0-beta-19271`, `longcat-2.5-preview-free`, `muse-spark-1.3-contributor-free`
423
- and `nemotron-3-ultra-free` complete the fan-out with both reports waking the parent, after the
424
- tool results were tuned to say plainly that the parent should end its turn. How to run it, what
425
- each model did, what was tuned and why: [docs/real-model.md](docs/real-model.md). It costs nothing
426
- on the free models, and it is not part of CI.
237
+ Once it runs, `courier_subscribe` ties a session to a topic and every matching delivery arrives as a
238
+ message, queued behind any running turn and waking the session if idle:
427
239
 
428
- ## Roadmap
240
+ - `POST /github` takes GitHub webhook deliveries: reviews, comments, pull requests and issues being
241
+ opened, closed or merged, and completed CI runs reach the sessions subscribed to `owner/repo#N`
242
+ and `owner/repo`; other events with a repository reach `owner/repo`.
243
+ - `POST /hook/<name>` takes anything else, for sessions subscribed to `<name>`: a JSON body's
244
+ `text`, `summary` or `message` field, otherwise the body itself.
429
245
 
430
- Tracked as [issues](https://github.com/ivopogace/opencode-courier/issues).
246
+ Every delivery must be signed (`X-Hub-Signature-256`, HMAC-SHA256 under the shared secret), replays
247
+ are refused, and what a session sees is a short summary marked as outside text. Which events wake
248
+ whom, the signing scheme for `/hook/<name>`, limits and status codes: [the
249
+ reference](docs/reference.md#webhooks).
431
250
 
432
251
  ## Development
433
252
 
@@ -436,241 +255,38 @@ bun install
436
255
  bun test # unit tests, with a fake plugin context
437
256
  npm run typecheck
438
257
  npm run build # emits dist/
439
- OPENCODE_BIN=$(which opencode2) npm run test:e2e # live test, see below
440
- OPENCODE_BIN=$(which opencode2) e2e/real-model.sh # with a real model, see Real models
258
+ OPENCODE_BIN=$(which opencode) npm run test:e2e # live test, about two minutes, no API key
259
+ OPENCODE_BIN=$(which opencode) e2e/real-model.sh # smoke test with a real model, not in CI
441
260
  ```
442
261
 
443
262
  `e2e/run.sh` starts a real OpenCode V2 server in a throwaway project and home directory, with this
444
- plugin loaded and `e2e/mock-model.mjs` as the model: an OpenAI-compatible server that replies from
445
- a fixed script, so no API key is needed. It checks that a parent's spawn completes, that the parent
446
- gets a new turn after its own has ended once the child reports (shared and `isolate: true`), that
447
- `courier_status` reports and fails readably, that a child runs on the model its parent was started
448
- with rather than the default one, that a child whose model request is refused is reported to its
449
- idle parent, once and with the error, that a child's permission request (a project rule makes it
450
- ask before one command) wakes its idle parent once, with what it asks for and the choices, that
451
- `courier_status` shows it pending and `courier_answer` passes the answer back, `once` to a shared
452
- child and `reject` to an isolated one, after which the child carries on and reports, that a request
453
- answered in the child's own session gets the parent a message that it is settled and a later
454
- `courier_answer` passes nothing on, that a child's question (the stand-in model calls OpenCode's
455
- question tool) wakes its idle parent once, with the questions, which the parent asks the person in
456
- its own session, that `courier_status` shows it pending, and that the person's answer there reaches
457
- the child: single choice, multi-select, a typed answer (from an isolated child), and through
458
- `courier_answer` from a parent that relabelled the options or reworded the question, which is then
459
- told its answers were not passed on; that answering in the child's session
460
- withdraws the parent's question, that a dismissal on either side is passed to the other, that a
461
- question whose child's turn was interrupted, or both turns, or that was open across a server
462
- restart, still reaches the child with the answer, as a message, and that a question of a child's
463
- child goes to the session at the top; that a `courier_later` message wakes an idle parent
464
- (with its delay sent as a string, as some models send it),
465
- that a cancelled one never arrives, that a pending one is delivered after a server restart, that
466
- `courier_children` lists the two children a parent spawned, before and after that restart, that
467
- a recorded GitHub review delivery (`e2e/fixtures/pull_request_review.json`), signed, wakes an idle
468
- session subscribed with `courier_subscribe`, once, while unsigned and wrongly signed ones are
469
- refused, and that `courier_cleanup` removes an isolated child's clean worktree but keeps one with an
470
- uncommitted file until asked with `force`. Last, it packs the package with `npm pack`, serves the
471
- tarball from a stand-in registry (`e2e/registry.mjs`), installs it with `opencode2 plugin add
472
- opencode-courier` and checks that its tools load from the installed copy. It takes about two
473
- minutes and needs node, npm, bun, git, curl, jq and openssl.
474
-
475
- CI (`.github/workflows/ci.yml`) runs both on every push to `main` and every pull request, with the
476
- OpenCode CLI at the same version as the pinned plugin API.
477
-
478
- ### Releasing
479
-
480
- A release starts from GitHub and ends with a maintainer's 2FA approval on npm; no tag needs
481
- creating or pushing by hand. Three workflows under `.github/workflows/` take turns:
482
-
483
- 1. **Start a release** (Actions → Start a release → Run workflow, from `main`, or
484
- `gh workflow run release-start.yml -f version=patch`). `version` is `patch`, `minor`, `major`
485
- or an exact version such as `0.2.0-beta.1`. The workflow bumps `version` in `package.json`,
486
- checks that the current version is tagged, that the new one is higher and that neither a
487
- `vX.Y.Z` tag, a `release-X.Y.Z` branch nor npm has it, then pushes the branch `release-X.Y.Z`
488
- and opens the pull request "Release X.Y.Z".
489
- 2. GitHub holds the checks of a pull request that GitHub Actions opened: select **Approve
490
- workflows to run** in its merge box, review it, and squash-merge it like any other pull
491
- request (`main` takes nothing else).
492
- 3. **Tag the release** runs on every push to `main` that touches `package.json`. When the version
493
- there has no tag yet, it tags the commit `vX.Y.Z` and dispatches **Release** on that tag. A
494
- version whose tag exists is left alone, so a push that does not bump the version does nothing.
495
- 4. **Release** (`release.yml`), on the tag, runs the CI workflow, checks that the tag matches the
496
- `version` in `package.json`, builds, and runs `npm stage publish` from the `npm` environment,
497
- authenticated as the repository variable `NPM_AUTH` says: with the `NPM_TOKEN` secret
498
- (`token`, the default) or through npm trusted publishing (`oidc`), both below. It adds a
499
- provenance attestation, which links the package to the workflow run that built it, unless
500
- `NPM_PROVENANCE` turns it off. A prerelease (`0.2.0-beta.1`) is staged for the `next`
501
- dist-tag.
502
- 5. It then creates a **draft** GitHub release with generated notes (marked as a prerelease for
503
- one), so nothing is announced yet.
504
- 6. A maintainer reviews the staged version and approves it with 2FA: on npmjs.com under Staged
505
- Packages, or with `npm stage list` and `npm stage approve <id>`. The version is live from then.
506
- 7. Publish the draft release: `gh release edit vX.Y.Z --draft=false`, or Publish release on
507
- GitHub.
508
-
509
- Why the tag is dispatched rather than pushed into `on: push: tags`: Tag the release pushes it with
510
- the workflow's `GITHUB_TOKEN`, and GitHub starts no workflow for an event made with that token,
511
- `workflow_dispatch` excepted. The dispatch names the tag as its ref, so `GITHUB_REF` is the tag:
512
- that is what the `npm` environment's `v*` rule and Release's tag check look at, and the rule stays
513
- as it is. A tag pushed by hand still triggers Release as before.
514
-
515
- **Dry runs**, to exercise the flow without releasing. Start a release with `dry_run` computes the
516
- bump and shows the diff, pushing nothing. Tag the release with `dry_run` (the default when run by
517
- hand, and it runs on any branch) checks the version and the tag, builds and runs
518
- `npm stage publish --dry-run`, which also refuses a version npm already has; so run it on the
519
- release branch to rehearse a release: `gh workflow run release-tag.yml --ref release-X.Y.Z`.
520
- Release itself takes a `dry_run` on a tag cut with this flow (v0.1.2 onward; earlier tags carry a
521
- `release.yml` without the dispatch trigger), `gh workflow run release.yml --ref vX.Y.Z
522
- -f dry_run=true`: the environment admits the tag, the token is checked in token mode (a missing
523
- one is a warning there), the package is packed and nothing is staged or released.
524
-
525
- The flow needs the repository setting **Allow GitHub Actions to create and approve pull requests**
526
- (Settings → Actions → General → Workflow permissions); without it Start a release stops at opening
527
- the pull request. Should a release pull request show neither checks nor the approval banner, close
528
- and reopen it: that is an event a person made, and CI runs on it as on any pull request.
529
-
530
- **By hand**, should a step fail. The bump is an ordinary pull request that changes `version` in
531
- `package.json`; once it is merged, Tag the release tags it. If that run failed, tag the merge
532
- commit yourself and the tag push runs Release as it always did:
533
-
534
- ```bash
535
- git fetch origin && git tag -a vX.Y.Z -m vX.Y.Z origin/main && git push origin vX.Y.Z
536
- ```
537
-
538
- If the tag exists but Release did not start: `gh workflow run release.yml --ref vX.Y.Z`. If staging
539
- fails, nothing reached npm and the version is still free. Re-running the job reuses the workflow
540
- file at the tag, so after fixing `release.yml` move the tag to the fixed commit instead:
541
- `git push origin :refs/tags/vX.Y.Z`, then tag and push again (or re-run Tag the release on
542
- `main` with `dry_run` off).
543
-
544
- Repository variables are set under Settings → Secrets and variables → Actions → Variables, and a
545
- re-run of the job picks a change up without moving the tag.
546
-
547
- **The token** (`NPM_AUTH` unset or `token`). `NPM_TOKEN` is a secret of the `npm` environment
548
- (repository Settings → Environments → `npm` → Environment secrets). It holds an npm granular
549
- access token, made on npmjs.com under Access Tokens → Generate New Token → Granular Access Token,
550
- with:
551
-
552
- - Packages and scopes: Read and write, for `opencode-courier` only;
553
- - **Bypass two-factor authentication left off.** Such a token can stage a version but not publish
554
- one, so nothing goes live without a maintainer's 2FA approval, even if the token leaks;
555
- - an expiry date. npm caps how long a token with write access lives; when it has expired or been
556
- revoked, the job stops at "Check the npm token", and a new token replaces the secret.
557
-
558
- Limit the `npm` environment to version tags: under its Deployment branches and tags, choose
559
- Selected branches and tags and add the tag pattern `v*`. Otherwise a workflow on any branch that
560
- names the environment can read the secret. A tag ruleset on `v*` closes the remaining gap, with
561
- two things to know: Tag the release creates the tags with the workflow's token, so a rule that
562
- restricts creation needs the GitHub Actions app in the ruleset's bypass list or the tag push fails;
563
- and a rule against deletion also blocks the move-the-tag recovery above for anyone not in that
564
- list.
565
-
566
- If "Check the npm token" passes but staging fails with E403, look at the package's Publishing
567
- access on npmjs.com (the package's Settings): the option that disallows tokens refuses this one
568
- too.
569
-
570
- The job stages with `--provenance`, signed through GitHub's OIDC token and Sigstore. Should npm
571
- refuse the attestation, set the repository variable `NPM_PROVENANCE` to `false` and re-run the job,
572
- which then stages without one; any value other than `true` or `false` (in any case) stops the job.
573
-
574
- **Trusted publishing** (`NPM_AUTH` set to `oidc`) needs no stored token, but does not work for this
575
- repository yet: npm rejects the immutable OIDC subject claims GitHub issues for repositories
576
- created after 2026-07-15 ([npm/cli#9969](https://github.com/npm/cli/issues/9969)), with `OIDC token
577
- exchange error - package not found`. Once npm fixes that:
578
-
579
- 1. On npmjs.com, give the package a trusted publisher: this repository, workflow `release.yml`,
580
- environment `npm`, allowed to stage only.
581
- 2. Set the repository variable `NPM_AUTH` to `oidc` and release. In this mode the job does not pass
582
- `NPM_TOKEN`, so a failed exchange cannot fall back to it: npm falls back to the placeholder
583
- token setup-node configures and the job fails with E401. Before staging it logs the claims of
584
- its OIDC token (repository, workflow, environment, ref), so a mismatch with the trusted
585
- publisher shows, and it stages with `--loglevel verbose`, because npm reports a failed exchange
586
- only there. Trusted publishing adds the provenance attestation itself.
587
- 3. Once a release has been staged that way, revoke the token on npmjs.com and delete the
588
- `NPM_TOKEN` secret. To go back, set `NPM_AUTH` to `token` or delete the variable.
589
-
590
- To stage by hand instead (npm 11.15.0 or later, Node 22.14 or later):
591
-
592
- ```bash
593
- git checkout vX.Y.Z
594
- npm install && npm run build
595
- npm login
596
- npm stage publish --access public # add --tag next for a prerelease
597
- ```
598
-
599
- Approve the staged version with 2FA as above, then create the release:
600
- `gh release create vX.Y.Z --verify-tag --generate-notes` (add `--prerelease` for a prerelease). A
601
- version staged by hand has no provenance attestation.
602
-
603
- A prerelease version (`1.2.0-beta.1`) is staged for the `next` dist-tag and its release is marked
604
- as a prerelease. Nothing moves `next` after a stable release, so it can point at an older version
605
- than `latest`; that only matters to someone installing `opencode-courier@next`, and
606
- `npm dist-tag rm opencode-courier next` removes the tag until the next prerelease sets it again.
607
-
608
- CI also checks the package as published: `publint` for `package.json` and `exports`, and
609
- `@arethetypeswrong/cli` for the type declarations.
610
-
611
- The plugin API is still beta and pinned to an exact version in `package.json`; bump it
612
- deliberately and re-run both test suites.
613
-
614
- ### Notes on the V2 plugin API
615
-
616
- Found while testing against `0.0.0-beta-19271`:
617
-
618
- - A plugin tool is only reachable through code mode's `execute` tool unless it is registered with
619
- `options: { codemode: false }`. The courier tools are direct tools.
620
- - A tool whose result `metadata` holds an `undefined` value never completes: the call stays
621
- `running` and no error is reported. Results here drop `undefined` keys.
622
- - A plugin cannot add an HTTP route to OpenCode's own server. The nearest thing, `rpc.register`,
623
- is reached through the authenticated `/api/rpc` endpoint with a JSON envelope, so neither
624
- GitHub's headers nor the raw body its signature covers would get through. The webhook receiver
625
- is therefore its own small listener inside the OpenCode process, shared by the plugin's
626
- per-location instances. It waits for the previous listener to finish closing before it binds,
627
- 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
628
- `plugins`, which takes a local directory as `package` too.
629
- - OpenCode errors such as `Session.NotFoundError` can arrive with an empty message, so the tools
630
- rethrow them with the tag and session id.
631
- - OpenCode decodes a tool's input with its own copy of `effect`, not the plugin's, and schema
632
- checks and transformations from the plugin's copy do not survive that: `Schema.Finite` refuses
633
- the number `2` ("Expected a finite number"), and `Schema.FiniteFromString` fails with "Cannot
634
- convert a symbol to a number". Tool inputs use plain schemas and the tools validate the values
635
- themselves; a unit test keeps it that way.
636
- - `permission.asked` and `permission.replied` reach a plugin's `event.subscribe()`, as
637
- `session.execution.failed` does. OpenCode keeps pending permission requests per location, and a
638
- plugin instance's `permission` domain answers from its own location's, so a request of an
639
- isolated child is answered through the instance loaded in the child's worktree. The plugin keeps
640
- the domain of every loaded instance and answers through the one that holds the request.
641
- - A permission request rejected without a message ends the asking session's turn ("The user
642
- declined this tool call"); with a message, only the tool call fails, and its model is told that
643
- the call could not be run, not the message.
644
- - A plugin can replace the `execute` of any tool, OpenCode's own included, with `tool.transform`
645
- and the editor's `update` (an id that is missing is ignored). OpenCode's question tool is such a
646
- tool, `question`, added by a plugin of OpenCode's own that loads before external ones. The
647
- promise API hands the original `execute` out only as a function returning a promise, run
648
- without an abort signal, so the call cannot be stopped once started. OpenCode's form service
649
- withdraws a question's form when the call that asked it is interrupted, and nothing else in the
650
- plugin API can withdraw or answer a form: the context has no form domain. The question relay
651
- needs to withdraw a child's question once its parent's answer is in, so the plugin's default
652
- export is an Effect plugin (`{ id, effect }`): it runs the promise plugin through `fromPromise`
653
- from `@opencode-ai/plugin/promise/adapter`, which is what OpenCode does with a promise plugin,
654
- then wraps the question tool with the Effect API, where the original `execute` is an Effect
655
- that can be raced and interrupted. The plugin's `effect` is the same version as OpenCode's
656
- (`4.0.0-rc.112` at the pinned version), and Effects of the two copies compose. A plugin loaded
657
- after this one that replaces `question` as well would drop the relay.
658
- - OpenCode's question tool asks through a form. `form.created` reaches `event.subscribe()` (form
659
- events are ephemeral, never stored), of every location's plugin instance whichever location the
660
- form is in, with the form's `metadata.tool.id`, the id of the call; the
661
- form is created only after the call's permission check has passed. A person dismissing a form
662
- makes the tool die with `QuestionTool.CancelledError`, which ends the asking session's turn
663
- ("The user dismissed this question"). Forms live in memory: a restart drops them, and the call
664
- that asked stays `running` until the session's next turn marks it aborted. When OpenCode closes
665
- a location, as on a graceful server shutdown, it unloads the plugin there first and then
666
- withdraws every open form, which looks to the tool exactly like the person dismissing it; the
667
- relay treats a call that ends after its plugin instance unloaded as cut off, not dismissed.
668
- - OpenCode stops every turn in a project location after 60 minutes without a stored session event
669
- there (`packages/core/src/location-activity.ts`), which withdraws any question still open. A
670
- tool call has no timeout of its own.
671
- - `Schema.Number` advertises the strings `"Infinity"`, `"-Infinity"` and `"NaN"` in its JSON
672
- Schema, so models are offered a string where a number is meant. Some send numbers as strings
673
- regardless, so `courier_later` takes `delayMinutes` as either.
263
+ plugin loaded and `e2e/mock-model.mjs` as the model: an OpenAI-compatible server that replies from a
264
+ fixed script. It walks every tool through the behaviour above, including a permission request, a
265
+ question answered on either side, a server restart with pending work, a signed and an unsigned
266
+ GitHub delivery, and the package installed from a stand-in npm registry. It needs node, npm, bun,
267
+ git, curl, jq and openssl. New behaviour gets a scenario there.
268
+
269
+ `e2e/real-model.sh` runs the same server with a real model (by default a free one on [OpenCode
270
+ Zen](https://opencode.ai/zen), no key needed) and asks the parent to fan a small task out to two
271
+ children; `COURIER_SCENARIO=permission` and `COURIER_SCENARIO=question` exercise the two relays with
272
+ the script in the person's place. It checks that the parent spawns instead of doing the work, ends
273
+ its turn instead of polling, and that each report wakes it. Which models pass and what was tuned for
274
+ them: [docs/real-model.md](docs/real-model.md).
275
+
276
+ CI (`.github/workflows/ci.yml`) runs both suites on every push to `main` and every pull request,
277
+ with the OpenCode CLI at the same version as the pinned plugin API, and the live suite once more
278
+ with the CLI at its `latest` dist-tag: that job may fail, and its step summary names the version it
279
+ ran on, so a host release that breaks the plugin is seen without blocking the build. Releases start from GitHub and
280
+ end with a maintainer's 2FA approval on npm: [docs/releasing.md](docs/releasing.md). What the plugin
281
+ API does that the plugin had to work around, and what changed when the pin last moved:
282
+ [docs/plugin-api-notes.md](docs/plugin-api-notes.md).
283
+
284
+ ## Contributing
285
+
286
+ Questions and bug reports go to the [issues](https://github.com/ivopogace/opencode-courier/issues).
287
+ Pull requests are welcome: a change in behaviour comes with a unit test, an `e2e/run.sh` scenario
288
+ and its line in this README or [docs/reference.md](docs/reference.md), and CI must be green.
289
+ Maintainers release from GitHub as [docs/releasing.md](docs/releasing.md) describes.
674
290
 
675
291
  ## License
676
292