opencode-courier 0.2.2 → 0.3.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 +247 -108
- package/dist/bounded.d.ts +8 -0
- package/dist/bounded.js +19 -0
- package/dist/cleanup.d.ts +21 -21
- package/dist/cleanup.js +61 -30
- package/dist/courier.d.ts +116 -15
- package/dist/courier.js +239 -74
- package/dist/group.d.ts +86 -0
- package/dist/group.js +247 -0
- package/dist/hub.d.ts +300 -0
- package/dist/hub.js +343 -0
- package/dist/index.d.ts +5 -13
- package/dist/index.js +82 -313
- package/dist/json.d.ts +8 -0
- package/dist/json.js +15 -0
- package/dist/later.d.ts +7 -7
- package/dist/later.js +8 -7
- package/dist/limits.d.ts +65 -0
- package/dist/limits.js +109 -0
- package/dist/notices.d.ts +356 -0
- package/dist/notices.js +678 -0
- package/dist/question/answer.d.ts +35 -0
- package/dist/question/answer.js +214 -0
- package/dist/question/index.d.ts +6 -0
- package/dist/question/index.js +7 -0
- package/dist/question/lifecycle.d.ts +43 -0
- package/dist/question/lifecycle.js +91 -0
- package/dist/question/pure.d.ts +48 -0
- package/dist/question/pure.js +74 -0
- package/dist/question/relay.d.ts +5 -0
- package/dist/question/relay.js +269 -0
- package/dist/question/runtime.d.ts +20 -0
- package/dist/question/runtime.js +74 -0
- package/dist/question/shared.d.ts +24 -0
- package/dist/question/shared.js +24 -0
- package/dist/relay.d.ts +32 -36
- package/dist/relay.js +6 -56
- package/dist/report.d.ts +48 -0
- package/dist/report.js +88 -0
- package/dist/roster.d.ts +48 -8
- package/dist/roster.js +140 -17
- package/dist/skill.d.ts +13 -0
- package/dist/skill.js +9 -0
- package/dist/stop.d.ts +41 -0
- package/dist/stop.js +120 -0
- package/dist/storage.d.ts +8 -1
- package/dist/storage.js +8 -5
- package/dist/tools.d.ts +94 -0
- package/dist/tools.js +297 -0
- package/dist/tree.d.ts +102 -0
- package/dist/tree.js +58 -0
- package/dist/version.d.ts +4 -10
- package/dist/version.js +4 -10
- package/dist/watch.d.ts +70 -42
- package/dist/watch.js +346 -51
- package/dist/webhook.d.ts +11 -28
- package/dist/webhook.js +46 -146
- package/package.json +16 -5
- package/dist/question.d.ts +0 -158
- package/dist/question.js +0 -768
package/README.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# opencode-courier
|
|
2
2
|
|
|
3
|
+
**Your OpenCode session hands work to other sessions, ends its turn, and is woken when they
|
|
4
|
+
report.** Multi-session orchestration for OpenCode V2: spawn child sessions, be woken by them. No
|
|
5
|
+
polling.
|
|
6
|
+
|
|
3
7
|
[](https://github.com/ivopogace/opencode-courier/actions/workflows/ci.yml)
|
|
4
8
|
[](https://sonarcloud.io/summary/new_code?id=ivopogace_opencode-courier)
|
|
5
9
|
[](https://sonarcloud.io/summary/new_code?id=ivopogace_opencode-courier)
|
|
@@ -10,31 +14,98 @@
|
|
|
10
14
|
[](LICENSE)
|
|
11
15
|
[](#supported-opencode-version)
|
|
12
16
|
|
|
13
|
-
|
|
14
|
-
sessions, message them, and be woken by them, without polling.
|
|
17
|
+

|
|
15
18
|
|
|
16
|
-
A parent
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
A parent writes a README while two slow children work, and each report wakes it ([the
|
|
20
|
+
prompt](#demos)).
|
|
21
|
+
|
|
22
|
+
## Quickstart
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install -g @opencode/cli@2.0.26 # OpenCode V2, at the version courier is tested on
|
|
26
|
+
opencode plugin add opencode-courier # by name, so OpenCode offers new releases
|
|
27
|
+
opencode # in any folder
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Paste this prompt:
|
|
31
|
+
|
|
32
|
+
> Start two helper sessions with courier_spawn and have each report back to you. One runs
|
|
33
|
+
> `` `sleep 20; echo $((17 * 23))` ``, the other `` `sleep 40; echo $((2 ** 10))` ``. Do not run the
|
|
34
|
+
> commands yourself. When both have reported, reply with one line: RESULTS \<first\> \<second\>
|
|
35
|
+
|
|
36
|
+
The parent spawns both children and ends its turn. Within a minute the first report starts a new
|
|
37
|
+
turn on its own, about 20 seconds later the second does, and the parent replies `RESULTS 391 1024`.
|
|
38
|
+
If OpenCode asks to allow a child's command, the parent passes the question to you. Free models
|
|
39
|
+
sometimes stall on a request; if nothing moves for a few minutes, send the prompt again. Next:
|
|
40
|
+
[Using it](#using-it).
|
|
41
|
+
|
|
42
|
+
## OpenCode V2 native
|
|
43
|
+
|
|
44
|
+
Courier is built on OpenCode V2's plugin API (`@opencode/plugin`), not the V1 one
|
|
45
|
+
(`@opencode-ai/plugin`), and needs OpenCode V2. Each release is pinned to one V2 version and tested
|
|
46
|
+
against it end to end; the current one is tested on **OpenCode 2.0.26**. CI also runs the live
|
|
47
|
+
suite on the newest OpenCode release, so a host release that breaks the plugin shows up there
|
|
48
|
+
first. Older releases and their versions: [Supported OpenCode version](#supported-opencode-version).
|
|
22
49
|
|
|
23
50
|
> **Status: early.** Passes an end-to-end test inside a live OpenCode V2 server driven by a
|
|
24
51
|
> scripted stand-in model, and a smoke test with real (free) models: see
|
|
25
52
|
> [Development](#development).
|
|
26
53
|
|
|
54
|
+
## Courier or the built-in subagent tool?
|
|
55
|
+
|
|
56
|
+
OpenCode V2 has its own [`subagent` tool](https://opencode.ai/v2/docs/agents): it starts an agent
|
|
57
|
+
with fresh context in a child session, and the parent either waits for its final answer or, with
|
|
58
|
+
`background: true`, carries on and is notified when the child finishes. Passing the child's
|
|
59
|
+
`sessionID` back sends it another prompt, steering it if it is running and waking it if it is idle.
|
|
60
|
+
Use it when one result back is all you need: a search, a review, a self-contained task that needs
|
|
61
|
+
nothing from you on the way.
|
|
62
|
+
|
|
63
|
+
Use courier when the work needs more than one result back. On top of what the built-in tool does,
|
|
64
|
+
it adds:
|
|
65
|
+
|
|
66
|
+
- **Messages from the child, mid-run:** with the built-in tool a child's one message to its parent
|
|
67
|
+
is its final answer. With courier a child reports progress, or asks its parent something,
|
|
68
|
+
whenever it likes with `courier_send`, and any session can message any other.
|
|
69
|
+
- **Waking an idle session from anywhere:** a message from a child, a schedule or a webhook starts a
|
|
70
|
+
new turn in a session that has ended its own.
|
|
71
|
+
- **Scheduled messages:** `courier_later` wakes a session at a set time, for check-ins and
|
|
72
|
+
reminders.
|
|
73
|
+
- **Webhooks:** GitHub reviews, comments and CI runs, or any signed POST, wake the
|
|
74
|
+
[subscribed](#webhooks) session.
|
|
75
|
+
- **Worktree isolation:** `isolate: true` gives a child its own git worktree, so children can edit
|
|
76
|
+
files in parallel. An isolated child that splits hands its committed work down: its isolated
|
|
77
|
+
children start from its HEAD, and each isolated child commits on `courier/<its session ID>` and
|
|
78
|
+
reports that branch for its parent to merge ([nested isolation](docs/reference.md#nested-isolation)).
|
|
79
|
+
- **Relaying to you:** a child's permission requests and questions reach you through the parent,
|
|
80
|
+
and your answers go back; a form only you can answer is pointed out to you.
|
|
81
|
+
|
|
82
|
+
## How it works
|
|
83
|
+
|
|
84
|
+
A parent session calls `courier_spawn`, gets a session id back immediately and ends its turn. The
|
|
85
|
+
child works on its own and, when it is done or stuck, calls `courier_send` with the parent's id, a
|
|
86
|
+
status and what it produced. That message lands in the parent's inbox and OpenCode starts a new
|
|
87
|
+
turn for the parent if it is idle. A child whose turn fails instead, so that it cannot report, or ends without reporting, is
|
|
88
|
+
reported by the plugin, and a child that waits for a permission or asks a question has it passed to the parent, who asks you and
|
|
89
|
+
passes your answer back. A child that waits on a form only you can answer, such as OpenCode asking
|
|
90
|
+
which web search provider to use, has its parent told so.
|
|
91
|
+
|
|
92
|
+
There is no polling anywhere: a message is admitted into the target session's inbox through
|
|
93
|
+
OpenCode's own API, which wakes the session, the same way OpenCode's background subagents report to
|
|
94
|
+
their parent. Delivery is `steer` by default (injected into the target's running turn, or starts
|
|
95
|
+
one if idle); `queue: true` waits until the current turn ends.
|
|
96
|
+
|
|
97
|
+
## Demos
|
|
98
|
+
|
|
27
99
|
**The parent does not wait.** Prompt: *"Spawn two children: one runs `sleep 30` then lists the
|
|
28
100
|
exports of math.js, the other runs `sleep 45` then lists the exports of text.js. Don't wait for
|
|
29
101
|
them: meanwhile write a short README.md for this folder yourself, and add their reports to it when
|
|
30
102
|
they arrive."*
|
|
31
103
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
into the README and cancels the check-in it had scheduled for itself.
|
|
104
|
+
The recording at the top is this run. Watch the parent write its README and finish its turn 16
|
|
105
|
+
seconds in, while both children still sleep. Each report then starts a new turn on its own (the
|
|
106
|
+
`Message from ses_…` line is the child's message): the first after about 30 seconds, the second
|
|
107
|
+
after about 45, and the parent folds each into the README and cancels the check-in it had scheduled
|
|
108
|
+
for itself.
|
|
38
109
|
|
|
39
110
|
**A child's question reaches you.** Prompt: *"Spawn a child session to add a multiply function to
|
|
40
111
|
math.js. It must ask me first whether to name it multiply, times or product."*
|
|
@@ -44,87 +115,91 @@ math.js. It must ask me first whether to name it multiply, times or product."*
|
|
|
44
115
|
Watch the child's question open in the parent's session as OpenCode's own question form, where
|
|
45
116
|
`times` is picked. The parent passes it back, and the child adds `times` to math.js and reports.
|
|
46
117
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
118
|
+
Both recordings are real runs with a free model on [OpenCode Zen](https://opencode.ai/zen) (Muse
|
|
119
|
+
Spark 1.3).
|
|
120
|
+
|
|
121
|
+
## Recursive orchestration
|
|
122
|
+
|
|
123
|
+
A session splits a goal across child sessions, each child decides in turn whether to split its
|
|
124
|
+
part again, and the results are checked and integrated at every level on the way back up. The
|
|
125
|
+
plugin ships the playbook as an OpenCode skill, `courier-orchestrate`, listed in every session with
|
|
126
|
+
the plugin loaded: how to decide whether to split, and to say which in one line of the reply
|
|
127
|
+
(`split: no, because …; considered: <the parts you weighed>` or `split: 3 parts: …`), plan the
|
|
128
|
+
parts, start them in one join group, end the turn, read each report by its status, verify
|
|
129
|
+
first-hand through the project's scripts, integrate and report upward, and what to do when a part
|
|
130
|
+
fails, ends quietly, blocks or hits a limit. It names the parts an implementation task with one
|
|
131
|
+
tightly coupled core usually still has: its e2e scenario, its documentation, sometimes a second
|
|
132
|
+
test file, and a review of the core by a session with fresh context (`isolate: false`, it edits
|
|
133
|
+
nothing). The session does the core itself first and commits it, then starts one session per
|
|
134
|
+
remaining part, if 2 or more remain; a review session's findings are a report to act on, and
|
|
135
|
+
running the checks stays the session's own. Its description asks a model to load it at the start
|
|
136
|
+
of any implementation task that may have independent parts, and a session nobody spawned is pointed to it by its system prompt as well
|
|
137
|
+
([Session trees](#session-trees)); `/skills` in the TUI attaches it to your prompt. Its text:
|
|
138
|
+
[the reference](docs/reference.md#the-courier-orchestrate-skill).
|
|
139
|
+
|
|
140
|
+
The smoke test's recursive scenario (`COURIER_SCENARIO=recursive`, [docs/real-model.md](docs/real-model.md#recursive-orchestration))
|
|
141
|
+
asks a free model:
|
|
142
|
+
|
|
143
|
+
> Have this job done through helper sessions started with courier_spawn, one session per part, and
|
|
144
|
+
> do no part yourself. It has two parts. Part "numbers" is itself two independent halves, which the
|
|
145
|
+
> session that gets it hands to two sessions of its own: one runs `` `sleep 20; echo $((17 * 23))` ``
|
|
146
|
+
> and writes the number it printed to numbers/a.txt, the other runs `` `sleep 20; echo $((2 ** 10))` ``
|
|
147
|
+
> and writes the number it printed to numbers/b.txt. Part "count" runs `` `sleep 20; echo $((99 - 57))` ``
|
|
148
|
+
> and writes the number it printed to count/total.txt. When the parts have reported back, read the
|
|
149
|
+
> three files yourself and reply with one line: RESULTS \<a\> \<b\> \<total\>
|
|
150
|
+
|
|
151
|
+
The root starts two sessions and ends its turn; the "numbers" session starts two of its own, ends
|
|
152
|
+
its turn, checks both files when their reports wake it and reports once to the root; the "count"
|
|
153
|
+
session does its part itself. The root is woken by the two reports, reads the three files and
|
|
154
|
+
replies `RESULTS 391 1024 42`. The checker verifies the shape of the tree, that no session polled or
|
|
155
|
+
went past `maxDepth`, that every report carries a status and each sub-orchestrator reported once,
|
|
156
|
+
after its children, and the files and the final line.
|
|
69
157
|
|
|
70
158
|
## Install
|
|
71
159
|
|
|
72
160
|
### Supported OpenCode version
|
|
73
161
|
|
|
74
|
-
Requires OpenCode V2: the `opencode` command from `@opencode/cli` (
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
that breaks the plugin shows up in CI first. A newer OpenCode may still break tools;
|
|
81
|
-
[docs/plugin-api-notes.md](docs/plugin-api-notes.md) lists what the pinned version already needed
|
|
82
|
-
working around, and what changed the last time the pin moved. A release that moves the pin adds a
|
|
83
|
-
row here. When the plugin loads on an OpenCode whose version is not the pinned one, it writes one
|
|
84
|
-
line to the server log naming both versions, so a mismatch is named before a tool fails.
|
|
162
|
+
Requires OpenCode V2: the `opencode` command from `@opencode/cli` (also installed as `opencode2`,
|
|
163
|
+
the name of the beta line). Each release of this plugin is built and tested against exactly one
|
|
164
|
+
OpenCode V2 version, and a release that moves the pin adds a row here. OpenCode itself never
|
|
165
|
+
checks the version: the plugin installs and loads on any V2 host. When it loads on a version other
|
|
166
|
+
than the pinned one, it writes one line to the server log naming both versions, so a mismatch is
|
|
167
|
+
named before a tool fails. More in [the reference](docs/reference.md#the-opencode-version).
|
|
85
168
|
|
|
86
169
|
| opencode-courier | OpenCode V2 (`opencode` and `@opencode/plugin`) |
|
|
87
170
|
|---|---|
|
|
171
|
+
| 0.2.3 | 2.0.26 |
|
|
88
172
|
| 0.2.2 | 2.0.24 |
|
|
89
173
|
| 0.2.1 | 2.0.23 |
|
|
90
174
|
| 0.2.0 | 2.0.22 |
|
|
91
175
|
| 0.1.6 | 0.0.0-beta-19271 (the beta line: `opencode2` from `@opencode-ai/cli`, and `@opencode-ai/plugin`) |
|
|
92
176
|
|
|
93
|
-
|
|
94
|
-
2.0.24 as well. 0.2.1 changes nothing but the pin: 2.0.23 changed nothing the plugin calls, and
|
|
95
|
-
0.2.0 passes the suite on 2.0.23 as well. 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
|
|
96
|
-
then (no 2.x release above 2.0.22 existed), where the suite passed too. Of the older hosts tried, it
|
|
97
|
-
loads on 2.0.4 and 2.0.21 (nothing in between was run, and the suite was not), and fails to load on
|
|
98
|
-
2.0.0 and 2.0.3, which lack the `model` domain the plugin API gained in 2.0.4. The version in
|
|
99
|
-
`package.json` protects nobody on its own: OpenCode says nothing about it, since
|
|
100
|
-
`opencode plugin add` installs the plugin whatever your OpenCode version and its loader warns about
|
|
101
|
-
nothing either (a recorded experiment, in
|
|
102
|
-
[docs/plugin-api-notes.md](docs/plugin-api-notes.md#what-plugin-add-and-loading-do-with-the-peer-dependency-2026-10-04),
|
|
103
|
-
which also says why the peer dependency stays exact rather than a range: it picks the copy of the
|
|
104
|
-
plugin API the plugin runs on). The plugin's own log line above is the only runtime signal, apart
|
|
105
|
-
from the load failure on those hosts before 2.0.4. Check yours with `opencode --version`, and
|
|
106
|
-
install the matching CLI with:
|
|
177
|
+
Check yours with `opencode --version`, and install the matching CLI with:
|
|
107
178
|
|
|
108
179
|
```bash
|
|
109
|
-
npm install -g @opencode/cli@2.0.
|
|
180
|
+
npm install -g @opencode/cli@2.0.26
|
|
110
181
|
```
|
|
111
182
|
|
|
112
183
|
### The plugin
|
|
113
184
|
|
|
114
|
-
Then install the plugin:
|
|
115
|
-
|
|
116
185
|
```bash
|
|
117
186
|
opencode plugin add opencode-courier
|
|
118
187
|
```
|
|
119
188
|
|
|
120
189
|
This installs the package from npm and adds `"opencode-courier"` to `plugins` in the global
|
|
121
190
|
configuration (`~/.config/opencode/opencode.json`). To receive webhooks, replace that entry with the
|
|
122
|
-
object form shown under [Webhooks](#webhooks), which carries a `webhook` option
|
|
191
|
+
object form shown under [Webhooks](#webhooks), which carries a `webhook` option; the [session tree
|
|
192
|
+
limits](#session-trees) are options of the same entry.
|
|
193
|
+
|
|
194
|
+
Install it by name, without a version: OpenCode only checks plugins for updates when their entry is
|
|
195
|
+
not an exact version, so `opencode-courier@0.2.1` is never offered a newer release. How to move an
|
|
196
|
+
entry that carries a version to the name, and what to look at when an update still does not show:
|
|
197
|
+
[Updating the plugin](docs/reference.md#updating-the-plugin).
|
|
123
198
|
|
|
124
199
|
### From a local clone
|
|
125
200
|
|
|
126
201
|
```bash
|
|
127
|
-
git clone
|
|
202
|
+
git clone https://github.com/ivopogace/opencode-courier && cd opencode-courier
|
|
128
203
|
bun install && npm run build
|
|
129
204
|
```
|
|
130
205
|
|
|
@@ -142,30 +217,40 @@ Then list it in `opencode.json` (V2 uses `plugins`, plural). A local plugin path
|
|
|
142
217
|
1. Keep the background server running so sessions can be woken while you are away (`opencode
|
|
143
218
|
service start`; `opencode service status` to check).
|
|
144
219
|
2. Give the agents that run children the permissions their work needs. A child that hits an approval
|
|
145
|
-
prompt has it passed to its parent, which asks you
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
220
|
+
prompt has it passed to its parent, which asks you, but the child waits until you answer, so keep
|
|
221
|
+
prompts for what you want to decide yourself. A child's question goes to the parent the same
|
|
222
|
+
way, so let the agents that run children use the `question` tool if they should be able to ask
|
|
223
|
+
you; OpenCode's default agent may. Choose a web search provider before children search the web
|
|
224
|
+
(run one web search in your own session and answer OpenCode's prompt, or use its "Third-party
|
|
225
|
+
search" setting): otherwise the first child to search shows that prompt in its own session,
|
|
226
|
+
which only you can answer there.
|
|
227
|
+
3. Your own session's prompts are relayed nowhere, and each one stalls the whole tree until you
|
|
228
|
+
answer it. Before an unattended run, allow the orchestrating session's toolchain up front: the
|
|
229
|
+
repository's `bun run` and `npm run` scripts, `git`, and whatever its checks need. Prefer one rule
|
|
230
|
+
on `bun run *` or `npm run *` over a rule per binary; the skill tells a session that orchestrates
|
|
231
|
+
to run checks through the project's scripts rather than `./node_modules/.bin/<tool>`, so one rule
|
|
232
|
+
covers them.
|
|
233
|
+
4. Use `isolate: true` whenever children edit files in parallel. The child's worktree is made from
|
|
234
|
+
the last commit (from an isolated session, its HEAD), so an uncommitted `opencode.json` is not there and the child falls back to your
|
|
153
235
|
global config: keep providers and models in the global config, or commit the file. When you are
|
|
154
236
|
done with an isolated child, `courier_cleanup` it so its worktree does not linger.
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
237
|
+
5. A child whose turn fails, or ends without `courier_send`, is reported to its parent by the
|
|
238
|
+
plugin. What the plugin cannot see, such as a server that stops while a child runs, a
|
|
239
|
+
`courier_later` check-in still covers: for a long-running child, schedule one for yourself and
|
|
240
|
+
`courier_cancel` it when the child reports.
|
|
158
241
|
|
|
159
242
|
## Tools
|
|
160
243
|
|
|
161
244
|
| Tool | Does |
|
|
162
245
|
|---|---|
|
|
163
|
-
| `courier_spawn` | Creates a session (optionally in its own git worktree with `isolate: true
|
|
164
|
-
| `courier_send` | Delivers a message to a session, signed with the sender's id, waking it if idle. |
|
|
246
|
+
| `courier_spawn` | Creates a session (optionally in its own git worktree with `isolate: true`, which from an isolated session starts at its HEAD, [nested isolation](docs/reference.md#nested-isolation)) on the parent's model, sends it the task plus a brief naming the parent, how to report back and when to split the task further, and returns at once. With a `group` name, the reports of every session started under that name are held and delivered together, in one message ([reference](docs/reference.md#join-groups)). Refused past the [session tree limits](#session-trees). |
|
|
247
|
+
| `courier_send` | Delivers a message to a session, signed with the sender's id, waking it if idle. From a child to its parent, a `status` (`done`, `partial`, `blocked` or `failed`) makes it the child's report, carried as an attribute of the message (`<courier from="…" status="done">`), and `artifacts` (`branch`, `commits`, `files`, `checks`) are listed in its body in a fixed layout; a message without a status is progress, not the report ([reference](docs/reference.md#a-childs-report)). From a member of a join group, a report with `done`, `partial` or `failed` is held with the group instead, and the result says so. |
|
|
165
248
|
| `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. |
|
|
166
|
-
| `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
|
|
167
|
-
| `
|
|
168
|
-
| `
|
|
249
|
+
| `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, when it was started and, for one started in a group, the group and whether its report is `held`, `out` or `released`. |
|
|
250
|
+
| `courier_tree` | Lists the whole tree under this session (or a given `sessionID`): every session started from it with `courier_spawn`, directly or through others, depth first, each with what `courier_children` shows plus its depth, its parent, and its report state (whether it owes a report, sent progress, ended without one, and the status of its last report). Cut at `maxTotal` sessions ([reference](docs/reference.md#seeing-and-stopping-a-subtree)). |
|
|
251
|
+
| `courier_stop` | Interrupts a session you started, directly or through others, and every session under it, deepest first, and cancels the `courier_later` messages scheduled for them. With `cleanup: true` it also removes their worktrees, by `courier_cleanup`'s rules (kept ones are listed). You are not told of what you stopped; a session between you and the target is, once, as `ended="stopped"` ([reference](docs/reference.md#seeing-and-stopping-a-subtree)). |
|
|
252
|
+
| `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 ([reference](docs/reference.md#worktree-cleanup)). A child without a worktree is only dropped from `courier_children`; either way, a join group member that has not reported is dropped from its group, which is released without it ([reference](docs/reference.md#join-groups)). |
|
|
253
|
+
| `courier_answer` | Passes your answer to a permission request or a question that a session started from this one waits on: `reply` (`once`, `always` or `reject`, with an optional `message`) for a permission request, `answers` for a question. |
|
|
169
254
|
| `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. |
|
|
170
255
|
| `courier_cancel` | Drops a message scheduled with `courier_later`, e.g. because the child it was waiting for reported first. |
|
|
171
256
|
| `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). |
|
|
@@ -178,9 +263,31 @@ The short version; the long one, with every edge, is [docs/reference.md](docs/re
|
|
|
178
263
|
- **A child runs on its parent's model**, not on OpenCode's default, so a parent you moved to
|
|
179
264
|
another model starts children that can reach theirs too. An `agent` with a model of its own keeps
|
|
180
265
|
it. [More](docs/reference.md#the-childs-model).
|
|
266
|
+
- **A child can split its task** with `courier_spawn` of its own, within the [limits](#session-trees):
|
|
267
|
+
its brief says when to split and when to do the work itself, and a session that splits checks
|
|
268
|
+
its children's work and sends one combined report. [More](docs/reference.md#session-trees-and-their-limits).
|
|
269
|
+
- **A child reports once, with a status**: `done`, `partial`, `blocked` (it needs a decision from
|
|
270
|
+
its parent, and says what) or `failed`, as an attribute of the message, with its artifacts listed
|
|
271
|
+
in the body. A message without a status is progress, so a child that sends "halfway" and then
|
|
272
|
+
stops is still reported as ending without a report. [More](docs/reference.md#a-childs-report).
|
|
273
|
+
- **A join group wakes you once.** Start a batch of sessions with the same `group` name, and
|
|
274
|
+
their `done`, `partial` and `failed` reports are held until the last one is in and your turn has ended, then delivered
|
|
275
|
+
in one message, marked `group="<name>"`, listing every report with its status and artifacts in
|
|
276
|
+
the order the sessions were started. A `blocked` report, progress, a failed turn, a silent end, a
|
|
277
|
+
permission request and a question reach you at once, as ever; a member whose turn fails or is
|
|
278
|
+
interrupted leaves the group, which is released without it. A member that will not report (it
|
|
279
|
+
ended its turn silently, or you gave up on it) holds the others back: drop it with
|
|
280
|
+
`courier_cleanup`, and the group is released without it, the message naming it; a report it
|
|
281
|
+
sends after all comes on its own. Held reports survive a restart, and the name is free again
|
|
282
|
+
once the group has been delivered. [More](docs/reference.md#join-groups).
|
|
181
283
|
- **A child that fails** cannot report, so the plugin does: every failed turn of a spawned session
|
|
182
284
|
sends its parent a message marked `failed="<error type>"`, with the error, waking it if idle.
|
|
183
285
|
[More](docs/reference.md#a-child-that-fails).
|
|
286
|
+
- **A child that ends its turn without reporting** to its parent has the parent told, marked
|
|
287
|
+
`ended="without-report"`, with its last reply, waking it if idle; it counts toward the limits
|
|
288
|
+
until it reports. One that waits, on a request, a session it started, a scheduled message or a
|
|
289
|
+
webhook it just subscribed to, is not reported, nor is one that answers what you typed in its session.
|
|
290
|
+
[More](docs/reference.md#a-child-that-ends-without-a-report).
|
|
184
291
|
- **A child that asks for permission** has the request passed to the session at the top, with what
|
|
185
292
|
it asks for and the choices OpenCode offers (`once`, `always`, `reject`). That session asks you
|
|
186
293
|
and answers with `courier_answer`; the child carries on.
|
|
@@ -191,13 +298,52 @@ The short version; the long one, with every edge, is [docs/reference.md](docs/re
|
|
|
191
298
|
parent's or by `courier_answer`, and the other question is withdrawn; a question stays answerable
|
|
192
299
|
across an interrupted turn or a server restart.
|
|
193
300
|
[More](docs/reference.md#a-child-that-asks-a-question).
|
|
194
|
-
- **
|
|
195
|
-
|
|
301
|
+
- **A child that shows a form** the plugin cannot pass on, such as OpenCode's web search asking
|
|
302
|
+
for a provider, has the top session told, marked `asks="form"`, with the form's choices: only you
|
|
303
|
+
can answer it, in the child's session. [More](docs/reference.md#a-child-that-shows-a-form).
|
|
304
|
+
- **You can see and stop a whole subtree.** `courier_tree` lists every session under yours with its
|
|
305
|
+
depth and report state; `courier_stop` interrupts one of them and everything under it, deepest
|
|
306
|
+
first, and cancels their scheduled messages. A stop is neither a failure nor a silent end, and
|
|
307
|
+
only a session above the target may order it. [More](docs/reference.md#seeing-and-stopping-a-subtree).
|
|
308
|
+
- **The plugin remembers.** Each parent's children (`courier_children`) and whether each owes a
|
|
309
|
+
report, held group reports, pending `courier_later` messages and open questions survive a compaction or a restart; entries are dropped after 14 days.
|
|
196
310
|
[Roster](docs/reference.md#roster), [Scheduled messages](docs/reference.md#scheduled-messages).
|
|
311
|
+
- **One OpenCode server per data directory, preferably.** A second server on the same one, such as
|
|
312
|
+
`opencode serve` next to `opencode service`, shares the plugin's storage: one of the two delivers
|
|
313
|
+
`courier_later` messages and released groups, but a child's permission request can only be answered from the server
|
|
314
|
+
that runs the child. [More](docs/reference.md#two-servers-on-one-data-directory).
|
|
197
315
|
- **Worktrees are yours to remove.** An isolated child's worktree is kept until `courier_cleanup`,
|
|
198
316
|
which refuses to drop uncommitted changes or unbranched commits unless told to.
|
|
199
317
|
[More](docs/reference.md#worktree-cleanup).
|
|
200
318
|
|
|
319
|
+
## Session trees
|
|
320
|
+
|
|
321
|
+
A spawned session may start sessions of its own, so the sessions form a tree, and three options
|
|
322
|
+
bound it. Set them in the plugin's `options`, in the global config (`~/.config/opencode/opencode.json`):
|
|
323
|
+
|
|
324
|
+
```jsonc
|
|
325
|
+
{
|
|
326
|
+
"plugins": [{ "package": "opencode-courier", "options": { "maxDepth": 2, "maxChildren": 3 } }]
|
|
327
|
+
}
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
| Option | Default | |
|
|
331
|
+
|---|---|---|
|
|
332
|
+
| `maxDepth` | `3` | How deep the tree goes: your session is depth 0, the sessions it starts depth 1. A session at `maxDepth` cannot start sessions. |
|
|
333
|
+
| `maxChildren` | `5` | How many sessions one session started may have live at once. |
|
|
334
|
+
| `maxTotal` | `20` | How many spawned sessions may be live at once in one tree. |
|
|
335
|
+
|
|
336
|
+
`courier_spawn` refuses a spawn past a limit, naming it and saying what to do instead: do the work
|
|
337
|
+
itself, or wait for a child to report. A session counts while it is live: its turn runs, it owes
|
|
338
|
+
its parent a report (its task or a message reached it after it last reported, failed or was interrupted), or a
|
|
339
|
+
session below it is live. One that has reported, with nothing live below it, does not. Each
|
|
340
|
+
spawned session's model is also told its role on every request: `sub-orchestrator`, which may split
|
|
341
|
+
its task, or `leaf`, at `maxDepth`, which does not see `courier_spawn` at all. Your own session is
|
|
342
|
+
told, in one line, to load the `courier-orchestrate` skill before a task that touches several files
|
|
343
|
+
or areas, decide whether to split it, and say which in one line of its reply (`split: no, because …;
|
|
344
|
+
considered: <the parts you weighed>` or `split: 3 parts: …`); once it has started a session, that
|
|
345
|
+
line becomes a `root orchestrator` part with the limits. The details: [the reference](docs/reference.md#session-trees-and-their-limits).
|
|
346
|
+
|
|
201
347
|
## Webhooks
|
|
202
348
|
|
|
203
349
|
The receiver is off unless the plugin has a `webhook` option. Put it in the **global** config
|
|
@@ -269,36 +415,29 @@ OPENCODE_BIN=$(which opencode) npm run test:e2e # live test, about two minutes
|
|
|
269
415
|
OPENCODE_BIN=$(which opencode) e2e/real-model.sh # smoke test with a real model, not in CI
|
|
270
416
|
```
|
|
271
417
|
|
|
272
|
-
`e2e/run.sh` starts a real OpenCode V2 server in a throwaway project and home
|
|
273
|
-
plugin loaded and
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
ran on, so a host release that breaks the plugin is seen without blocking the build. On `main` and
|
|
290
|
-
on this repository's own pull requests, a SonarCloud job then scans `src/` with the unit suites'
|
|
291
|
-
coverage (`sonar-project.properties`), and fails when the quality gate does. Releases start from
|
|
292
|
-
GitHub and end with a maintainer's 2FA approval on npm: [docs/releasing.md](docs/releasing.md).
|
|
293
|
-
What the plugin API does that the plugin had to work around, and what changed when the pin last
|
|
294
|
-
moved: [docs/plugin-api-notes.md](docs/plugin-api-notes.md).
|
|
418
|
+
The live test (`e2e/run.sh`) starts a real OpenCode V2 server in a throwaway project and home
|
|
419
|
+
directory, with this plugin loaded and a scripted OpenAI-compatible stand-in model, and walks every
|
|
420
|
+
tool through the behaviour above. It needs node, npm, bun, git, curl, jq and openssl. New behaviour
|
|
421
|
+
gets a scenario there.
|
|
422
|
+
|
|
423
|
+
The smoke test (`e2e/real-model.sh`) runs the same server with a real model (by default a free one
|
|
424
|
+
on [OpenCode Zen](https://opencode.ai/zen), no key needed) and checks that the parent spawns instead
|
|
425
|
+
of doing the work, ends its turn instead of polling, and is woken by each report;
|
|
426
|
+
`COURIER_SCENARIO=permission` and `COURIER_SCENARIO=question` exercise the two relays, and
|
|
427
|
+
`COURIER_SCENARIO=recursive` the [recursive orchestration](#recursive-orchestration) above. Which
|
|
428
|
+
models pass and what was tuned for them: [docs/real-model.md](docs/real-model.md).
|
|
429
|
+
|
|
430
|
+
CI runs both suites on every push to `main` and every pull request, with the OpenCode CLI at the
|
|
431
|
+
pinned version, and the live suite once more on the newest OpenCode release, where a failure warns
|
|
432
|
+
rather than breaks the build. SonarCloud scans `src/` on `main` and on pull requests. Releases start
|
|
433
|
+
from GitHub and end with a maintainer's 2FA approval on npm: [docs/releasing.md](docs/releasing.md).
|
|
434
|
+
What the plugin API does that the plugin had to work around: [docs/plugin-api-notes.md](docs/plugin-api-notes.md).
|
|
295
435
|
|
|
296
436
|
## Contributing
|
|
297
437
|
|
|
298
438
|
Questions and bug reports go to the [issues](https://github.com/ivopogace/opencode-courier/issues).
|
|
299
439
|
Pull requests are welcome: a change in behaviour comes with a unit test, an `e2e/run.sh` scenario
|
|
300
440
|
and its line in this README or [docs/reference.md](docs/reference.md), and CI must be green.
|
|
301
|
-
Maintainers release from GitHub as [docs/releasing.md](docs/releasing.md) describes.
|
|
302
441
|
|
|
303
442
|
## License
|
|
304
443
|
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded `Set`s and `Map`s of remembered ids that drop their oldest entry past a bound. Functions
|
|
3
|
+
* on the built-ins, not a class, as older plugin copies call `.has` and `.add` on the shared ones.
|
|
4
|
+
*/
|
|
5
|
+
/** Adds to a bounded set, dropping the oldest entry past `max`; false when the value was there already. */
|
|
6
|
+
export declare function addBounded<T>(set: Set<T>, value: T, max: number): boolean;
|
|
7
|
+
/** Sets an entry of a bounded map, dropping the oldest entry past `max`; an existing key keeps its place. */
|
|
8
|
+
export declare function setBounded<K, V>(map: Map<K, V>, key: K, value: V, max: number): void;
|
package/dist/bounded.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded `Set`s and `Map`s of remembered ids that drop their oldest entry past a bound. Functions
|
|
3
|
+
* on the built-ins, not a class, as older plugin copies call `.has` and `.add` on the shared ones.
|
|
4
|
+
*/
|
|
5
|
+
/** Adds to a bounded set, dropping the oldest entry past `max`; false when the value was there already. */
|
|
6
|
+
export function addBounded(set, value, max) {
|
|
7
|
+
if (set.has(value))
|
|
8
|
+
return false;
|
|
9
|
+
set.add(value);
|
|
10
|
+
if (set.size > max)
|
|
11
|
+
set.delete(set.values().next().value);
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
/** Sets an entry of a bounded map, dropping the oldest entry past `max`; an existing key keeps its place. */
|
|
15
|
+
export function setBounded(map, key, value, max) {
|
|
16
|
+
map.set(key, value);
|
|
17
|
+
if (map.size > max)
|
|
18
|
+
map.delete(map.keys().next().value);
|
|
19
|
+
}
|
package/dist/cleanup.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Plugin } from "@opencode/plugin";
|
|
2
|
+
import { type CleanupResult } from "./notices.js";
|
|
2
3
|
import { type RosterStorage } from "./roster.js";
|
|
3
4
|
type Context = Plugin.Context;
|
|
4
5
|
/** What would be lost by removing a worktree. */
|
|
@@ -18,39 +19,38 @@ export interface CleanupPorts {
|
|
|
18
19
|
readonly projectID: string;
|
|
19
20
|
/** The worktree's state, or undefined when its directory no longer exists; `base` is the commit it was made from. */
|
|
20
21
|
readonly inspect: (directory: string, base?: string) => Promise<WorktreeState | undefined>;
|
|
22
|
+
/** When a member still out is dropped from its group. */
|
|
23
|
+
readonly now: () => number;
|
|
24
|
+
/** Has the scheduler deliver what is due now: a group the forgotten child may have been the last member out of. */
|
|
25
|
+
readonly nudge: () => void;
|
|
21
26
|
}
|
|
22
27
|
export interface CleanupInput {
|
|
23
28
|
readonly sessionID: string;
|
|
24
29
|
readonly force?: boolean;
|
|
25
30
|
}
|
|
26
|
-
export type CleanupResult = {
|
|
27
|
-
readonly sessionID: string;
|
|
28
|
-
readonly directory: string;
|
|
29
|
-
readonly outcome: "removed";
|
|
30
|
-
} | {
|
|
31
|
-
readonly sessionID: string;
|
|
32
|
-
readonly directory: string;
|
|
33
|
-
readonly outcome: "gone";
|
|
34
|
-
} | {
|
|
35
|
-
readonly sessionID: string;
|
|
36
|
-
readonly directory: string;
|
|
37
|
-
readonly outcome: "kept";
|
|
38
|
-
readonly reason: string;
|
|
39
|
-
readonly changes: readonly string[];
|
|
40
|
-
readonly commits: readonly string[];
|
|
41
|
-
};
|
|
42
|
-
/** Why a worktree in this state must be kept, or undefined when removing it loses nothing. */
|
|
43
|
-
export declare function keepReason(state: WorktreeState): string | undefined;
|
|
44
31
|
/** At most this many changes and commits are returned; the reason still counts them all. */
|
|
45
32
|
export declare const MAX_LISTED = 50;
|
|
46
33
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* result says what is in it.
|
|
34
|
+
* Forgets a child the parent started, removing its worktree if it is isolated. A worktree with uncommitted changes
|
|
35
|
+
* or commits found nowhere else is kept unless `force` is set; the result says so.
|
|
50
36
|
*/
|
|
51
37
|
export declare function cleanup(ports: CleanupPorts, parentID: string, input: CleanupInput): Promise<CleanupResult>;
|
|
38
|
+
/** Names the git to run, by absolute path, overriding the places `GIT_LOCATIONS` lists. */
|
|
39
|
+
export declare const GIT_ENV = "OPENCODE_COURIER_GIT";
|
|
40
|
+
/**
|
|
41
|
+
* Where git is looked for, in order: its usual install locations, never `PATH`, so a writable
|
|
42
|
+
* directory early in `PATH` cannot put another program in its place.
|
|
43
|
+
*/
|
|
44
|
+
export declare const GIT_LOCATIONS: Readonly<Record<"posix" | "win32", readonly string[]>>;
|
|
45
|
+
/**
|
|
46
|
+
* The git to run: `OPENCODE_COURIER_GIT` when it is an absolute path, otherwise the first of
|
|
47
|
+
* `GIT_LOCATIONS` that exists. Throws when there is none.
|
|
48
|
+
*/
|
|
49
|
+
export declare function findGit(env?: Readonly<Record<string, string | undefined>>, platform?: NodeJS.Platform, exists?: (path: string) => boolean): string;
|
|
52
50
|
/** The commit a worktree is on, or undefined when git cannot tell. */
|
|
53
51
|
export declare function headOf(directory: string): Promise<string | undefined>;
|
|
52
|
+
/** Whether a worktree has uncommitted changes, untracked files included; undefined when git cannot tell. */
|
|
53
|
+
export declare function dirtyOf(directory: string): Promise<boolean | undefined>;
|
|
54
54
|
/**
|
|
55
55
|
* Reads a worktree's state with git; undefined when the directory is gone. Commits reachable from
|
|
56
56
|
* `base`, the commit the worktree was made from, are not its own work and are not listed.
|