opencode-courier 0.2.1 → 0.2.3
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 +139 -94
- package/dist/bounded.d.ts +10 -0
- package/dist/bounded.js +21 -0
- package/dist/cleanup.d.ts +13 -18
- package/dist/cleanup.js +38 -19
- package/dist/courier.d.ts +1 -5
- package/dist/courier.js +11 -23
- package/dist/hub.d.ts +325 -0
- package/dist/hub.js +340 -0
- package/dist/index.d.ts +4 -8
- package/dist/index.js +72 -310
- package/dist/json.d.ts +4 -0
- package/dist/json.js +4 -0
- package/dist/later.d.ts +1 -1
- package/dist/later.js +14 -5
- package/dist/notices.d.ts +246 -0
- package/dist/notices.js +399 -0
- package/dist/question/answer.d.ts +35 -0
- package/dist/question/answer.js +217 -0
- package/dist/question/index.d.ts +6 -0
- package/dist/question/index.js +7 -0
- package/dist/question/lifecycle.d.ts +52 -0
- package/dist/question/lifecycle.js +100 -0
- package/dist/question/pure.d.ts +49 -0
- package/dist/question/pure.js +75 -0
- package/dist/question/relay.d.ts +5 -0
- package/dist/question/relay.js +283 -0
- package/dist/question/runtime.d.ts +21 -0
- package/dist/question/runtime.js +75 -0
- package/dist/question/shared.d.ts +24 -0
- package/dist/question/shared.js +24 -0
- package/dist/relay.d.ts +31 -33
- package/dist/relay.js +5 -53
- package/dist/roster.d.ts +29 -1
- package/dist/roster.js +101 -9
- package/dist/storage.d.ts +12 -0
- package/dist/storage.js +16 -3
- package/dist/tools.d.ts +74 -0
- package/dist/tools.js +228 -0
- package/dist/watch.d.ts +28 -37
- package/dist/watch.js +158 -46
- package/dist/webhook.d.ts +1 -13
- package/dist/webhook.js +121 -177
- package/package.json +16 -5
- package/dist/question.d.ts +0 -158
- package/dist/question.js +0 -693
package/README.md
CHANGED
|
@@ -1,36 +1,109 @@
|
|
|
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)
|
|
8
|
+
[](https://sonarcloud.io/summary/new_code?id=ivopogace_opencode-courier)
|
|
9
|
+
[](https://sonarcloud.io/summary/new_code?id=ivopogace_opencode-courier)
|
|
10
|
+
[](https://sonarcloud.io/summary/new_code?id=ivopogace_opencode-courier)
|
|
11
|
+
[](https://sonarcloud.io/summary/new_code?id=ivopogace_opencode-courier)
|
|
4
12
|
[](https://www.npmjs.com/package/opencode-courier)
|
|
5
13
|
[](https://socket.dev/npm/package/opencode-courier)
|
|
6
14
|
[](LICENSE)
|
|
7
15
|
[](#supported-opencode-version)
|
|
8
16
|
|
|
9
|
-
|
|
10
|
-
|
|
17
|
+

|
|
18
|
+
|
|
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).
|
|
49
|
+
|
|
50
|
+
> **Status: early.** Passes an end-to-end test inside a live OpenCode V2 server driven by a
|
|
51
|
+
> scripted stand-in model, and a smoke test with real (free) models: see
|
|
52
|
+
> [Development](#development).
|
|
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.
|
|
77
|
+
- **Relaying to you:** a child's permission requests and questions reach you through the parent,
|
|
78
|
+
and your answers go back; a form only you can answer is pointed out to you.
|
|
79
|
+
|
|
80
|
+
## How it works
|
|
11
81
|
|
|
12
82
|
A parent session calls `courier_spawn`, gets a session id back immediately and ends its turn. The
|
|
13
83
|
child works on its own and, when it is done or stuck, calls `courier_send` with the parent's id.
|
|
14
84
|
That message lands in the parent's inbox and OpenCode starts a new turn for the parent if it is
|
|
15
85
|
idle. A child whose turn fails instead, so that it cannot report, is reported by the plugin, and a
|
|
16
86
|
child that waits for a permission or asks a question has it passed to the parent, who asks you and
|
|
17
|
-
passes your answer back.
|
|
87
|
+
passes your answer back. A child that waits on a form only you can answer, such as OpenCode asking
|
|
88
|
+
which web search provider to use, has its parent told so.
|
|
18
89
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
90
|
+
There is no polling anywhere: a message is admitted into the target session's inbox through
|
|
91
|
+
OpenCode's own API, which wakes the session, the same way OpenCode's background subagents report to
|
|
92
|
+
their parent. Delivery is `steer` by default (injected into the target's running turn, or starts
|
|
93
|
+
one if idle); `queue: true` waits until the current turn ends.
|
|
94
|
+
|
|
95
|
+
## Demos
|
|
22
96
|
|
|
23
97
|
**The parent does not wait.** Prompt: *"Spawn two children: one runs `sleep 30` then lists the
|
|
24
98
|
exports of math.js, the other runs `sleep 45` then lists the exports of text.js. Don't wait for
|
|
25
99
|
them: meanwhile write a short README.md for this folder yourself, and add their reports to it when
|
|
26
100
|
they arrive."*
|
|
27
101
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
into the README and cancels the check-in it had scheduled for itself.
|
|
102
|
+
The recording at the top is this run. Watch the parent write its README and finish its turn 16
|
|
103
|
+
seconds in, while both children still sleep. Each report then starts a new turn on its own (the
|
|
104
|
+
`Message from ses_…` line is the child's message): the first after about 30 seconds, the second
|
|
105
|
+
after about 45, and the parent folds each into the README and cancels the check-in it had scheduled
|
|
106
|
+
for itself.
|
|
34
107
|
|
|
35
108
|
**A child's question reaches you.** Prompt: *"Spawn a child session to add a multiply function to
|
|
36
109
|
math.js. It must ask me first whether to name it multiply, times or product."*
|
|
@@ -40,73 +113,36 @@ math.js. It must ask me first whether to name it multiply, times or product."*
|
|
|
40
113
|
Watch the child's question open in the parent's session as OpenCode's own question form, where
|
|
41
114
|
`times` is picked. The parent passes it back, and the child adds `times` to math.js and reports.
|
|
42
115
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-

|
|
47
|
-
|
|
48
|
-

|
|
49
|
-
|
|
50
|
-
</details>
|
|
51
|
-
|
|
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).
|
|
54
|
-
|
|
55
|
-
## How the wake works
|
|
56
|
-
|
|
57
|
-
There is no polling anywhere. `courier_send` calls the plugin API's `session.synthetic`, which
|
|
58
|
-
admits a message into the target session's inbox and, unless `resume: false` is passed, calls
|
|
59
|
-
`execution.wake` on it (`packages/core/src/session/session.ts` on OpenCode's `v2` branch).
|
|
60
|
-
OpenCode's own background subagents report to their parent the same way
|
|
61
|
-
(`packages/core/src/session/subagent-completion.ts`).
|
|
62
|
-
|
|
63
|
-
Delivery is `steer` by default (injected into the target's running turn, or starts one if idle);
|
|
64
|
-
`queue: true` waits until the current turn ends.
|
|
116
|
+
Both recordings are real runs with a free model on [OpenCode Zen](https://opencode.ai/zen) (Muse
|
|
117
|
+
Spark 1.3).
|
|
65
118
|
|
|
66
119
|
## Install
|
|
67
120
|
|
|
68
121
|
### Supported OpenCode version
|
|
69
122
|
|
|
70
|
-
Requires OpenCode V2: the `opencode` command from `@opencode/cli` (
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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.
|
|
123
|
+
Requires OpenCode V2: the `opencode` command from `@opencode/cli` (also installed as `opencode2`,
|
|
124
|
+
the name of the beta line). Each release of this plugin is built and tested against exactly one
|
|
125
|
+
OpenCode V2 version, and a release that moves the pin adds a row here. OpenCode itself never
|
|
126
|
+
checks the version: the plugin installs and loads on any V2 host. When it loads on a version other
|
|
127
|
+
than the pinned one, it writes one line to the server log naming both versions, so a mismatch is
|
|
128
|
+
named before a tool fails. More in [the reference](docs/reference.md#the-opencode-version).
|
|
81
129
|
|
|
82
130
|
| opencode-courier | OpenCode V2 (`opencode` and `@opencode/plugin`) |
|
|
83
131
|
|---|---|
|
|
132
|
+
| 0.2.3 | 2.0.26 |
|
|
133
|
+
| 0.2.2 | 2.0.24 |
|
|
84
134
|
| 0.2.1 | 2.0.23 |
|
|
85
135
|
| 0.2.0 | 2.0.22 |
|
|
86
136
|
| 0.1.6 | 0.0.0-beta-19271 (the beta line: `opencode2` from `@opencode-ai/cli`, and `@opencode-ai/plugin`) |
|
|
87
137
|
|
|
88
|
-
|
|
89
|
-
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
|
|
90
|
-
then (no 2.x release above 2.0.22 existed), where the suite passed too. Of the older hosts tried, it
|
|
91
|
-
loads on 2.0.4 and 2.0.21 (nothing in between was run, and the suite was not), and fails to load on
|
|
92
|
-
2.0.0 and 2.0.3, which lack the `model` domain the plugin API gained in 2.0.4. The version in
|
|
93
|
-
`package.json` protects nobody on its own: OpenCode says nothing about it, since
|
|
94
|
-
`opencode plugin add` installs the plugin whatever your OpenCode version and its loader warns about
|
|
95
|
-
nothing either (a recorded experiment, in
|
|
96
|
-
[docs/plugin-api-notes.md](docs/plugin-api-notes.md#what-plugin-add-and-loading-do-with-the-peer-dependency-2026-10-04),
|
|
97
|
-
which also says why the peer dependency stays exact rather than a range: it picks the copy of the
|
|
98
|
-
plugin API the plugin runs on). The plugin's own log line above is the only runtime signal, apart
|
|
99
|
-
from the load failure on those hosts before 2.0.4. Check yours with `opencode --version`, and
|
|
100
|
-
install the matching CLI with:
|
|
138
|
+
Check yours with `opencode --version`, and install the matching CLI with:
|
|
101
139
|
|
|
102
140
|
```bash
|
|
103
|
-
npm install -g @opencode/cli@2.0.
|
|
141
|
+
npm install -g @opencode/cli@2.0.26
|
|
104
142
|
```
|
|
105
143
|
|
|
106
144
|
### The plugin
|
|
107
145
|
|
|
108
|
-
Then install the plugin:
|
|
109
|
-
|
|
110
146
|
```bash
|
|
111
147
|
opencode plugin add opencode-courier
|
|
112
148
|
```
|
|
@@ -115,10 +151,15 @@ This installs the package from npm and adds `"opencode-courier"` to `plugins` in
|
|
|
115
151
|
configuration (`~/.config/opencode/opencode.json`). To receive webhooks, replace that entry with the
|
|
116
152
|
object form shown under [Webhooks](#webhooks), which carries a `webhook` option.
|
|
117
153
|
|
|
154
|
+
Install it by name, without a version: OpenCode only checks plugins for updates when their entry is
|
|
155
|
+
not an exact version, so `opencode-courier@0.2.1` is never offered a newer release. How to move an
|
|
156
|
+
entry that carries a version to the name, and what to look at when an update still does not show:
|
|
157
|
+
[Updating the plugin](docs/reference.md#updating-the-plugin).
|
|
158
|
+
|
|
118
159
|
### From a local clone
|
|
119
160
|
|
|
120
161
|
```bash
|
|
121
|
-
git clone
|
|
162
|
+
git clone https://github.com/ivopogace/opencode-courier && cd opencode-courier
|
|
122
163
|
bun install && npm run build
|
|
123
164
|
```
|
|
124
165
|
|
|
@@ -136,12 +177,13 @@ Then list it in `opencode.json` (V2 uses `plugins`, plural). A local plugin path
|
|
|
136
177
|
1. Keep the background server running so sessions can be woken while you are away (`opencode
|
|
137
178
|
service start`; `opencode service status` to check).
|
|
138
179
|
2. Give the agents that run children the permissions their work needs. A child that hits an approval
|
|
139
|
-
prompt has it passed to its parent, which asks you
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
180
|
+
prompt has it passed to its parent, which asks you, but the child waits until you answer, so keep
|
|
181
|
+
prompts for what you want to decide yourself. A child's question goes to the parent the same
|
|
182
|
+
way, so let the agents that run children use the `question` tool if they should be able to ask
|
|
183
|
+
you; OpenCode's default agent may. Choose a web search provider before children search the web
|
|
184
|
+
(run one web search in your own session and answer OpenCode's prompt, or use its "Third-party
|
|
185
|
+
search" setting): otherwise the first child to search shows that prompt in its own session,
|
|
186
|
+
which only you can answer there.
|
|
145
187
|
3. Use `isolate: true` whenever children edit files in parallel. The child's worktree is made from
|
|
146
188
|
the last commit, so an uncommitted `opencode.json` is not there and the child falls back to your
|
|
147
189
|
global config: keep providers and models in the global config, or commit the file. When you are
|
|
@@ -158,8 +200,8 @@ Then list it in `opencode.json` (V2 uses `plugins`, plural). A local plugin path
|
|
|
158
200
|
| `courier_send` | Delivers a message to a session, signed with the sender's id, waking it if idle. |
|
|
159
201
|
| `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. |
|
|
160
202
|
| `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. |
|
|
161
|
-
| `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. |
|
|
162
|
-
| `courier_answer` | Passes
|
|
203
|
+
| `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)). |
|
|
204
|
+
| `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. |
|
|
163
205
|
| `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. |
|
|
164
206
|
| `courier_cancel` | Drops a message scheduled with `courier_later`, e.g. because the child it was waiting for reported first. |
|
|
165
207
|
| `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). |
|
|
@@ -181,11 +223,20 @@ The short version; the long one, with every edge, is [docs/reference.md](docs/re
|
|
|
181
223
|
[More](docs/reference.md#a-child-that-asks-for-permission).
|
|
182
224
|
- **A child that asks a question** with OpenCode's question tool has it shown in the top session.
|
|
183
225
|
When that session asks you the same question, your answer goes to the child's waiting call as if
|
|
184
|
-
you had answered there
|
|
185
|
-
|
|
226
|
+
you had answered there. Whichever answer reaches the child first counts, in its session, in the
|
|
227
|
+
parent's or by `courier_answer`, and the other question is withdrawn; a question stays answerable
|
|
228
|
+
across an interrupted turn or a server restart.
|
|
229
|
+
[More](docs/reference.md#a-child-that-asks-a-question).
|
|
230
|
+
- **A child that shows a form** the plugin cannot pass on, such as OpenCode's web search asking
|
|
231
|
+
for a provider, has the top session told, marked `asks="form"`, with the form's choices: only you
|
|
232
|
+
can answer it, in the child's session. [More](docs/reference.md#a-child-that-shows-a-form).
|
|
186
233
|
- **The plugin remembers.** Each parent's children (`courier_children`), pending `courier_later`
|
|
187
234
|
messages and open questions survive a compaction or a restart; entries are dropped after 14 days.
|
|
188
235
|
[Roster](docs/reference.md#roster), [Scheduled messages](docs/reference.md#scheduled-messages).
|
|
236
|
+
- **One OpenCode server per data directory, preferably.** A second server on the same one, such as
|
|
237
|
+
`opencode serve` next to `opencode service`, shares the plugin's storage: one of the two delivers
|
|
238
|
+
`courier_later` messages, but a child's permission request can only be answered from the server
|
|
239
|
+
that runs the child. [More](docs/reference.md#two-servers-on-one-data-directory).
|
|
189
240
|
- **Worktrees are yours to remove.** An isolated child's worktree is kept until `courier_cleanup`,
|
|
190
241
|
which refuses to drop uncommitted changes or unbranched commits unless told to.
|
|
191
242
|
[More](docs/reference.md#worktree-cleanup).
|
|
@@ -261,34 +312,28 @@ OPENCODE_BIN=$(which opencode) npm run test:e2e # live test, about two minutes
|
|
|
261
312
|
OPENCODE_BIN=$(which opencode) e2e/real-model.sh # smoke test with a real model, not in CI
|
|
262
313
|
```
|
|
263
314
|
|
|
264
|
-
`e2e/run.sh` starts a real OpenCode V2 server in a throwaway project and home
|
|
265
|
-
plugin loaded and
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
with the CLI at its `latest` dist-tag: that job may fail, and its step summary names the version it
|
|
281
|
-
ran on, so a host release that breaks the plugin is seen without blocking the build. Releases start from GitHub and
|
|
282
|
-
end with a maintainer's 2FA approval on npm: [docs/releasing.md](docs/releasing.md). What the plugin
|
|
283
|
-
API does that the plugin had to work around, and what changed when the pin last moved:
|
|
284
|
-
[docs/plugin-api-notes.md](docs/plugin-api-notes.md).
|
|
315
|
+
The live test (`e2e/run.sh`) starts a real OpenCode V2 server in a throwaway project and home
|
|
316
|
+
directory, with this plugin loaded and a scripted OpenAI-compatible stand-in model, and walks every
|
|
317
|
+
tool through the behaviour above. It needs node, npm, bun, git, curl, jq and openssl. New behaviour
|
|
318
|
+
gets a scenario there.
|
|
319
|
+
|
|
320
|
+
The smoke test (`e2e/real-model.sh`) runs the same server with a real model (by default a free one
|
|
321
|
+
on [OpenCode Zen](https://opencode.ai/zen), no key needed) and checks that the parent spawns instead
|
|
322
|
+
of doing the work, ends its turn instead of polling, and is woken by each report;
|
|
323
|
+
`COURIER_SCENARIO=permission` and `COURIER_SCENARIO=question` exercise the two relays. Which models
|
|
324
|
+
pass and what was tuned for them: [docs/real-model.md](docs/real-model.md).
|
|
325
|
+
|
|
326
|
+
CI runs both suites on every push to `main` and every pull request, with the OpenCode CLI at the
|
|
327
|
+
pinned version, and the live suite once more on the newest OpenCode release, where a failure warns
|
|
328
|
+
rather than breaks the build. SonarCloud scans `src/` on `main` and on pull requests. Releases start
|
|
329
|
+
from GitHub and end with a maintainer's 2FA approval on npm: [docs/releasing.md](docs/releasing.md).
|
|
330
|
+
What the plugin API does that the plugin had to work around: [docs/plugin-api-notes.md](docs/plugin-api-notes.md).
|
|
285
331
|
|
|
286
332
|
## Contributing
|
|
287
333
|
|
|
288
334
|
Questions and bug reports go to the [issues](https://github.com/ivopogace/opencode-courier/issues).
|
|
289
335
|
Pull requests are welcome: a change in behaviour comes with a unit test, an `e2e/run.sh` scenario
|
|
290
336
|
and its line in this README or [docs/reference.md](docs/reference.md), and CI must be green.
|
|
291
|
-
Maintainers release from GitHub as [docs/releasing.md](docs/releasing.md) describes.
|
|
292
337
|
|
|
293
338
|
## License
|
|
294
339
|
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded collections of remembered ids: plain `Set`s and `Map`s that drop their oldest entry past
|
|
3
|
+
* a bound. Functions over the built-in types rather than a class, since the process-wide ones are
|
|
4
|
+
* shared with copies of the plugin loaded before an update, which call `.has` and `.add` on them.
|
|
5
|
+
* Imports nothing of the plugin's.
|
|
6
|
+
*/
|
|
7
|
+
/** Adds to a bounded set, dropping the oldest entry past `max`; false when the value was there already. */
|
|
8
|
+
export declare function addBounded<T>(set: Set<T>, value: T, max: number): boolean;
|
|
9
|
+
/** Sets an entry of a bounded map, dropping the oldest entry past `max`; an existing key keeps its place. */
|
|
10
|
+
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,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded collections of remembered ids: plain `Set`s and `Map`s that drop their oldest entry past
|
|
3
|
+
* a bound. Functions over the built-in types rather than a class, since the process-wide ones are
|
|
4
|
+
* shared with copies of the plugin loaded before an update, which call `.has` and `.add` on them.
|
|
5
|
+
* Imports nothing of the plugin's.
|
|
6
|
+
*/
|
|
7
|
+
/** Adds to a bounded set, dropping the oldest entry past `max`; false when the value was there already. */
|
|
8
|
+
export function addBounded(set, value, max) {
|
|
9
|
+
if (set.has(value))
|
|
10
|
+
return false;
|
|
11
|
+
set.add(value);
|
|
12
|
+
if (set.size > max)
|
|
13
|
+
set.delete(set.values().next().value);
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
/** Sets an entry of a bounded map, dropping the oldest entry past `max`; an existing key keeps its place. */
|
|
17
|
+
export function setBounded(map, key, value, max) {
|
|
18
|
+
map.set(key, value);
|
|
19
|
+
if (map.size > max)
|
|
20
|
+
map.delete(map.keys().next().value);
|
|
21
|
+
}
|
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. */
|
|
@@ -23,24 +24,6 @@ export interface CleanupInput {
|
|
|
23
24
|
readonly sessionID: string;
|
|
24
25
|
readonly force?: boolean;
|
|
25
26
|
}
|
|
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
27
|
/** At most this many changes and commits are returned; the reason still counts them all. */
|
|
45
28
|
export declare const MAX_LISTED = 50;
|
|
46
29
|
/**
|
|
@@ -49,6 +32,18 @@ export declare const MAX_LISTED = 50;
|
|
|
49
32
|
* result says what is in it.
|
|
50
33
|
*/
|
|
51
34
|
export declare function cleanup(ports: CleanupPorts, parentID: string, input: CleanupInput): Promise<CleanupResult>;
|
|
35
|
+
/** Names the git to run, by absolute path, overriding the places `GIT_LOCATIONS` lists. */
|
|
36
|
+
export declare const GIT_ENV = "OPENCODE_COURIER_GIT";
|
|
37
|
+
/**
|
|
38
|
+
* Where git is looked for, in order: its usual install locations, never `PATH`, so a writable
|
|
39
|
+
* directory early in `PATH` cannot put another program in its place.
|
|
40
|
+
*/
|
|
41
|
+
export declare const GIT_LOCATIONS: Readonly<Record<"posix" | "win32", readonly string[]>>;
|
|
42
|
+
/**
|
|
43
|
+
* The git to run: `OPENCODE_COURIER_GIT` when it is an absolute path, otherwise the first of
|
|
44
|
+
* `GIT_LOCATIONS` that exists. Throws when there is none.
|
|
45
|
+
*/
|
|
46
|
+
export declare function findGit(env?: Readonly<Record<string, string | undefined>>, platform?: NodeJS.Platform, exists?: (path: string) => boolean): string;
|
|
52
47
|
/** The commit a worktree is on, or undefined when git cannot tell. */
|
|
53
48
|
export declare function headOf(directory: string): Promise<string | undefined>;
|
|
54
49
|
/**
|
package/dist/cleanup.js
CHANGED
|
@@ -1,24 +1,10 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
const reasons = [
|
|
7
|
-
state.changes.length ? `${count(state.changes.length, "uncommitted change")} (${preview(state.changes)})` : "",
|
|
8
|
-
state.commits.length
|
|
9
|
-
? `${count(state.commits.length, "commit")} on no branch, tag or remote (${preview(state.commits)})`
|
|
10
|
-
: "",
|
|
11
|
-
].filter(Boolean);
|
|
12
|
-
return reasons.length ? reasons.join(" and ") : undefined;
|
|
13
|
-
}
|
|
14
|
-
function count(n, noun) {
|
|
15
|
-
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
16
|
-
}
|
|
3
|
+
import { isAbsolute } from "node:path";
|
|
4
|
+
import { keepReason } from "./notices.js";
|
|
5
|
+
import { remove, rosterKey } from "./roster.js";
|
|
17
6
|
/** At most this many changes and commits are returned; the reason still counts them all. */
|
|
18
7
|
export const MAX_LISTED = 50;
|
|
19
|
-
function preview(items) {
|
|
20
|
-
return items.length > 5 ? `${items.slice(0, 5).join(", ")}, ...` : items.join(", ");
|
|
21
|
-
}
|
|
22
8
|
/**
|
|
23
9
|
* Removes the worktree of an isolated child the parent started, and forgets the child. A worktree
|
|
24
10
|
* with uncommitted changes or commits that exist nowhere else is kept unless `force` is set, and the
|
|
@@ -31,7 +17,7 @@ export async function cleanup(ports, parentID, input) {
|
|
|
31
17
|
if (!entry.isolated)
|
|
32
18
|
throw new Error(`${input.sessionID} ran in ${entry.directory}, not in a worktree of its own; there is nothing to remove.`);
|
|
33
19
|
const { directory } = entry;
|
|
34
|
-
const forget = () => ports.storage
|
|
20
|
+
const forget = () => remove(ports.storage, parentID, input.sessionID);
|
|
35
21
|
// With force the state only decides whether there is anything left to remove, so a worktree git
|
|
36
22
|
// can no longer read is still removed.
|
|
37
23
|
const state = await ports
|
|
@@ -59,8 +45,41 @@ export async function cleanup(ports, parentID, input) {
|
|
|
59
45
|
await forget();
|
|
60
46
|
return { sessionID: input.sessionID, directory, outcome: "removed" };
|
|
61
47
|
}
|
|
48
|
+
/** Names the git to run, by absolute path, overriding the places `GIT_LOCATIONS` lists. */
|
|
49
|
+
export const GIT_ENV = "OPENCODE_COURIER_GIT";
|
|
50
|
+
/**
|
|
51
|
+
* Where git is looked for, in order: its usual install locations, never `PATH`, so a writable
|
|
52
|
+
* directory early in `PATH` cannot put another program in its place.
|
|
53
|
+
*/
|
|
54
|
+
export const GIT_LOCATIONS = {
|
|
55
|
+
posix: [
|
|
56
|
+
"/usr/bin/git",
|
|
57
|
+
"/usr/local/bin/git",
|
|
58
|
+
"/opt/homebrew/bin/git",
|
|
59
|
+
// NixOS, and nix-darwin.
|
|
60
|
+
"/run/current-system/sw/bin/git",
|
|
61
|
+
],
|
|
62
|
+
win32: [String.raw `C:\Program Files\Git\cmd\git.exe`, String.raw `C:\Program Files (x86)\Git\cmd\git.exe`],
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* The git to run: `OPENCODE_COURIER_GIT` when it is an absolute path, otherwise the first of
|
|
66
|
+
* `GIT_LOCATIONS` that exists. Throws when there is none.
|
|
67
|
+
*/
|
|
68
|
+
export function findGit(env = process.env, platform = process.platform, exists = existsSync) {
|
|
69
|
+
const named = env[GIT_ENV]?.trim();
|
|
70
|
+
if (named) {
|
|
71
|
+
if (!isAbsolute(named))
|
|
72
|
+
throw new Error(`${GIT_ENV} must be an absolute path, not ${named}.`);
|
|
73
|
+
return named;
|
|
74
|
+
}
|
|
75
|
+
const locations = GIT_LOCATIONS[platform === "win32" ? "win32" : "posix"];
|
|
76
|
+
const found = locations.find((path) => exists(path));
|
|
77
|
+
if (!found)
|
|
78
|
+
throw new Error(`git is in none of ${locations.join(", ")}; set ${GIT_ENV} to its absolute path.`);
|
|
79
|
+
return found;
|
|
80
|
+
}
|
|
62
81
|
function git(directory, args) {
|
|
63
|
-
return new Promise((resolve, reject) => execFile(
|
|
82
|
+
return new Promise((resolve, reject) => execFile(findGit(), ["-C", directory, ...args], { maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => error ? reject(new Error(`git ${args[0]} in ${directory}: ${stderr.trim() || error.message}`)) : resolve(stdout)));
|
|
64
83
|
}
|
|
65
84
|
/** The commit a worktree is on, or undefined when git cannot tell. */
|
|
66
85
|
export function headOf(directory) {
|
package/dist/courier.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Plugin } from "@opencode/plugin";
|
|
2
|
-
import type
|
|
2
|
+
import { type Prompt } from "./notices.js";
|
|
3
3
|
import { type RosterStorage } from "./roster.js";
|
|
4
4
|
type Context = Plugin.Context;
|
|
5
5
|
/** The slice of the plugin context the courier tools use; tests pass a fake. */
|
|
@@ -48,10 +48,6 @@ export interface StatusInput {
|
|
|
48
48
|
export interface ChildrenInput {
|
|
49
49
|
readonly sessionID?: string;
|
|
50
50
|
}
|
|
51
|
-
/** Closes a tool result after which the caller most likely has nothing left to do. */
|
|
52
|
-
export declare const END_TURN = "If nothing else is left to do now, end your turn by replying without calling more tools.";
|
|
53
|
-
export declare function childBrief(parentID: string, task: string): string;
|
|
54
|
-
export declare function envelope(from: string, message: string, attributes?: Record<string, string>): string;
|
|
55
51
|
/** Creates a child session, hands it the task and returns at once; the child reports back with courier_send. */
|
|
56
52
|
export declare function spawn(ports: CourierPorts, parentID: string, input: SpawnInput): Promise<{
|
|
57
53
|
rosterError?: string | undefined;
|
package/dist/courier.js
CHANGED
|
@@ -1,24 +1,5 @@
|
|
|
1
|
+
import { childBrief, envelope } from "./notices.js";
|
|
1
2
|
import { current, record } from "./roster.js";
|
|
2
|
-
/** Closes a tool result after which the caller most likely has nothing left to do. */
|
|
3
|
-
export const END_TURN = "If nothing else is left to do now, end your turn by replying without calling more tools.";
|
|
4
|
-
export function childBrief(parentID, task) {
|
|
5
|
-
return [
|
|
6
|
-
`You were started by session ${parentID} through opencode-courier.`,
|
|
7
|
-
"",
|
|
8
|
-
`When you finish, or need a decision you cannot make yourself, call courier_send with sessionID "${parentID}" and a short report.`,
|
|
9
|
-
"That message wakes the parent. It is the only way the parent hears from you, so do not end without sending it.",
|
|
10
|
-
"If you need the person to decide something, use your question tool; it reaches them through the session that started you.",
|
|
11
|
-
"",
|
|
12
|
-
"Task:",
|
|
13
|
-
task,
|
|
14
|
-
].join("\n");
|
|
15
|
-
}
|
|
16
|
-
export function envelope(from, message, attributes = {}) {
|
|
17
|
-
const extra = Object.entries(attributes)
|
|
18
|
-
.map(([name, value]) => ` ${name}="${value}"`)
|
|
19
|
-
.join("");
|
|
20
|
-
return `<courier from="${from}"${extra}>\n${message}\n</courier>`;
|
|
21
|
-
}
|
|
22
3
|
function titleOf(task) {
|
|
23
4
|
const line = task.trim().split("\n")[0] ?? "";
|
|
24
5
|
return line.length > 60 ? `${line.slice(0, 57)}...` : line;
|
|
@@ -34,6 +15,15 @@ async function inheritedModel(ports, parentID, agent) {
|
|
|
34
15
|
const named = await ports.agent.get({ agentID: agent, location: { directory: ports.directory } });
|
|
35
16
|
return named.data.model ? undefined : parent.model;
|
|
36
17
|
}
|
|
18
|
+
/** Removes a worktree made for a child that was never created; one that cannot be removed stays. */
|
|
19
|
+
async function dropWorktree(ports, directory) {
|
|
20
|
+
try {
|
|
21
|
+
await ports.worktree.remove({ projectID: ports.projectID, directory, force: false });
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// The spawn's own failure is the one to report.
|
|
25
|
+
}
|
|
26
|
+
}
|
|
37
27
|
/** Creates a child session, hands it the task and returns at once; the child reports back with courier_send. */
|
|
38
28
|
export async function spawn(ports, parentID, input) {
|
|
39
29
|
// A failed lookup must not keep the child from starting; it then runs on OpenCode's default.
|
|
@@ -54,9 +44,7 @@ export async function spawn(ports, parentID, input) {
|
|
|
54
44
|
.catch(async (error) => {
|
|
55
45
|
// No session will ever use the fresh worktree, and nothing records it, so it goes now.
|
|
56
46
|
if (directory)
|
|
57
|
-
await ports
|
|
58
|
-
.remove({ projectID: ports.projectID, directory, force: false })
|
|
59
|
-
.catch(() => undefined);
|
|
47
|
+
await dropWorktree(ports, directory);
|
|
60
48
|
throw error;
|
|
61
49
|
});
|
|
62
50
|
// Recorded before the prompt, so a child that exists is on the roster even if prompting fails. A
|