opencode-courier 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +124 -20
- package/dist/bounded.d.ts +2 -4
- package/dist/bounded.js +2 -4
- package/dist/cleanup.d.ts +8 -3
- package/dist/cleanup.js +24 -12
- package/dist/courier.d.ts +116 -11
- package/dist/courier.js +239 -55
- package/dist/group.d.ts +86 -0
- package/dist/group.js +247 -0
- package/dist/hub.d.ts +52 -77
- package/dist/hub.js +68 -65
- package/dist/index.d.ts +4 -8
- package/dist/index.js +27 -20
- package/dist/json.d.ts +4 -0
- package/dist/json.js +11 -0
- package/dist/later.d.ts +6 -6
- package/dist/later.js +6 -5
- package/dist/limits.d.ts +65 -0
- package/dist/limits.js +109 -0
- package/dist/notices.d.ts +125 -15
- package/dist/notices.js +304 -25
- package/dist/question/answer.js +6 -9
- package/dist/question/lifecycle.d.ts +10 -19
- package/dist/question/lifecycle.js +10 -19
- package/dist/question/pure.d.ts +3 -4
- package/dist/question/pure.js +2 -3
- package/dist/question/relay.js +13 -27
- package/dist/question/runtime.d.ts +2 -3
- package/dist/question/runtime.js +2 -3
- package/dist/relay.d.ts +2 -4
- package/dist/relay.js +2 -4
- package/dist/report.d.ts +48 -0
- package/dist/report.js +88 -0
- package/dist/roster.d.ts +29 -17
- package/dist/roster.js +57 -26
- package/dist/skill.d.ts +13 -0
- package/dist/skill.js +9 -0
- package/dist/stop.d.ts +41 -0
- package/dist/stop.js +120 -0
- package/dist/tools.d.ts +20 -0
- package/dist/tools.js +81 -12
- package/dist/tree.d.ts +102 -0
- package/dist/tree.js +58 -0
- package/dist/version.d.ts +4 -10
- package/dist/version.js +4 -10
- package/dist/watch.d.ts +55 -18
- package/dist/watch.js +219 -34
- package/dist/webhook.d.ts +10 -15
- package/dist/webhook.js +11 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -73,17 +73,19 @@ it adds:
|
|
|
73
73
|
- **Webhooks:** GitHub reviews, comments and CI runs, or any signed POST, wake the
|
|
74
74
|
[subscribed](#webhooks) session.
|
|
75
75
|
- **Worktree isolation:** `isolate: true` gives a child its own git worktree, so children can edit
|
|
76
|
-
files in parallel.
|
|
76
|
+
files in parallel. An isolated child that splits hands its committed work down: its isolated
|
|
77
|
+
children start from its HEAD, and each isolated child commits on `courier/<its session ID>` and
|
|
78
|
+
reports that branch for its parent to merge ([nested isolation](docs/reference.md#nested-isolation)).
|
|
77
79
|
- **Relaying to you:** a child's permission requests and questions reach you through the parent,
|
|
78
80
|
and your answers go back; a form only you can answer is pointed out to you.
|
|
79
81
|
|
|
80
82
|
## How it works
|
|
81
83
|
|
|
82
84
|
A parent session calls `courier_spawn`, gets a session id back immediately and ends its turn. The
|
|
83
|
-
child works on its own and, when it is done or stuck, calls `courier_send` with the parent's id
|
|
84
|
-
That message lands in the parent's inbox and OpenCode starts a new
|
|
85
|
-
idle. A child whose turn fails instead, so that it cannot report,
|
|
86
|
-
child that waits for a permission or asks a question has it passed to the parent, who asks you and
|
|
85
|
+
child works on its own and, when it is done or stuck, calls `courier_send` with the parent's id, a
|
|
86
|
+
status and what it produced. That message lands in the parent's inbox and OpenCode starts a new
|
|
87
|
+
turn for the parent if it is idle. A child whose turn fails instead, so that it cannot report, or ends without reporting, is
|
|
88
|
+
reported by the plugin, and a child that waits for a permission or asks a question has it passed to the parent, who asks you and
|
|
87
89
|
passes your answer back. A child that waits on a form only you can answer, such as OpenCode asking
|
|
88
90
|
which web search provider to use, has its parent told so.
|
|
89
91
|
|
|
@@ -116,6 +118,43 @@ Watch the child's question open in the parent's session as OpenCode's own questi
|
|
|
116
118
|
Both recordings are real runs with a free model on [OpenCode Zen](https://opencode.ai/zen) (Muse
|
|
117
119
|
Spark 1.3).
|
|
118
120
|
|
|
121
|
+
## Recursive orchestration
|
|
122
|
+
|
|
123
|
+
A session splits a goal across child sessions, each child decides in turn whether to split its
|
|
124
|
+
part again, and the results are checked and integrated at every level on the way back up. The
|
|
125
|
+
plugin ships the playbook as an OpenCode skill, `courier-orchestrate`, listed in every session with
|
|
126
|
+
the plugin loaded: how to decide whether to split, and to say which in one line of the reply
|
|
127
|
+
(`split: no, because …; considered: <the parts you weighed>` or `split: 3 parts: …`), plan the
|
|
128
|
+
parts, start them in one join group, end the turn, read each report by its status, verify
|
|
129
|
+
first-hand through the project's scripts, integrate and report upward, and what to do when a part
|
|
130
|
+
fails, ends quietly, blocks or hits a limit. It names the parts an implementation task with one
|
|
131
|
+
tightly coupled core usually still has: its e2e scenario, its documentation, sometimes a second
|
|
132
|
+
test file, and a review of the core by a session with fresh context (`isolate: false`, it edits
|
|
133
|
+
nothing). The session does the core itself first and commits it, then starts one session per
|
|
134
|
+
remaining part, if 2 or more remain; a review session's findings are a report to act on, and
|
|
135
|
+
running the checks stays the session's own. Its description asks a model to load it at the start
|
|
136
|
+
of any implementation task that may have independent parts, and a session nobody spawned is pointed to it by its system prompt as well
|
|
137
|
+
([Session trees](#session-trees)); `/skills` in the TUI attaches it to your prompt. Its text:
|
|
138
|
+
[the reference](docs/reference.md#the-courier-orchestrate-skill).
|
|
139
|
+
|
|
140
|
+
The smoke test's recursive scenario (`COURIER_SCENARIO=recursive`, [docs/real-model.md](docs/real-model.md#recursive-orchestration))
|
|
141
|
+
asks a free model:
|
|
142
|
+
|
|
143
|
+
> Have this job done through helper sessions started with courier_spawn, one session per part, and
|
|
144
|
+
> do no part yourself. It has two parts. Part "numbers" is itself two independent halves, which the
|
|
145
|
+
> session that gets it hands to two sessions of its own: one runs `` `sleep 20; echo $((17 * 23))` ``
|
|
146
|
+
> and writes the number it printed to numbers/a.txt, the other runs `` `sleep 20; echo $((2 ** 10))` ``
|
|
147
|
+
> and writes the number it printed to numbers/b.txt. Part "count" runs `` `sleep 20; echo $((99 - 57))` ``
|
|
148
|
+
> and writes the number it printed to count/total.txt. When the parts have reported back, read the
|
|
149
|
+
> three files yourself and reply with one line: RESULTS \<a\> \<b\> \<total\>
|
|
150
|
+
|
|
151
|
+
The root starts two sessions and ends its turn; the "numbers" session starts two of its own, ends
|
|
152
|
+
its turn, checks both files when their reports wake it and reports once to the root; the "count"
|
|
153
|
+
session does its part itself. The root is woken by the two reports, reads the three files and
|
|
154
|
+
replies `RESULTS 391 1024 42`. The checker verifies the shape of the tree, that no session polled or
|
|
155
|
+
went past `maxDepth`, that every report carries a status and each sub-orchestrator reported once,
|
|
156
|
+
after its children, and the files and the final line.
|
|
157
|
+
|
|
119
158
|
## Install
|
|
120
159
|
|
|
121
160
|
### Supported OpenCode version
|
|
@@ -149,7 +188,8 @@ opencode plugin add opencode-courier
|
|
|
149
188
|
|
|
150
189
|
This installs the package from npm and adds `"opencode-courier"` to `plugins` in the global
|
|
151
190
|
configuration (`~/.config/opencode/opencode.json`). To receive webhooks, replace that entry with the
|
|
152
|
-
object form shown under [Webhooks](#webhooks), which carries a `webhook` option
|
|
191
|
+
object form shown under [Webhooks](#webhooks), which carries a `webhook` option; the [session tree
|
|
192
|
+
limits](#session-trees) are options of the same entry.
|
|
153
193
|
|
|
154
194
|
Install it by name, without a version: OpenCode only checks plugins for updates when their entry is
|
|
155
195
|
not an exact version, so `opencode-courier@0.2.1` is never offered a newer release. How to move an
|
|
@@ -184,23 +224,32 @@ Then list it in `opencode.json` (V2 uses `plugins`, plural). A local plugin path
|
|
|
184
224
|
(run one web search in your own session and answer OpenCode's prompt, or use its "Third-party
|
|
185
225
|
search" setting): otherwise the first child to search shows that prompt in its own session,
|
|
186
226
|
which only you can answer there.
|
|
187
|
-
3.
|
|
188
|
-
|
|
227
|
+
3. Your own session's prompts are relayed nowhere, and each one stalls the whole tree until you
|
|
228
|
+
answer it. Before an unattended run, allow the orchestrating session's toolchain up front: the
|
|
229
|
+
repository's `bun run` and `npm run` scripts, `git`, and whatever its checks need. Prefer one rule
|
|
230
|
+
on `bun run *` or `npm run *` over a rule per binary; the skill tells a session that orchestrates
|
|
231
|
+
to run checks through the project's scripts rather than `./node_modules/.bin/<tool>`, so one rule
|
|
232
|
+
covers them.
|
|
233
|
+
4. Use `isolate: true` whenever children edit files in parallel. The child's worktree is made from
|
|
234
|
+
the last commit (from an isolated session, its HEAD), so an uncommitted `opencode.json` is not there and the child falls back to your
|
|
189
235
|
global config: keep providers and models in the global config, or commit the file. When you are
|
|
190
236
|
done with an isolated child, `courier_cleanup` it so its worktree does not linger.
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
237
|
+
5. A child whose turn fails, or ends without `courier_send`, is reported to its parent by the
|
|
238
|
+
plugin. What the plugin cannot see, such as a server that stops while a child runs, a
|
|
239
|
+
`courier_later` check-in still covers: for a long-running child, schedule one for yourself and
|
|
240
|
+
`courier_cancel` it when the child reports.
|
|
194
241
|
|
|
195
242
|
## Tools
|
|
196
243
|
|
|
197
244
|
| Tool | Does |
|
|
198
245
|
|---|---|
|
|
199
|
-
| `courier_spawn` | Creates a session (optionally in its own git worktree with `isolate: true
|
|
200
|
-
| `courier_send` | Delivers a message to a session, signed with the sender's id, waking it if idle. |
|
|
246
|
+
| `courier_spawn` | Creates a session (optionally in its own git worktree with `isolate: true`, which from an isolated session starts at its HEAD, [nested isolation](docs/reference.md#nested-isolation)) on the parent's model, sends it the task plus a brief naming the parent, how to report back and when to split the task further, and returns at once. With a `group` name, the reports of every session started under that name are held and delivered together, in one message ([reference](docs/reference.md#join-groups)). Refused past the [session tree limits](#session-trees). |
|
|
247
|
+
| `courier_send` | Delivers a message to a session, signed with the sender's id, waking it if idle. From a child to its parent, a `status` (`done`, `partial`, `blocked` or `failed`) makes it the child's report, carried as an attribute of the message (`<courier from="…" status="done">`), and `artifacts` (`branch`, `commits`, `files`, `checks`) are listed in its body in a fixed layout; a message without a status is progress, not the report ([reference](docs/reference.md#a-childs-report)). From a member of a join group, a report with `done`, `partial` or `failed` is held with the group instead, and the result says so. |
|
|
201
248
|
| `courier_status` | One look at a session: outcome, idle time, last reply and the permission requests and questions it waits on. For check-ins, not for waiting. |
|
|
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
|
|
203
|
-
| `
|
|
249
|
+
| `courier_children` | Lists the sessions this one (or a given `sessionID`) started with `courier_spawn`, each with what `courier_status` reports plus its directory, whether it is isolated, when it was started and, for one started in a group, the group and whether its report is `held`, `out` or `released`. |
|
|
250
|
+
| `courier_tree` | Lists the whole tree under this session (or a given `sessionID`): every session started from it with `courier_spawn`, directly or through others, depth first, each with what `courier_children` shows plus its depth, its parent, and its report state (whether it owes a report, sent progress, ended without one, and the status of its last report). Cut at `maxTotal` sessions ([reference](docs/reference.md#seeing-and-stopping-a-subtree)). |
|
|
251
|
+
| `courier_stop` | Interrupts a session you started, directly or through others, and every session under it, deepest first, and cancels the `courier_later` messages scheduled for them. With `cleanup: true` it also removes their worktrees, by `courier_cleanup`'s rules (kept ones are listed). You are not told of what you stopped; a session between you and the target is, once, as `ended="stopped"` ([reference](docs/reference.md#seeing-and-stopping-a-subtree)). |
|
|
252
|
+
| `courier_cleanup` | Removes the git worktree of a child started with `isolate: true` and drops the child from `courier_children`. Keeps a worktree with uncommitted changes or commits on no branch, tag or remote and lists them, unless `force: true` is passed ([reference](docs/reference.md#worktree-cleanup)). A child without a worktree is only dropped from `courier_children`; either way, a join group member that has not reported is dropped from its group, which is released without it ([reference](docs/reference.md#join-groups)). |
|
|
204
253
|
| `courier_answer` | Passes your answer to a permission request or a question that a session started from this one waits on: `reply` (`once`, `always` or `reject`, with an optional `message`) for a permission request, `answers` for a question. |
|
|
205
254
|
| `courier_later` | Schedules a message for a session (this one by default) in `delayMinutes` or `at` an ISO time, and returns an id. When due it is delivered like `courier_send`, queued behind any running turn and waking the session if idle. |
|
|
206
255
|
| `courier_cancel` | Drops a message scheduled with `courier_later`, e.g. because the child it was waiting for reported first. |
|
|
@@ -214,9 +263,31 @@ The short version; the long one, with every edge, is [docs/reference.md](docs/re
|
|
|
214
263
|
- **A child runs on its parent's model**, not on OpenCode's default, so a parent you moved to
|
|
215
264
|
another model starts children that can reach theirs too. An `agent` with a model of its own keeps
|
|
216
265
|
it. [More](docs/reference.md#the-childs-model).
|
|
266
|
+
- **A child can split its task** with `courier_spawn` of its own, within the [limits](#session-trees):
|
|
267
|
+
its brief says when to split and when to do the work itself, and a session that splits checks
|
|
268
|
+
its children's work and sends one combined report. [More](docs/reference.md#session-trees-and-their-limits).
|
|
269
|
+
- **A child reports once, with a status**: `done`, `partial`, `blocked` (it needs a decision from
|
|
270
|
+
its parent, and says what) or `failed`, as an attribute of the message, with its artifacts listed
|
|
271
|
+
in the body. A message without a status is progress, so a child that sends "halfway" and then
|
|
272
|
+
stops is still reported as ending without a report. [More](docs/reference.md#a-childs-report).
|
|
273
|
+
- **A join group wakes you once.** Start a batch of sessions with the same `group` name, and
|
|
274
|
+
their `done`, `partial` and `failed` reports are held until the last one is in and your turn has ended, then delivered
|
|
275
|
+
in one message, marked `group="<name>"`, listing every report with its status and artifacts in
|
|
276
|
+
the order the sessions were started. A `blocked` report, progress, a failed turn, a silent end, a
|
|
277
|
+
permission request and a question reach you at once, as ever; a member whose turn fails or is
|
|
278
|
+
interrupted leaves the group, which is released without it. A member that will not report (it
|
|
279
|
+
ended its turn silently, or you gave up on it) holds the others back: drop it with
|
|
280
|
+
`courier_cleanup`, and the group is released without it, the message naming it; a report it
|
|
281
|
+
sends after all comes on its own. Held reports survive a restart, and the name is free again
|
|
282
|
+
once the group has been delivered. [More](docs/reference.md#join-groups).
|
|
217
283
|
- **A child that fails** cannot report, so the plugin does: every failed turn of a spawned session
|
|
218
284
|
sends its parent a message marked `failed="<error type>"`, with the error, waking it if idle.
|
|
219
285
|
[More](docs/reference.md#a-child-that-fails).
|
|
286
|
+
- **A child that ends its turn without reporting** to its parent has the parent told, marked
|
|
287
|
+
`ended="without-report"`, with its last reply, waking it if idle; it counts toward the limits
|
|
288
|
+
until it reports. One that waits, on a request, a session it started, a scheduled message or a
|
|
289
|
+
webhook it just subscribed to, is not reported, nor is one that answers what you typed in its session.
|
|
290
|
+
[More](docs/reference.md#a-child-that-ends-without-a-report).
|
|
220
291
|
- **A child that asks for permission** has the request passed to the session at the top, with what
|
|
221
292
|
it asks for and the choices OpenCode offers (`once`, `always`, `reject`). That session asks you
|
|
222
293
|
and answers with `courier_answer`; the child carries on.
|
|
@@ -230,17 +301,49 @@ The short version; the long one, with every edge, is [docs/reference.md](docs/re
|
|
|
230
301
|
- **A child that shows a form** the plugin cannot pass on, such as OpenCode's web search asking
|
|
231
302
|
for a provider, has the top session told, marked `asks="form"`, with the form's choices: only you
|
|
232
303
|
can answer it, in the child's session. [More](docs/reference.md#a-child-that-shows-a-form).
|
|
233
|
-
- **
|
|
234
|
-
|
|
304
|
+
- **You can see and stop a whole subtree.** `courier_tree` lists every session under yours with its
|
|
305
|
+
depth and report state; `courier_stop` interrupts one of them and everything under it, deepest
|
|
306
|
+
first, and cancels their scheduled messages. A stop is neither a failure nor a silent end, and
|
|
307
|
+
only a session above the target may order it. [More](docs/reference.md#seeing-and-stopping-a-subtree).
|
|
308
|
+
- **The plugin remembers.** Each parent's children (`courier_children`) and whether each owes a
|
|
309
|
+
report, held group reports, pending `courier_later` messages and open questions survive a compaction or a restart; entries are dropped after 14 days.
|
|
235
310
|
[Roster](docs/reference.md#roster), [Scheduled messages](docs/reference.md#scheduled-messages).
|
|
236
311
|
- **One OpenCode server per data directory, preferably.** A second server on the same one, such as
|
|
237
312
|
`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
|
|
313
|
+
`courier_later` messages and released groups, but a child's permission request can only be answered from the server
|
|
239
314
|
that runs the child. [More](docs/reference.md#two-servers-on-one-data-directory).
|
|
240
315
|
- **Worktrees are yours to remove.** An isolated child's worktree is kept until `courier_cleanup`,
|
|
241
316
|
which refuses to drop uncommitted changes or unbranched commits unless told to.
|
|
242
317
|
[More](docs/reference.md#worktree-cleanup).
|
|
243
318
|
|
|
319
|
+
## Session trees
|
|
320
|
+
|
|
321
|
+
A spawned session may start sessions of its own, so the sessions form a tree, and three options
|
|
322
|
+
bound it. Set them in the plugin's `options`, in the global config (`~/.config/opencode/opencode.json`):
|
|
323
|
+
|
|
324
|
+
```jsonc
|
|
325
|
+
{
|
|
326
|
+
"plugins": [{ "package": "opencode-courier", "options": { "maxDepth": 2, "maxChildren": 3 } }]
|
|
327
|
+
}
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
| Option | Default | |
|
|
331
|
+
|---|---|---|
|
|
332
|
+
| `maxDepth` | `3` | How deep the tree goes: your session is depth 0, the sessions it starts depth 1. A session at `maxDepth` cannot start sessions. |
|
|
333
|
+
| `maxChildren` | `5` | How many sessions one session started may have live at once. |
|
|
334
|
+
| `maxTotal` | `20` | How many spawned sessions may be live at once in one tree. |
|
|
335
|
+
|
|
336
|
+
`courier_spawn` refuses a spawn past a limit, naming it and saying what to do instead: do the work
|
|
337
|
+
itself, or wait for a child to report. A session counts while it is live: its turn runs, it owes
|
|
338
|
+
its parent a report (its task or a message reached it after it last reported, failed or was interrupted), or a
|
|
339
|
+
session below it is live. One that has reported, with nothing live below it, does not. Each
|
|
340
|
+
spawned session's model is also told its role on every request: `sub-orchestrator`, which may split
|
|
341
|
+
its task, or `leaf`, at `maxDepth`, which does not see `courier_spawn` at all. Your own session is
|
|
342
|
+
told, in one line, to load the `courier-orchestrate` skill before a task that touches several files
|
|
343
|
+
or areas, decide whether to split it, and say which in one line of its reply (`split: no, because …;
|
|
344
|
+
considered: <the parts you weighed>` or `split: 3 parts: …`); once it has started a session, that
|
|
345
|
+
line becomes a `root orchestrator` part with the limits. The details: [the reference](docs/reference.md#session-trees-and-their-limits).
|
|
346
|
+
|
|
244
347
|
## Webhooks
|
|
245
348
|
|
|
246
349
|
The receiver is off unless the plugin has a `webhook` option. Put it in the **global** config
|
|
@@ -320,8 +423,9 @@ gets a scenario there.
|
|
|
320
423
|
The smoke test (`e2e/real-model.sh`) runs the same server with a real model (by default a free one
|
|
321
424
|
on [OpenCode Zen](https://opencode.ai/zen), no key needed) and checks that the parent spawns instead
|
|
322
425
|
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
|
|
324
|
-
|
|
426
|
+
`COURIER_SCENARIO=permission` and `COURIER_SCENARIO=question` exercise the two relays, and
|
|
427
|
+
`COURIER_SCENARIO=recursive` the [recursive orchestration](#recursive-orchestration) above. Which
|
|
428
|
+
models pass and what was tuned for them: [docs/real-model.md](docs/real-model.md).
|
|
325
429
|
|
|
326
430
|
CI runs both suites on every push to `main` and every pull request, with the OpenCode CLI at the
|
|
327
431
|
pinned version, and the live suite once more on the newest OpenCode release, where a failure warns
|
package/dist/bounded.d.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Bounded
|
|
3
|
-
*
|
|
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.
|
|
2
|
+
* Bounded `Set`s and `Map`s of remembered ids that drop their oldest entry past a bound. Functions
|
|
3
|
+
* on the built-ins, not a class, as older plugin copies call `.has` and `.add` on the shared ones.
|
|
6
4
|
*/
|
|
7
5
|
/** Adds to a bounded set, dropping the oldest entry past `max`; false when the value was there already. */
|
|
8
6
|
export declare function addBounded<T>(set: Set<T>, value: T, max: number): boolean;
|
package/dist/bounded.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Bounded
|
|
3
|
-
*
|
|
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.
|
|
2
|
+
* Bounded `Set`s and `Map`s of remembered ids that drop their oldest entry past a bound. Functions
|
|
3
|
+
* on the built-ins, not a class, as older plugin copies call `.has` and `.add` on the shared ones.
|
|
6
4
|
*/
|
|
7
5
|
/** Adds to a bounded set, dropping the oldest entry past `max`; false when the value was there already. */
|
|
8
6
|
export function addBounded(set, value, max) {
|
package/dist/cleanup.d.ts
CHANGED
|
@@ -19,6 +19,10 @@ export interface CleanupPorts {
|
|
|
19
19
|
readonly projectID: string;
|
|
20
20
|
/** The worktree's state, or undefined when its directory no longer exists; `base` is the commit it was made from. */
|
|
21
21
|
readonly inspect: (directory: string, base?: string) => Promise<WorktreeState | undefined>;
|
|
22
|
+
/** When a member still out is dropped from its group. */
|
|
23
|
+
readonly now: () => number;
|
|
24
|
+
/** Has the scheduler deliver what is due now: a group the forgotten child may have been the last member out of. */
|
|
25
|
+
readonly nudge: () => void;
|
|
22
26
|
}
|
|
23
27
|
export interface CleanupInput {
|
|
24
28
|
readonly sessionID: string;
|
|
@@ -27,9 +31,8 @@ export interface CleanupInput {
|
|
|
27
31
|
/** At most this many changes and commits are returned; the reason still counts them all. */
|
|
28
32
|
export declare const MAX_LISTED = 50;
|
|
29
33
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* result says what is in it.
|
|
34
|
+
* Forgets a child the parent started, removing its worktree if it is isolated. A worktree with uncommitted changes
|
|
35
|
+
* or commits found nowhere else is kept unless `force` is set; the result says so.
|
|
33
36
|
*/
|
|
34
37
|
export declare function cleanup(ports: CleanupPorts, parentID: string, input: CleanupInput): Promise<CleanupResult>;
|
|
35
38
|
/** Names the git to run, by absolute path, overriding the places `GIT_LOCATIONS` lists. */
|
|
@@ -46,6 +49,8 @@ export declare const GIT_LOCATIONS: Readonly<Record<"posix" | "win32", readonly
|
|
|
46
49
|
export declare function findGit(env?: Readonly<Record<string, string | undefined>>, platform?: NodeJS.Platform, exists?: (path: string) => boolean): string;
|
|
47
50
|
/** The commit a worktree is on, or undefined when git cannot tell. */
|
|
48
51
|
export declare function headOf(directory: string): Promise<string | undefined>;
|
|
52
|
+
/** Whether a worktree has uncommitted changes, untracked files included; undefined when git cannot tell. */
|
|
53
|
+
export declare function dirtyOf(directory: string): Promise<boolean | undefined>;
|
|
49
54
|
/**
|
|
50
55
|
* Reads a worktree's state with git; undefined when the directory is gone. Commits reachable from
|
|
51
56
|
* `base`, the commit the worktree was made from, are not its own work and are not listed.
|
package/dist/cleanup.js
CHANGED
|
@@ -1,32 +1,30 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { isAbsolute } from "node:path";
|
|
4
|
+
import { markLeft, membershipOf } from "./group.js";
|
|
4
5
|
import { keepReason } from "./notices.js";
|
|
5
6
|
import { remove, rosterKey } from "./roster.js";
|
|
6
7
|
/** At most this many changes and commits are returned; the reason still counts them all. */
|
|
7
8
|
export const MAX_LISTED = 50;
|
|
8
9
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* result says what is in it.
|
|
10
|
+
* Forgets a child the parent started, removing its worktree if it is isolated. A worktree with uncommitted changes
|
|
11
|
+
* or commits found nowhere else is kept unless `force` is set; the result says so.
|
|
12
12
|
*/
|
|
13
13
|
export async function cleanup(ports, parentID, input) {
|
|
14
14
|
const entry = (await ports.storage.get(rosterKey(parentID, input.sessionID)));
|
|
15
15
|
if (!entry)
|
|
16
16
|
throw new Error(`${input.sessionID} is not on the courier_children list of ${parentID}.`);
|
|
17
|
-
if (!entry.isolated)
|
|
18
|
-
throw new Error(`${input.sessionID} ran in ${entry.directory}, not in a worktree of its own; there is nothing to remove.`);
|
|
19
17
|
const { directory } = entry;
|
|
20
|
-
const forget = () =>
|
|
18
|
+
const forget = () => forgetChild(ports, entry);
|
|
19
|
+
if (!entry.isolated)
|
|
20
|
+
return { sessionID: input.sessionID, directory, outcome: "forgotten", ...(await forget()) };
|
|
21
21
|
// With force the state only decides whether there is anything left to remove, so a worktree git
|
|
22
22
|
// can no longer read is still removed.
|
|
23
23
|
const state = await ports
|
|
24
24
|
.inspect(directory, entry.base)
|
|
25
25
|
.catch((error) => (input.force ? { changes: [], commits: [] } : Promise.reject(error)));
|
|
26
|
-
if (!state)
|
|
27
|
-
await forget();
|
|
28
|
-
return { sessionID: input.sessionID, directory, outcome: "gone" };
|
|
29
|
-
}
|
|
26
|
+
if (!state)
|
|
27
|
+
return { sessionID: input.sessionID, directory, outcome: "gone", ...(await forget()) };
|
|
30
28
|
const reason = keepReason(state);
|
|
31
29
|
if (reason && !input.force)
|
|
32
30
|
return {
|
|
@@ -42,8 +40,18 @@ export async function cleanup(ports, parentID, input) {
|
|
|
42
40
|
directory,
|
|
43
41
|
force: input.force === true,
|
|
44
42
|
});
|
|
45
|
-
await forget();
|
|
46
|
-
|
|
43
|
+
return { sessionID: input.sessionID, directory, outcome: "removed", ...(await forget()) };
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Drops a member still out from its group as `dropped`, so the group is released without it, then the roster entry
|
|
47
|
+
* and report state. A held report stays with its group. Returns the group it was dropped from.
|
|
48
|
+
*/
|
|
49
|
+
async function forgetChild(ports, entry) {
|
|
50
|
+
const member = entry.group ? await membershipOf(ports.storage, entry.parentID, entry.group, entry.sessionID) : undefined;
|
|
51
|
+
if (member?.out && (await markLeft(ports.storage, member, "dropped", ports.now())))
|
|
52
|
+
ports.nudge();
|
|
53
|
+
await remove(ports.storage, entry.parentID, entry.sessionID);
|
|
54
|
+
return member?.out ? { group: member.group } : {};
|
|
47
55
|
}
|
|
48
56
|
/** Names the git to run, by absolute path, overriding the places `GIT_LOCATIONS` lists. */
|
|
49
57
|
export const GIT_ENV = "OPENCODE_COURIER_GIT";
|
|
@@ -85,6 +93,10 @@ function git(directory, args) {
|
|
|
85
93
|
export function headOf(directory) {
|
|
86
94
|
return git(directory, ["rev-parse", "HEAD"]).then((out) => out.trim() || undefined, () => undefined);
|
|
87
95
|
}
|
|
96
|
+
/** Whether a worktree has uncommitted changes, untracked files included; undefined when git cannot tell. */
|
|
97
|
+
export function dirtyOf(directory) {
|
|
98
|
+
return git(directory, ["status", "--porcelain"]).then((out) => out.trim().length > 0, () => undefined);
|
|
99
|
+
}
|
|
88
100
|
/**
|
|
89
101
|
* Reads a worktree's state with git; undefined when the directory is gone. Commits reachable from
|
|
90
102
|
* `base`, the commit the worktree was made from, are not its own work and are not listed.
|
package/dist/courier.d.ts
CHANGED
|
@@ -1,21 +1,31 @@
|
|
|
1
1
|
import type { Plugin } from "@opencode/plugin";
|
|
2
|
-
import { type
|
|
3
|
-
import { type
|
|
2
|
+
import { type Limits, type SpawnGate } from "./limits.js";
|
|
3
|
+
import { type Artifacts, type Prompt, type Sent, type Status } from "./notices.js";
|
|
4
|
+
import { type RosterEntry, type RosterStorage } from "./roster.js";
|
|
4
5
|
type Context = Plugin.Context;
|
|
5
6
|
/** The slice of the plugin context the courier tools use; tests pass a fake. */
|
|
6
7
|
export interface CourierPorts {
|
|
7
|
-
readonly session: Pick<Context["session"], "create" | "prompt" | "synthetic" | "get" | "context">;
|
|
8
|
+
readonly session: Pick<Context["session"], "create" | "prompt" | "synthetic" | "get" | "context" | "interrupt">;
|
|
8
9
|
readonly agent: Pick<Context["agent"], "get">;
|
|
9
10
|
readonly worktree: Pick<Context["worktree"], "create" | "remove">;
|
|
10
11
|
/** The project of the plugin's location; an isolated child's worktree is made in it, and removed from it. */
|
|
11
12
|
readonly projectID: string;
|
|
12
13
|
/** The commit a directory's checkout is on, or undefined; recorded as an isolated child's base. */
|
|
13
14
|
readonly head: (directory: string) => Promise<string | undefined>;
|
|
15
|
+
/** Whether a directory's checkout has uncommitted changes, or undefined when git cannot tell. */
|
|
16
|
+
readonly dirty: (directory: string) => Promise<boolean | undefined>;
|
|
14
17
|
readonly storage: RosterStorage;
|
|
15
18
|
readonly directory: string;
|
|
16
19
|
readonly now: () => number;
|
|
17
20
|
/** The permission requests and questions a session waits on, wherever they are pending. */
|
|
18
21
|
readonly pending: (sessionID: string) => Promise<ReadonlyArray<Pending>>;
|
|
22
|
+
readonly limits: Limits;
|
|
23
|
+
readonly gate: SpawnGate;
|
|
24
|
+
/** The depths the `context` hook remembers; a parent's is dropped when it starts a child. */
|
|
25
|
+
readonly roles: Map<string, number | null>;
|
|
26
|
+
/** Has the scheduler deliver what is due now rather than at its next tick: a group a report has just completed. */
|
|
27
|
+
readonly nudge: () => void;
|
|
28
|
+
readonly log: (message: string) => void;
|
|
19
29
|
}
|
|
20
30
|
/** A request a session waits on until someone answers it: a permission request, or a question it asked. */
|
|
21
31
|
export type Pending = {
|
|
@@ -36,10 +46,13 @@ export interface SpawnInput {
|
|
|
36
46
|
readonly title?: string;
|
|
37
47
|
readonly agent?: string;
|
|
38
48
|
readonly isolate?: boolean;
|
|
49
|
+
readonly group?: string;
|
|
39
50
|
}
|
|
40
51
|
export interface SendInput {
|
|
41
52
|
readonly sessionID: string;
|
|
42
53
|
readonly message: string;
|
|
54
|
+
readonly status?: Status;
|
|
55
|
+
readonly artifacts?: Artifacts;
|
|
43
56
|
readonly queue?: boolean;
|
|
44
57
|
}
|
|
45
58
|
export interface StatusInput {
|
|
@@ -48,48 +61,140 @@ export interface StatusInput {
|
|
|
48
61
|
export interface ChildrenInput {
|
|
49
62
|
readonly sessionID?: string;
|
|
50
63
|
}
|
|
51
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* Creates a child session, hands it the task and returns at once; the child reports back with
|
|
66
|
+
* courier_send. Refused past a limit, whether or not the `context` hook hid the tool.
|
|
67
|
+
*/
|
|
52
68
|
export declare function spawn(ports: CourierPorts, parentID: string, input: SpawnInput): Promise<{
|
|
69
|
+
uncommitted?: boolean | undefined;
|
|
70
|
+
fromParent?: boolean | undefined;
|
|
71
|
+
groupError?: string | undefined;
|
|
53
72
|
rosterError?: string | undefined;
|
|
73
|
+
group?: string | undefined;
|
|
54
74
|
sessionID: string;
|
|
55
75
|
directory: string;
|
|
56
76
|
}>;
|
|
57
|
-
/**
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Drops a message into another session's inbox; OpenCode wakes that session if it is idle. From a spawned session
|
|
79
|
+
* to the one that started it, it is its report or its progress (`noteReport`); a group member's final report is held (`hold`).
|
|
80
|
+
*/
|
|
81
|
+
export declare function send(ports: CourierPorts, from: string, input: SendInput): Promise<Sent>;
|
|
61
82
|
/** A one-off look at a session, for check-ins; not meant to be called in a loop. */
|
|
62
83
|
export declare function status(ports: CourierPorts, input: StatusInput): Promise<Partial<{
|
|
63
84
|
sessionID: string;
|
|
64
85
|
title: string | undefined;
|
|
65
86
|
parentID: string | undefined;
|
|
66
|
-
outcome: "failed" | "
|
|
87
|
+
outcome: "failed" | "interrupted" | "succeeded" | undefined;
|
|
67
88
|
updated: number;
|
|
68
89
|
idle: number | undefined;
|
|
69
90
|
lastText: string | undefined;
|
|
70
91
|
pending: readonly Pending[] | undefined;
|
|
71
92
|
}>>;
|
|
72
|
-
/** The
|
|
93
|
+
/** The text of a session's last reply, from its context's messages, or undefined when it has none. */
|
|
94
|
+
export declare function lastReply(messages: Awaited<ReturnType<Context["session"]["context"]>>): string | undefined;
|
|
95
|
+
/**
|
|
96
|
+
* The sessions a parent started, each with what courier_status reports, or the error it gave; a child in a
|
|
97
|
+
* group with the group's name and its standing there: `held`, `out`, how it left, `released` or `unknown`.
|
|
98
|
+
*/
|
|
73
99
|
export declare function listChildren(ports: CourierPorts, parentID: string): Promise<({
|
|
100
|
+
group: {
|
|
101
|
+
name: string;
|
|
102
|
+
report: string;
|
|
103
|
+
};
|
|
104
|
+
directory: string;
|
|
105
|
+
isolated: boolean;
|
|
106
|
+
created: number;
|
|
107
|
+
sessionID?: string | undefined;
|
|
108
|
+
title?: string | undefined;
|
|
109
|
+
parentID?: string | undefined;
|
|
110
|
+
outcome?: "failed" | "interrupted" | "succeeded" | undefined;
|
|
111
|
+
updated?: number | undefined;
|
|
112
|
+
idle?: number | undefined;
|
|
113
|
+
lastText?: string | undefined;
|
|
114
|
+
pending?: readonly Pending[] | undefined;
|
|
115
|
+
} | {
|
|
116
|
+
group?: undefined;
|
|
74
117
|
directory: string;
|
|
75
118
|
isolated: boolean;
|
|
76
119
|
created: number;
|
|
77
120
|
sessionID?: string | undefined;
|
|
78
121
|
title?: string | undefined;
|
|
79
122
|
parentID?: string | undefined;
|
|
80
|
-
outcome?: "failed" | "
|
|
123
|
+
outcome?: "failed" | "interrupted" | "succeeded" | undefined;
|
|
81
124
|
updated?: number | undefined;
|
|
82
125
|
idle?: number | undefined;
|
|
83
126
|
lastText?: string | undefined;
|
|
84
127
|
pending?: readonly Pending[] | undefined;
|
|
85
128
|
} | {
|
|
86
129
|
error: string;
|
|
130
|
+
group: {
|
|
131
|
+
name: string;
|
|
132
|
+
report: string;
|
|
133
|
+
};
|
|
134
|
+
directory: string;
|
|
135
|
+
isolated: boolean;
|
|
136
|
+
created: number;
|
|
137
|
+
sessionID: string;
|
|
138
|
+
title: string;
|
|
139
|
+
} | {
|
|
140
|
+
error: string;
|
|
141
|
+
group?: undefined;
|
|
87
142
|
directory: string;
|
|
88
143
|
isolated: boolean;
|
|
89
144
|
created: number;
|
|
90
145
|
sessionID: string;
|
|
91
146
|
title: string;
|
|
92
147
|
})[]>;
|
|
148
|
+
/** One roster entry as courier_children shows it: what courier_status reports with its directory and group standing, or the error it gave. */
|
|
149
|
+
export declare function described(ports: CourierPorts, entry: RosterEntry): Promise<{
|
|
150
|
+
group: {
|
|
151
|
+
name: string;
|
|
152
|
+
report: string;
|
|
153
|
+
};
|
|
154
|
+
directory: string;
|
|
155
|
+
isolated: boolean;
|
|
156
|
+
created: number;
|
|
157
|
+
sessionID?: string | undefined;
|
|
158
|
+
title?: string | undefined;
|
|
159
|
+
parentID?: string | undefined;
|
|
160
|
+
outcome?: "failed" | "interrupted" | "succeeded" | undefined;
|
|
161
|
+
updated?: number | undefined;
|
|
162
|
+
idle?: number | undefined;
|
|
163
|
+
lastText?: string | undefined;
|
|
164
|
+
pending?: readonly Pending[] | undefined;
|
|
165
|
+
} | {
|
|
166
|
+
group?: undefined;
|
|
167
|
+
directory: string;
|
|
168
|
+
isolated: boolean;
|
|
169
|
+
created: number;
|
|
170
|
+
sessionID?: string | undefined;
|
|
171
|
+
title?: string | undefined;
|
|
172
|
+
parentID?: string | undefined;
|
|
173
|
+
outcome?: "failed" | "interrupted" | "succeeded" | undefined;
|
|
174
|
+
updated?: number | undefined;
|
|
175
|
+
idle?: number | undefined;
|
|
176
|
+
lastText?: string | undefined;
|
|
177
|
+
pending?: readonly Pending[] | undefined;
|
|
178
|
+
} | {
|
|
179
|
+
error: string;
|
|
180
|
+
group: {
|
|
181
|
+
name: string;
|
|
182
|
+
report: string;
|
|
183
|
+
};
|
|
184
|
+
directory: string;
|
|
185
|
+
isolated: boolean;
|
|
186
|
+
created: number;
|
|
187
|
+
sessionID: string;
|
|
188
|
+
title: string;
|
|
189
|
+
} | {
|
|
190
|
+
error: string;
|
|
191
|
+
group?: undefined;
|
|
192
|
+
directory: string;
|
|
193
|
+
isolated: boolean;
|
|
194
|
+
created: number;
|
|
195
|
+
sessionID: string;
|
|
196
|
+
title: string;
|
|
197
|
+
}>;
|
|
93
198
|
/** A readable message for a failed courier call; OpenCode's own errors can carry an empty message. */
|
|
94
199
|
export declare function describeFailure(tool: string, error: unknown): Error;
|
|
95
200
|
export {};
|