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 +184 -568
- package/dist/cleanup.d.ts +3 -3
- package/dist/cleanup.js +1 -1
- package/dist/courier.d.ts +3 -1
- package/dist/courier.js +3 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.js +20 -6
- package/dist/later.d.ts +1 -1
- package/dist/question.d.ts +27 -7
- package/dist/question.js +95 -22
- package/dist/relay.d.ts +1 -1
- package/dist/relay.js +1 -1
- package/dist/roster.d.ts +3 -1
- package/dist/storage.d.ts +1 -1
- package/dist/version.d.ts +22 -0
- package/dist/version.js +35 -0
- package/dist/watch.d.ts +3 -2
- package/dist/watch.js +13 -3
- package/dist/webhook.d.ts +1 -1
- package/package.json +9 -4
package/README.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# opencode-courier
|
|
2
2
|
|
|
3
3
|
[](https://github.com/ivopogace/opencode-courier/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/opencode-courier)
|
|
5
|
+
[](https://socket.dev/npm/package/opencode-courier)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](#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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+

|
|
26
29
|
|
|
27
|
-
|
|
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
|
-

|
|
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
|
+

|
|
47
|
+
|
|
48
|
+

|
|
31
49
|
|
|
32
|
-
|
|
33
|
-
parent passes it back, and the child adds `times` to math.js and reports.
|
|
50
|
+
</details>
|
|
34
51
|
|
|
35
|
-
|
|
36
|
-
|
|
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 `
|
|
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
|
-
##
|
|
66
|
+
## Install
|
|
51
67
|
|
|
52
|
-
|
|
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
|
-
|
|
|
55
|
-
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
##
|
|
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
|
-
|
|
348
|
-
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
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
|
-
|
|
423
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
440
|
-
OPENCODE_BIN=$(which
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
|