opencode-courier 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +302 -2
- package/dist/cleanup.d.ts +59 -0
- package/dist/cleanup.js +98 -0
- package/dist/courier.d.ts +75 -0
- package/dist/courier.js +122 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +284 -0
- package/dist/later.d.ts +39 -0
- package/dist/later.js +76 -0
- package/dist/roster.d.ts +32 -0
- package/dist/roster.js +44 -0
- package/dist/storage.d.ts +6 -0
- package/dist/storage.js +12 -0
- package/dist/webhook.d.ts +103 -0
- package/dist/webhook.js +396 -0
- package/package.json +44 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ivo Pogace
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,303 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-courier
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/ivopogace/opencode-courier/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
An [OpenCode](https://github.com/anomalyco/opencode) V2 plugin that lets one session start other
|
|
6
|
+
sessions, message them, and be woken by them, without polling.
|
|
7
|
+
|
|
8
|
+
A parent session calls `courier_spawn`, gets a session id back immediately and ends its turn. The
|
|
9
|
+
child works on its own and, when it is done or stuck, calls `courier_send` with the parent's id.
|
|
10
|
+
That message lands in the parent's inbox and OpenCode starts a new turn for the parent if it is
|
|
11
|
+
idle.
|
|
12
|
+
|
|
13
|
+
> **Status: early.** Passes an end-to-end test inside a live OpenCode V2 server
|
|
14
|
+
> (`opencode2 v0.0.0-beta-19271`) driven by a scripted stand-in model (`e2e/run.sh`); not yet
|
|
15
|
+
> tried with a real model.
|
|
16
|
+
|
|
17
|
+
## How the wake works
|
|
18
|
+
|
|
19
|
+
There is no polling anywhere. `courier_send` calls the plugin API's `session.synthetic`, which
|
|
20
|
+
admits a message into the target session's inbox and, unless `resume: false` is passed, calls
|
|
21
|
+
`execution.wake` on it (`packages/core/src/session/session.ts` on OpenCode's `beta` branch).
|
|
22
|
+
OpenCode's own background subagents report to their parent the same way
|
|
23
|
+
(`packages/core/src/session/subagent-completion.ts`).
|
|
24
|
+
|
|
25
|
+
Delivery is `steer` by default (injected into the target's running turn, or starts one if idle);
|
|
26
|
+
`queue: true` waits until the current turn ends.
|
|
27
|
+
|
|
28
|
+
## Tools
|
|
29
|
+
|
|
30
|
+
| Tool | Does |
|
|
31
|
+
|---|---|
|
|
32
|
+
| `courier_spawn` | Creates a session (optionally in its own git worktree with `isolate: true`), sends it the task plus a brief naming the parent and how to report back, and returns at once. |
|
|
33
|
+
| `courier_send` | Delivers a message to a session, signed with the sender's id, waking it if idle. |
|
|
34
|
+
| `courier_status` | One look at a session: outcome, idle time and last reply. For check-ins, not for waiting. |
|
|
35
|
+
| `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. |
|
|
36
|
+
| `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. |
|
|
37
|
+
| `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. |
|
|
38
|
+
| `courier_cancel` | Drops a message scheduled with `courier_later`, e.g. because the child it was waiting for reported first. |
|
|
39
|
+
| `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). |
|
|
40
|
+
| `courier_unsubscribe` | Drops one topic, or all of a session's, e.g. once its pull request is merged. |
|
|
41
|
+
|
|
42
|
+
### Roster
|
|
43
|
+
|
|
44
|
+
`courier_spawn` records each child under its parent in the plugin's storage, so a parent that has
|
|
45
|
+
lost track after a compaction or a server restart can call `courier_children` to find them again.
|
|
46
|
+
A child that can no longer be looked up is still listed, with the error instead of its state.
|
|
47
|
+
Entries are dropped 14 days after the child was started, when that parent's roster is read or
|
|
48
|
+
the plugin is next loaded, except isolated children whose worktree is still there (see
|
|
49
|
+
[Worktree cleanup](#worktree-cleanup)). If the roster cannot be written, the child still gets its task and
|
|
50
|
+
`courier_spawn` says it is not on the list.
|
|
51
|
+
|
|
52
|
+
### Worktree cleanup
|
|
53
|
+
|
|
54
|
+
An isolated child works in a git worktree under OpenCode's data directory
|
|
55
|
+
(`…/opencode/worktree/<project>/<name>`, on a detached HEAD), and nothing removes it on its own.
|
|
56
|
+
When the parent has what it needs from the child, it calls `courier_cleanup { sessionID }`, which
|
|
57
|
+
removes the worktree through the plugin API's `worktree.remove` and drops the child from
|
|
58
|
+
`courier_children`.
|
|
59
|
+
|
|
60
|
+
The worktree is kept, and the result says why, when it holds work that would otherwise be lost:
|
|
61
|
+
|
|
62
|
+
- uncommitted changes, untracked files included (ignored files, such as `node_modules`, are not
|
|
63
|
+
work and go with the worktree);
|
|
64
|
+
- commits that are on no branch, tag or remote-tracking ref, which is where a child's commits on
|
|
65
|
+
its detached HEAD end up. A commit on a branch survives the removal, so it does not count, and
|
|
66
|
+
neither do commits the worktree was made from (`courier_spawn` records that commit), such as a
|
|
67
|
+
parent's own unbranched work when an isolated child spawns isolated children of its own.
|
|
68
|
+
|
|
69
|
+
The result lists up to 50 changed paths (an untracked directory counts once) and 50 commits. Commit
|
|
70
|
+
or branch what you want to keep (`git -C <worktree> branch <name>` keeps its commits), or call
|
|
71
|
+
`courier_cleanup` again with `force: true` to discard it; `force` also removes a worktree git can
|
|
72
|
+
no longer read. A worktree whose directory is already gone is just dropped from the list; git
|
|
73
|
+
forgets its registration on its next `git worktree prune` or `git gc`.
|
|
74
|
+
|
|
75
|
+
Cleanup is explicit only. A child reporting back does not mean the parent has merged, reviewed or
|
|
76
|
+
even read its work, and the parent may still send it more to do in the same worktree, so the
|
|
77
|
+
plugin never removes one on its own. Isolated children whose worktree still exists are kept on
|
|
78
|
+
`courier_children` past the 14 days, so they can still be found and cleaned up.
|
|
79
|
+
|
|
80
|
+
`courier_cleanup` cannot tell whether the child is still running, so call it after the child has
|
|
81
|
+
reported. It works on the calling session's own children.
|
|
82
|
+
|
|
83
|
+
### Scheduled messages
|
|
84
|
+
|
|
85
|
+
Pending `courier_later` messages are kept in the plugin's storage, and every loaded copy of the
|
|
86
|
+
plugin checks for due ones every 15 seconds, so a message can arrive up to about 15 seconds late.
|
|
87
|
+
OpenCode loads the plugin once per project location; the copies share one claim set, so each
|
|
88
|
+
message is delivered once.
|
|
89
|
+
|
|
90
|
+
They survive a server restart. After a start, OpenCode loads plugins for a project the first time
|
|
91
|
+
that project is used, so messages that fell due while it was down are delivered then, not at the
|
|
92
|
+
moment the server comes back. A crash between delivering a message and forgetting it can deliver
|
|
93
|
+
it twice after the restart; a lost check-in would be worse.
|
|
94
|
+
|
|
95
|
+
### Webhooks
|
|
96
|
+
|
|
97
|
+
With the `webhook` option set (see [Receiving webhooks](#receiving-webhooks)), the plugin listens
|
|
98
|
+
for HTTP deliveries and turns them into messages for subscribed sessions:
|
|
99
|
+
|
|
100
|
+
- `POST /github` takes GitHub webhook deliveries. A pull request review, a review comment, a
|
|
101
|
+
comment, a pull request or issue being opened, reopened, closed (or merged) or marked ready for
|
|
102
|
+
review, or a completed check run, check suite or workflow run on a pull request goes to the
|
|
103
|
+
sessions subscribed to `owner/repo#N` and to `owner/repo`; anything else with a repository (a
|
|
104
|
+
push, a release) goes to `owner/repo` only. Pings, CI runs that have not completed, and other
|
|
105
|
+
pull request and issue actions (pushes to the branch, edits, labels, assignments, review
|
|
106
|
+
requests) wake nobody.
|
|
107
|
+
- `POST /hook/<name>` takes anything else, for sessions subscribed to `<name>`. A JSON body's
|
|
108
|
+
`text`, `summary` or `message` field is delivered, otherwise the body itself.
|
|
109
|
+
|
|
110
|
+
Every delivery must carry an `X-Hub-Signature-256` header: `sha256=` followed by exactly 64 hex
|
|
111
|
+
digits, the HMAC-SHA256 under the shared secret. For GitHub that is of the raw body, as GitHub sends it. For
|
|
112
|
+
`/hook/<name>` it is of the name, a newline and the body, so a captured delivery cannot be sent to
|
|
113
|
+
another topic:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
sig=$(printf '%s\n%s' deploys "$body" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)
|
|
117
|
+
curl -X POST -H "x-hub-signature-256: sha256=$sig" --data-binary "$body" http://127.0.0.1:4097/hook/deploys
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
A missing or wrong signature gets `401`, and the body is not parsed. The check is constant-time.
|
|
121
|
+
Bodies over 1 MiB (`maxBytes`) get `413`. A delivered event gets `202`, with the number of
|
|
122
|
+
sessions it reached, which can be 0. The digests of the last 1000 accepted deliveries are remembered in
|
|
123
|
+
memory (as lowercase hex, so re-casing the header does not get around it), and a delivery already
|
|
124
|
+
accepted gets `200 already delivered`. One that reached nobody because every delivery to a session
|
|
125
|
+
failed is forgotten again, so it can be retried. That stops replays of
|
|
126
|
+
a captured delivery, and it also means a GitHub Redeliver of a delivery that already arrived is
|
|
127
|
+
ignored. Redelivering one that failed works. Generic senders that post the same text twice should
|
|
128
|
+
add something unique, such as a timestamp, to the body.
|
|
129
|
+
|
|
130
|
+
A session that OpenCode no longer knows loses its subscriptions the next time a delivery for it
|
|
131
|
+
fails, and `courier_subscribe` refuses a session id that does not exist.
|
|
132
|
+
|
|
133
|
+
A session sees a short summary (event, repository and number, who, state or conclusion, link, and
|
|
134
|
+
at most 1500 characters of a review or comment body), wrapped in `<courier from="github"
|
|
135
|
+
event="...">` and followed by a note that it is outside text, to be treated as data. Review and
|
|
136
|
+
comment bodies are written by whoever can comment on the repository, so subscribe sessions only to
|
|
137
|
+
repositories whose commenters you trust with your agent's attention. The server log gets one line
|
|
138
|
+
per delivery (event, delivery id, number of sessions), never the payload or the secret.
|
|
139
|
+
|
|
140
|
+
GitHub does not report check suites on pull requests from forks (`pull_requests` is empty), so CI
|
|
141
|
+
results for those reach `owner/repo` subscribers only. There is no GitHub event for a merge
|
|
142
|
+
conflict.
|
|
143
|
+
|
|
144
|
+
## Install
|
|
145
|
+
|
|
146
|
+
Requires OpenCode V2 (`npm install -g @opencode-ai/cli@beta`, command `opencode2`).
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
git clone <this repo> && cd opencode-courier
|
|
150
|
+
bun install && npm run build
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Then list it in `opencode.json` (V2 uses `plugins`, plural). A local plugin path must be a
|
|
154
|
+
**directory**; OpenCode loads its `index.js`, and ignores a path to a file with a warning:
|
|
155
|
+
|
|
156
|
+
```jsonc
|
|
157
|
+
{
|
|
158
|
+
"plugins": ["/absolute/path/to/opencode-courier/dist"]
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
To receive webhooks, give the plugin a `webhook` option instead (see below).
|
|
163
|
+
|
|
164
|
+
Once published to npm, `opencode2 plugin add opencode-courier` installs it and adds it to the
|
|
165
|
+
global configuration.
|
|
166
|
+
|
|
167
|
+
## Receiving webhooks
|
|
168
|
+
|
|
169
|
+
The receiver is off unless the plugin has a `webhook` option. Put it in the **global** config
|
|
170
|
+
(`~/.config/opencode/opencode.json`), since there is one receiver per OpenCode server:
|
|
171
|
+
|
|
172
|
+
```jsonc
|
|
173
|
+
{
|
|
174
|
+
"plugins": [
|
|
175
|
+
{
|
|
176
|
+
"package": "/absolute/path/to/opencode-courier/dist",
|
|
177
|
+
"options": { "webhook": { "port": 4097, "secretFile": "~/.config/opencode/courier-webhook-secret" } }
|
|
178
|
+
}
|
|
179
|
+
]
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`"webhook": true` takes every default. If the option is given more than once, for example in a
|
|
184
|
+
project's config as well, the first location to load wins, and the others log that their settings
|
|
185
|
+
are ignored.
|
|
186
|
+
|
|
187
|
+
| Option | Default | |
|
|
188
|
+
|---|---|---|
|
|
189
|
+
| `port` | `4097` | Port to listen on. |
|
|
190
|
+
| `host` | `127.0.0.1` | Address to bind. Only this machine can reach the default. |
|
|
191
|
+
| `secretFile` | | File holding the shared secret (`~` is expanded). |
|
|
192
|
+
| `secretEnv` | `COURIER_WEBHOOK_SECRET` | Environment variable holding it, when there is no `secretFile`. |
|
|
193
|
+
| `maxBytes` | `1048576` | Largest body accepted. |
|
|
194
|
+
|
|
195
|
+
The secret is never read from `opencode.json` itself (a `secret` key is refused), so the config
|
|
196
|
+
can be committed. Make one with `openssl rand -hex 32 > ~/.config/opencode/courier-webhook-secret`
|
|
197
|
+
and `chmod 600` it. A file is the safer choice with `opencode2 service start`, whose environment
|
|
198
|
+
may not be your shell's. Without a usable secret the receiver does not start, and the server log
|
|
199
|
+
says why.
|
|
200
|
+
|
|
201
|
+
On GitHub, add a webhook to the repository (Settings → Webhooks) with content type
|
|
202
|
+
`application/json`, the same secret, and the events you want (pull request reviews, review
|
|
203
|
+
comments, issue comments, pull requests, check suites or workflow runs). GitHub must reach the
|
|
204
|
+
receiver, and by default it only listens on `127.0.0.1`: forward a public URL to it with a tunnel
|
|
205
|
+
you trust (`cloudflared tunnel --url http://127.0.0.1:4097`, `ngrok http 4097`, or
|
|
206
|
+
`smee --url https://smee.io/<channel> --target http://127.0.0.1:4097/github`, which needs no
|
|
207
|
+
inbound port at all) and use `<public URL>/github` as the payload URL. Whatever you expose, only
|
|
208
|
+
signed deliveries are acted on.
|
|
209
|
+
|
|
210
|
+
The receiver starts when OpenCode loads the plugin, which after a server start happens the first
|
|
211
|
+
time a project is used. Until then deliveries fail; GitHub does not retry them on its own, but
|
|
212
|
+
lists them under Recent Deliveries with a Redeliver button.
|
|
213
|
+
|
|
214
|
+
## Using it
|
|
215
|
+
|
|
216
|
+
1. Keep the background server running so sessions can be woken while you are away
|
|
217
|
+
(`opencode2 service start`; `opencode2 service status` to check).
|
|
218
|
+
2. Give the agents that run children permissions that don't need a human; a child waiting on an
|
|
219
|
+
approval prompt never reports back.
|
|
220
|
+
3. Use `isolate: true` whenever children edit files in parallel. The child's worktree is made
|
|
221
|
+
from the last commit, so an uncommitted `opencode.json` is not there and the child falls back
|
|
222
|
+
to your global config: keep providers and models in the global config, or commit the file.
|
|
223
|
+
When you are done with an isolated child, `courier_cleanup` it so its worktree does not linger.
|
|
224
|
+
4. A child that crashes before calling `courier_send` never wakes the parent. When you spawn a
|
|
225
|
+
long-running child, also `courier_later` a check-in for yourself, and `courier_cancel` it when
|
|
226
|
+
the child reports.
|
|
227
|
+
|
|
228
|
+
## Roadmap
|
|
229
|
+
|
|
230
|
+
Tracked as [issues](https://github.com/ivopogace/opencode-courier/issues):
|
|
231
|
+
|
|
232
|
+
- [#5](https://github.com/ivopogace/opencode-courier/issues/5) **Smoke test with a real model.**
|
|
233
|
+
- [#6](https://github.com/ivopogace/opencode-courier/issues/6) **Publish to npm.**
|
|
234
|
+
|
|
235
|
+
## Development
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
bun install
|
|
239
|
+
bun test # unit tests, with a fake plugin context
|
|
240
|
+
npm run typecheck
|
|
241
|
+
npm run build # emits dist/
|
|
242
|
+
OPENCODE_BIN=$(which opencode2) npm run test:e2e # live test, see below
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
`e2e/run.sh` starts a real OpenCode V2 server in a throwaway project and home directory, with this
|
|
246
|
+
plugin loaded and `e2e/mock-model.mjs` as the model: an OpenAI-compatible server that replies from
|
|
247
|
+
a fixed script, so no API key is needed. It checks that a parent's spawn completes, that the parent
|
|
248
|
+
gets a new turn after its own has ended once the child reports (shared and `isolate: true`), that
|
|
249
|
+
`courier_status` reports and fails readably, that a `courier_later` message wakes an idle parent,
|
|
250
|
+
that a cancelled one never arrives, that a pending one is delivered after a server restart, that
|
|
251
|
+
`courier_children` lists the two children a parent spawned, before and after that restart, that
|
|
252
|
+
a recorded GitHub review delivery (`e2e/fixtures/pull_request_review.json`), signed, wakes an idle
|
|
253
|
+
session subscribed with `courier_subscribe`, once, while unsigned and wrongly signed ones are
|
|
254
|
+
refused, and that `courier_cleanup` removes an isolated child's clean worktree but keeps one with an
|
|
255
|
+
uncommitted file until asked with `force`. It takes about two minutes and needs node, bun, git,
|
|
256
|
+
curl, jq and openssl.
|
|
257
|
+
|
|
258
|
+
CI (`.github/workflows/ci.yml`) runs both on every push to `main` and every pull request, with the
|
|
259
|
+
OpenCode CLI at the same version as the pinned plugin API.
|
|
260
|
+
|
|
261
|
+
### Releasing
|
|
262
|
+
|
|
263
|
+
`.github/workflows/release.yml` publishes to npm on a `v*` tag. It runs the CI workflow first,
|
|
264
|
+
checks that the tag matches the `version` in `package.json`, builds, publishes from the `npm`
|
|
265
|
+
environment with provenance, and then creates a GitHub release with generated notes. A
|
|
266
|
+
prerelease version (`1.2.0-beta.1`) goes to the `next` dist-tag and is marked as a prerelease.
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
npm version patch # bumps package.json, commits, tags vX.Y.Z
|
|
270
|
+
git push --follow-tags
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
It authenticates with npm trusted publishing (OIDC), which needs no stored token: on npmjs.com,
|
|
274
|
+
the package's trusted publisher is this repository, workflow `release.yml`, environment `npm`.
|
|
275
|
+
Until that is set up, npm falls back to an access token in the repository secret `NPM_TOKEN`.
|
|
276
|
+
|
|
277
|
+
CI also checks the package as published: `publint` for `package.json` and `exports`, and
|
|
278
|
+
`@arethetypeswrong/cli` for the type declarations.
|
|
279
|
+
|
|
280
|
+
The plugin API is still beta and pinned to an exact version in `package.json`; bump it
|
|
281
|
+
deliberately and re-run both test suites.
|
|
282
|
+
|
|
283
|
+
### Notes on the V2 plugin API
|
|
284
|
+
|
|
285
|
+
Found while testing against `0.0.0-beta-19271`:
|
|
286
|
+
|
|
287
|
+
- A plugin tool is only reachable through code mode's `execute` tool unless it is registered with
|
|
288
|
+
`options: { codemode: false }`. The courier tools are direct tools.
|
|
289
|
+
- A tool whose result `metadata` holds an `undefined` value never completes: the call stays
|
|
290
|
+
`running` and no error is reported. Results here drop `undefined` keys.
|
|
291
|
+
- A plugin cannot add an HTTP route to OpenCode's own server. The nearest thing, `rpc.register`,
|
|
292
|
+
is reached through the authenticated `/api/rpc` endpoint with a JSON envelope, so neither
|
|
293
|
+
GitHub's headers nor the raw body its signature covers would get through. The webhook receiver
|
|
294
|
+
is therefore its own small listener inside the OpenCode process, shared by the plugin's
|
|
295
|
+
per-location instances. It waits for the previous listener to finish closing before it binds,
|
|
296
|
+
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
|
|
297
|
+
`plugins`, which takes a local directory as `package` too.
|
|
298
|
+
- OpenCode errors such as `Session.NotFoundError` can arrive with an empty message, so the tools
|
|
299
|
+
rethrow them with the tag and session id.
|
|
300
|
+
|
|
301
|
+
## License
|
|
302
|
+
|
|
303
|
+
MIT, see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
2
|
+
import { type RosterStorage } from "./roster.js";
|
|
3
|
+
type Context = Plugin.Context;
|
|
4
|
+
/** What would be lost by removing a worktree. */
|
|
5
|
+
export interface WorktreeState {
|
|
6
|
+
/** Paths with uncommitted changes, untracked files included, as `git status --porcelain` lists them. */
|
|
7
|
+
readonly changes: readonly string[];
|
|
8
|
+
/**
|
|
9
|
+
* Commits reachable from the worktree's HEAD but from no branch, tag or remote-tracking ref, nor
|
|
10
|
+
* from the commit the worktree was made from, newest first.
|
|
11
|
+
*/
|
|
12
|
+
readonly commits: readonly string[];
|
|
13
|
+
}
|
|
14
|
+
export interface CleanupPorts {
|
|
15
|
+
readonly storage: RosterStorage;
|
|
16
|
+
readonly worktree: Pick<Context["worktree"], "remove">;
|
|
17
|
+
/** The plugin's own location, for roster entries recorded before they carried their source. */
|
|
18
|
+
readonly directory: string;
|
|
19
|
+
/** The worktree's state, or undefined when its directory no longer exists; `base` is the commit it was made from. */
|
|
20
|
+
readonly inspect: (directory: string, base?: string) => Promise<WorktreeState | undefined>;
|
|
21
|
+
}
|
|
22
|
+
export interface CleanupInput {
|
|
23
|
+
readonly sessionID: string;
|
|
24
|
+
readonly force?: boolean;
|
|
25
|
+
}
|
|
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
|
+
/** At most this many changes and commits are returned; the reason still counts them all. */
|
|
45
|
+
export declare const MAX_LISTED = 50;
|
|
46
|
+
/**
|
|
47
|
+
* Removes the worktree of an isolated child the parent started, and forgets the child. A worktree
|
|
48
|
+
* with uncommitted changes or commits that exist nowhere else is kept unless `force` is set, and the
|
|
49
|
+
* result says what is in it.
|
|
50
|
+
*/
|
|
51
|
+
export declare function cleanup(ports: CleanupPorts, parentID: string, input: CleanupInput): Promise<CleanupResult>;
|
|
52
|
+
/** The commit a worktree is on, or undefined when git cannot tell. */
|
|
53
|
+
export declare function headOf(directory: string): Promise<string | undefined>;
|
|
54
|
+
/**
|
|
55
|
+
* Reads a worktree's state with git; undefined when the directory is gone. Commits reachable from
|
|
56
|
+
* `base`, the commit the worktree was made from, are not its own work and are not listed.
|
|
57
|
+
*/
|
|
58
|
+
export declare function inspectWorktree(directory: string, base?: string): Promise<WorktreeState | undefined>;
|
|
59
|
+
export {};
|
package/dist/cleanup.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { rosterKey } from "./roster.js";
|
|
4
|
+
/** Why a worktree in this state must be kept, or undefined when removing it loses nothing. */
|
|
5
|
+
export function keepReason(state) {
|
|
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
|
+
}
|
|
17
|
+
/** At most this many changes and commits are returned; the reason still counts them all. */
|
|
18
|
+
export const MAX_LISTED = 50;
|
|
19
|
+
function preview(items) {
|
|
20
|
+
return items.length > 5 ? `${items.slice(0, 5).join(", ")}, ...` : items.join(", ");
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Removes the worktree of an isolated child the parent started, and forgets the child. A worktree
|
|
24
|
+
* with uncommitted changes or commits that exist nowhere else is kept unless `force` is set, and the
|
|
25
|
+
* result says what is in it.
|
|
26
|
+
*/
|
|
27
|
+
export async function cleanup(ports, parentID, input) {
|
|
28
|
+
const entry = (await ports.storage.get(rosterKey(parentID, input.sessionID)));
|
|
29
|
+
if (!entry)
|
|
30
|
+
throw new Error(`${input.sessionID} is not on the courier_children list of ${parentID}.`);
|
|
31
|
+
if (!entry.isolated)
|
|
32
|
+
throw new Error(`${input.sessionID} ran in ${entry.directory}, not in a worktree of its own; there is nothing to remove.`);
|
|
33
|
+
const { directory } = entry;
|
|
34
|
+
const forget = () => ports.storage.remove(rosterKey(parentID, input.sessionID));
|
|
35
|
+
// With force the state only decides whether there is anything left to remove, so a worktree git
|
|
36
|
+
// can no longer read is still removed.
|
|
37
|
+
const state = await ports
|
|
38
|
+
.inspect(directory, entry.base)
|
|
39
|
+
.catch((error) => (input.force ? { changes: [], commits: [] } : Promise.reject(error)));
|
|
40
|
+
if (!state) {
|
|
41
|
+
await forget();
|
|
42
|
+
return { sessionID: input.sessionID, directory, outcome: "gone" };
|
|
43
|
+
}
|
|
44
|
+
const reason = keepReason(state);
|
|
45
|
+
if (reason && !input.force)
|
|
46
|
+
return {
|
|
47
|
+
sessionID: input.sessionID,
|
|
48
|
+
directory,
|
|
49
|
+
outcome: "kept",
|
|
50
|
+
reason,
|
|
51
|
+
changes: state.changes.slice(0, MAX_LISTED),
|
|
52
|
+
commits: state.commits.slice(0, MAX_LISTED),
|
|
53
|
+
};
|
|
54
|
+
await ports.worktree.remove({
|
|
55
|
+
location: { directory: entry.source ?? ports.directory },
|
|
56
|
+
directory,
|
|
57
|
+
force: input.force === true,
|
|
58
|
+
});
|
|
59
|
+
await forget();
|
|
60
|
+
return { sessionID: input.sessionID, directory, outcome: "removed" };
|
|
61
|
+
}
|
|
62
|
+
function git(directory, args) {
|
|
63
|
+
return new Promise((resolve, reject) => execFile("git", ["-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
|
+
}
|
|
65
|
+
/** The commit a worktree is on, or undefined when git cannot tell. */
|
|
66
|
+
export function headOf(directory) {
|
|
67
|
+
return git(directory, ["rev-parse", "HEAD"]).then((out) => out.trim() || undefined, () => undefined);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Reads a worktree's state with git; undefined when the directory is gone. Commits reachable from
|
|
71
|
+
* `base`, the commit the worktree was made from, are not its own work and are not listed.
|
|
72
|
+
*/
|
|
73
|
+
export async function inspectWorktree(directory, base) {
|
|
74
|
+
if (!existsSync(directory))
|
|
75
|
+
return undefined;
|
|
76
|
+
// An untracked directory is one entry, not every file in it.
|
|
77
|
+
const status = await git(directory, ["status", "--porcelain=v1", "-z", "--untracked-files=normal"]);
|
|
78
|
+
const changes = [];
|
|
79
|
+
const records = status.split("\0").filter(Boolean);
|
|
80
|
+
for (let i = 0; i < records.length; i++) {
|
|
81
|
+
const record = records[i];
|
|
82
|
+
changes.push(record.slice(3));
|
|
83
|
+
// A rename or copy is followed by its source path in a record of its own.
|
|
84
|
+
if ("RC".includes(record[0]) || "RC".includes(record[1]))
|
|
85
|
+
i++;
|
|
86
|
+
}
|
|
87
|
+
const log = await git(directory, [
|
|
88
|
+
"log",
|
|
89
|
+
"--format=%h %s",
|
|
90
|
+
"HEAD",
|
|
91
|
+
"--not",
|
|
92
|
+
"--branches",
|
|
93
|
+
"--tags",
|
|
94
|
+
"--remotes",
|
|
95
|
+
...(base ? [base] : []),
|
|
96
|
+
]);
|
|
97
|
+
return { changes, commits: log.split("\n").filter(Boolean) };
|
|
98
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
2
|
+
import { type RosterStorage } from "./roster.js";
|
|
3
|
+
type Context = Plugin.Context;
|
|
4
|
+
/** The slice of the plugin context the courier tools use; tests pass a fake. */
|
|
5
|
+
export interface CourierPorts {
|
|
6
|
+
readonly session: Pick<Context["session"], "create" | "prompt" | "synthetic" | "get" | "context">;
|
|
7
|
+
readonly worktree: Pick<Context["worktree"], "create" | "remove">;
|
|
8
|
+
/** The commit a directory's checkout is on, or undefined; recorded as an isolated child's base. */
|
|
9
|
+
readonly head: (directory: string) => Promise<string | undefined>;
|
|
10
|
+
readonly storage: RosterStorage;
|
|
11
|
+
readonly directory: string;
|
|
12
|
+
readonly now: () => number;
|
|
13
|
+
}
|
|
14
|
+
export interface SpawnInput {
|
|
15
|
+
readonly task: string;
|
|
16
|
+
readonly title?: string;
|
|
17
|
+
readonly agent?: string;
|
|
18
|
+
readonly isolate?: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface SendInput {
|
|
21
|
+
readonly sessionID: string;
|
|
22
|
+
readonly message: string;
|
|
23
|
+
readonly queue?: boolean;
|
|
24
|
+
}
|
|
25
|
+
export interface StatusInput {
|
|
26
|
+
readonly sessionID: string;
|
|
27
|
+
}
|
|
28
|
+
export interface ChildrenInput {
|
|
29
|
+
readonly sessionID?: string;
|
|
30
|
+
}
|
|
31
|
+
export declare function childBrief(parentID: string, task: string): string;
|
|
32
|
+
export declare function envelope(from: string, message: string, attributes?: Record<string, string>): string;
|
|
33
|
+
/** Creates a child session, hands it the task and returns at once; the child reports back with courier_send. */
|
|
34
|
+
export declare function spawn(ports: CourierPorts, parentID: string, input: SpawnInput): Promise<{
|
|
35
|
+
rosterError?: string | undefined;
|
|
36
|
+
sessionID: string;
|
|
37
|
+
directory: string;
|
|
38
|
+
}>;
|
|
39
|
+
/** Drops a message into another session's inbox; OpenCode wakes that session if it is idle. */
|
|
40
|
+
export declare function send(ports: CourierPorts, from: string, input: SendInput): Promise<{
|
|
41
|
+
messageID: string;
|
|
42
|
+
}>;
|
|
43
|
+
/** A one-off look at a session, for check-ins; not meant to be called in a loop. */
|
|
44
|
+
export declare function status(ports: CourierPorts, input: StatusInput): Promise<Partial<{
|
|
45
|
+
sessionID: string;
|
|
46
|
+
title: string | undefined;
|
|
47
|
+
parentID: string | undefined;
|
|
48
|
+
outcome: "succeeded" | "failed" | "interrupted" | undefined;
|
|
49
|
+
updated: number;
|
|
50
|
+
idle: number | undefined;
|
|
51
|
+
lastText: string | undefined;
|
|
52
|
+
}>>;
|
|
53
|
+
/** The sessions a parent started, each with what courier_status reports, or the error it gave. */
|
|
54
|
+
export declare function listChildren(ports: CourierPorts, parentID: string): Promise<({
|
|
55
|
+
directory: string;
|
|
56
|
+
isolated: boolean;
|
|
57
|
+
created: number;
|
|
58
|
+
sessionID?: string | undefined;
|
|
59
|
+
title?: string | undefined;
|
|
60
|
+
parentID?: string | undefined;
|
|
61
|
+
outcome?: "succeeded" | "failed" | "interrupted" | undefined;
|
|
62
|
+
updated?: number | undefined;
|
|
63
|
+
idle?: number | undefined;
|
|
64
|
+
lastText?: string | undefined;
|
|
65
|
+
} | {
|
|
66
|
+
error: string;
|
|
67
|
+
directory: string;
|
|
68
|
+
isolated: boolean;
|
|
69
|
+
created: number;
|
|
70
|
+
sessionID: string;
|
|
71
|
+
title: string;
|
|
72
|
+
})[]>;
|
|
73
|
+
/** A readable message for a failed courier call; OpenCode's own errors can carry an empty message. */
|
|
74
|
+
export declare function describeFailure(tool: string, error: unknown): Error;
|
|
75
|
+
export {};
|