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.
Files changed (50) hide show
  1. package/README.md +124 -20
  2. package/dist/bounded.d.ts +2 -4
  3. package/dist/bounded.js +2 -4
  4. package/dist/cleanup.d.ts +8 -3
  5. package/dist/cleanup.js +24 -12
  6. package/dist/courier.d.ts +116 -11
  7. package/dist/courier.js +239 -55
  8. package/dist/group.d.ts +86 -0
  9. package/dist/group.js +247 -0
  10. package/dist/hub.d.ts +52 -77
  11. package/dist/hub.js +68 -65
  12. package/dist/index.d.ts +4 -8
  13. package/dist/index.js +27 -20
  14. package/dist/json.d.ts +4 -0
  15. package/dist/json.js +11 -0
  16. package/dist/later.d.ts +6 -6
  17. package/dist/later.js +6 -5
  18. package/dist/limits.d.ts +65 -0
  19. package/dist/limits.js +109 -0
  20. package/dist/notices.d.ts +125 -15
  21. package/dist/notices.js +304 -25
  22. package/dist/question/answer.js +6 -9
  23. package/dist/question/lifecycle.d.ts +10 -19
  24. package/dist/question/lifecycle.js +10 -19
  25. package/dist/question/pure.d.ts +3 -4
  26. package/dist/question/pure.js +2 -3
  27. package/dist/question/relay.js +13 -27
  28. package/dist/question/runtime.d.ts +2 -3
  29. package/dist/question/runtime.js +2 -3
  30. package/dist/relay.d.ts +2 -4
  31. package/dist/relay.js +2 -4
  32. package/dist/report.d.ts +48 -0
  33. package/dist/report.js +88 -0
  34. package/dist/roster.d.ts +29 -17
  35. package/dist/roster.js +57 -26
  36. package/dist/skill.d.ts +13 -0
  37. package/dist/skill.js +9 -0
  38. package/dist/stop.d.ts +41 -0
  39. package/dist/stop.js +120 -0
  40. package/dist/tools.d.ts +20 -0
  41. package/dist/tools.js +81 -12
  42. package/dist/tree.d.ts +102 -0
  43. package/dist/tree.js +58 -0
  44. package/dist/version.d.ts +4 -10
  45. package/dist/version.js +4 -10
  46. package/dist/watch.d.ts +55 -18
  47. package/dist/watch.js +219 -34
  48. package/dist/webhook.d.ts +10 -15
  49. package/dist/webhook.js +11 -20
  50. 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 turn for the parent if it is
85
- idle. A child whose turn fails instead, so that it cannot report, is reported by the plugin, and a
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. Use `isolate: true` whenever children edit files in parallel. The child's worktree is made from
188
- the last commit, so an uncommitted `opencode.json` is not there and the child falls back to your
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
- 4. A child that crashes before calling `courier_send` never wakes the parent. When you spawn a
192
- long-running child, also `courier_later` a check-in for yourself, and `courier_cancel` it when
193
- the child reports.
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`) on the parent's model, sends it the task plus a brief naming the parent and how to report back, and returns at once. |
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 and when it was started. |
203
- | `courier_cleanup` | Removes the git worktree of a child started with `isolate: true` and drops the child from `courier_children`. Keeps a worktree with uncommitted changes or commits on no branch, tag or remote and lists them, unless `force: true` is passed ([reference](docs/reference.md#worktree-cleanup)). |
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
- - **The plugin remembers.** Each parent's children (`courier_children`), pending `courier_later`
234
- messages and open questions survive a compaction or a restart; entries are dropped after 14 days.
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. Which models
324
- pass and what was tuned for them: [docs/real-model.md](docs/real-model.md).
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 collections of remembered ids: plain `Set`s and `Map`s that drop their oldest entry past
3
- * a bound. Functions over the built-in types rather than a class, since the process-wide ones are
4
- * shared with copies of the plugin loaded before an update, which call `.has` and `.add` on them.
5
- * Imports nothing of the plugin's.
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 collections of remembered ids: plain `Set`s and `Map`s that drop their oldest entry past
3
- * a bound. Functions over the built-in types rather than a class, since the process-wide ones are
4
- * shared with copies of the plugin loaded before an update, which call `.has` and `.add` on them.
5
- * Imports nothing of the plugin's.
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
- * Removes the worktree of an isolated child the parent started, and forgets the child. A worktree
31
- * with uncommitted changes or commits that exist nowhere else is kept unless `force` is set, and the
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
- * Removes the worktree of an isolated child the parent started, and forgets the child. A worktree
10
- * with uncommitted changes or commits that exist nowhere else is kept unless `force` is set, and the
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 = () => remove(ports.storage, parentID, input.sessionID);
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
- return { sessionID: input.sessionID, directory, outcome: "removed" };
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 Prompt } from "./notices.js";
3
- import { type RosterStorage } from "./roster.js";
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
- /** Creates a child session, hands it the task and returns at once; the child reports back with courier_send. */
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
- /** Drops a message into another session's inbox; OpenCode wakes that session if it is idle. */
58
- export declare function send(ports: CourierPorts, from: string, input: SendInput): Promise<{
59
- messageID: string;
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" | "succeeded" | "interrupted" | undefined;
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 sessions a parent started, each with what courier_status reports, or the error it gave. */
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" | "succeeded" | "interrupted" | undefined;
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 {};