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 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
- # Temporary Holding Version
1
+ # opencode-courier
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![CI](https://github.com/ivopogace/opencode-courier/actions/workflows/ci.yml/badge.svg)](https://github.com/ivopogace/opencode-courier/actions/workflows/ci.yml)
4
+
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 {};
@@ -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 {};