opencode-courier 0.10.3 → 0.11.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 CHANGED
@@ -6,9 +6,6 @@ polling.
6
6
 
7
7
  [![CI](https://github.com/ivopogace/opencode-courier/actions/workflows/ci.yml/badge.svg)](https://github.com/ivopogace/opencode-courier/actions/workflows/ci.yml)
8
8
  [![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=ivopogace_opencode-courier&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=ivopogace_opencode-courier)
9
- [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=ivopogace_opencode-courier&metric=coverage)](https://sonarcloud.io/summary/new_code?id=ivopogace_opencode-courier)
10
- [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=ivopogace_opencode-courier&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=ivopogace_opencode-courier)
11
- [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=ivopogace_opencode-courier&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=ivopogace_opencode-courier)
12
9
  [![npm](https://img.shields.io/npm/v/opencode-courier)](https://www.npmjs.com/package/opencode-courier)
13
10
  [![Socket Badge](https://badge.socket.dev/npm/package/opencode-courier)](https://socket.dev/npm/package/opencode-courier)
14
11
  [![License: MIT](https://img.shields.io/npm/l/opencode-courier)](LICENSE)
@@ -16,15 +13,14 @@ polling.
16
13
 
17
14
  https://github.com/user-attachments/assets/d7cf74f8-15b3-4cc1-866e-b75ba0ac3f8a
18
15
 
19
- The tree panel, the asks inbox, the edge cases and memory on the live terminal UI, narrated
20
- ([what it shows](#demos)).
16
+ A narrated tour of the terminal UI: the tree panel, the asks inbox, failing children and memory.
21
17
 
22
18
  ## Quickstart
23
19
 
24
20
  ```bash
25
- npm install -g @opencode/cli@2.1.2 # OpenCode V2, at the version courier is tested on
26
- opencode plugin add opencode-courier # by name, so OpenCode offers new releases
27
- opencode # in any folder
21
+ npm install -g @opencode/cli@2.1.3 # OpenCode V2, the version courier is tested on
22
+ opencode plugin add opencode-courier
23
+ opencode
28
24
  ```
29
25
 
30
26
  Paste this prompt:
@@ -33,207 +29,65 @@ Paste this prompt:
33
29
  > `` `sleep 20; echo $((17 * 23))` ``, the other `` `sleep 40; echo $((2 ** 10))` ``. Do not run the
34
30
  > commands yourself. When both have reported, reply with one line: RESULTS \<first\> \<second\>
35
31
 
36
- The parent spawns both children and ends its turn. Within a minute the first report starts a new
37
- turn on its own, about 20 seconds later the second does, and the parent replies `RESULTS 391 1024`.
38
- If OpenCode asks to allow a child's command, the parent passes the question to you. Free models
39
- sometimes stall on a request; if nothing moves for a few minutes, send the prompt again. Next:
40
- [Using it](#using-it).
41
-
42
- ## OpenCode V2 native
43
-
44
- Courier is built on OpenCode V2's plugin API (`@opencode/plugin`), not the V1 one
45
- (`@opencode-ai/plugin`). Each release is pinned to one V2 version and tested against it end to end;
46
- the current one is tested on **OpenCode 2.1.2**. CI also runs the live suite on the newest OpenCode
47
- release, on `main` and nightly. Older releases: [every release](docs/reference.md#the-opencode-version).
48
-
49
- ## Courier or the built-in subagent tool?
50
-
51
- OpenCode V2's own [`subagent` tool](https://opencode.ai/v2/docs/agents) starts an agent with fresh
52
- context in a child session; the parent waits for its final answer or, with `background: true`, is
53
- notified when the child finishes. Use it when one result back is all you need. Use courier when the
54
- work needs more:
55
-
56
- - **Messages from the child, mid-run:** a child reports progress or asks its parent something
57
- whenever it likes with `courier_send`, and any session can message any other.
58
- - **Waking an idle session from anywhere:** a message from a child, a schedule (`courier_later`) or
59
- a [webhook](#webhooks) starts a new turn in a session that has ended its own.
60
- - **Worktree isolation:** `isolate: true` gives a child its own git worktree, so children edit files
61
- in parallel; each commits on `courier/<its session ID>` and reports that branch for its parent to
62
- merge ([nested isolation](docs/reference.md#nested-isolation)).
63
- - **Relaying to you:** a child's permission requests and questions reach you through the parent. A
64
- request is approved in OpenCode's own permission prompt in the parent's session, never by its
65
- model; a form only you can answer is pointed out to you.
66
-
67
- ## How it works
68
-
69
- A parent calls `courier_spawn`, gets a session id back at once and ends its turn. The child works
70
- on its own and, when done or stuck, calls `courier_send` with the parent's id, a status and what it
71
- produced. The message lands in the parent's inbox through OpenCode's own API, the way OpenCode's
72
- background subagents report, and starts a new turn if the parent is idle. Delivery is `steer` by
73
- default (into the target's running turn, or a new one if idle); `queue: true` waits until the current
74
- turn ends.
75
-
76
- ## Demos
77
-
78
- The video at the top (3:01, narrated) is the TUI driven in `tmux` on the live test's scripted
79
- stand-in model, so each scene is real plugin behaviour, the thing each step points at framed:
80
-
81
- - **A join group:** one member reports and its report is held with the group, while the other waits
82
- on a permission request, so the footer says one is waiting on you. `/courier tree` opens the panel,
83
- with Digby and a row for each child saying what it waits on.
84
- - **A click opens a child:** a click on a row opens that child's session, the panel following you
85
- there: one waits on the permission, the other shows the report it sent.
86
- - **The asks inbox:** back on the parent, you allow the permission from the inbox (`ctrl+x k`), and
87
- both reports reach the parent in one message; the parent notes a lesson with `courier_note`.
88
- - **A child whose turn fails:** OpenCode shows "Session failed", the parent is told, and the
89
- `/courier tree` row reads `failed`.
90
- - **A child that ends its turn without a report:** the parent is told, and the row says so.
91
- - **`/courier stop`:** pick the session, confirm, and it is stopped.
92
- - **A form only you can answer:** a child's web search shows a form; the footer, the panel row and
93
- the asks inbox point to it, and answering it there wakes the parent.
94
- - **The memory view:** `/courier memory` lists the project's session trees, each with how it was
95
- split, how its parts went and its lesson; `p` pins one, listed first and never swept.
96
- - **Recall from another tree:** a new tree's `courier_recall` lists the pinned record first, lesson
97
- and all.
98
-
99
- ## Recursive orchestration
100
-
101
- A session can split a goal across child sessions, each child deciding in turn whether to split its
102
- part again, with the results checked and integrated at every level on the way back up. The playbook
103
- ships as the OpenCode skill `courier-orchestrate`, listed in every session: models load it at the
104
- start of an implementation task that may have independent parts, and `/skills` in the TUI attaches
105
- it to your prompt ([what it says](docs/reference.md#the-courier-orchestrate-skill)).
32
+ The parent starts both children and ends its turn. Each report wakes it, and it replies
33
+ `RESULTS 391 1024`. Free models sometimes stall; if nothing moves for a few minutes, send the
34
+ prompt again.
106
35
 
107
- ## Install
108
-
109
- ### Supported OpenCode version
36
+ ## Why courier
110
37
 
111
- Requires OpenCode V2: the `opencode` command from `@opencode/cli` (also installed as `opencode2`).
112
- Each release is built and tested against exactly one OpenCode V2 version; a row lists the releases
113
- pinned to its version. OpenCode never checks it: the plugin loads on any V2 host, and on another
114
- version than the pinned one writes one line to the server log
115
- ([more](docs/reference.md#the-opencode-version)).
38
+ OpenCode's own [`subagent` tool](https://opencode.ai/v2/docs/agents) hands back a child's final
39
+ answer. Use it when one result is all you need. Use courier for more:
116
40
 
117
- <!-- compat-table:start rows=3: generated by script/compat-table.ts from the release tags -->
118
- | opencode-courier | OpenCode V2 (`opencode` and `@opencode/plugin`) |
119
- |---|---|
120
- | 0.10.2, 0.10.3 | 2.1.2 |
121
- | 0.10.1 | 2.1.0 |
122
- | 0.7.3, 0.8.0, 0.9.0, 0.10.0 | 2.0.32 |
123
- <!-- compat-table:end -->
41
+ - **Messages mid-run.** A child reports progress or asks its parent at any time.
42
+ - **Waking an idle session.** A message, a schedule or a GitHub webhook starts a new turn.
43
+ - **Parallel edits.** `isolate: true` gives a child its own git worktree and branch to merge.
44
+ - **Asks reach you.** A child's permission requests and questions come to you through the top
45
+ session. Only you approve a request, in OpenCode's own prompt.
46
+ - **Trees.** A child may split its part again, within limits you set.
124
47
 
125
- Older releases and their versions: [every release](docs/reference.md#the-opencode-version).
48
+ ## How it works
126
49
 
127
- The [terminal part](#in-the-terminal) relies on the host to provide its UI modules and is tested on
128
- OpenCode 2.1.2 only; the server part works on older V2 hosts from 2.0.4 on.
50
+ A parent calls `courier_spawn`, gets the child's session id at once, and ends its turn. When done
51
+ or stuck, the child calls `courier_send` with a status (`done`, `partial`, `blocked` or `failed`).
52
+ The message lands in the parent's inbox and starts a new turn if the parent is idle.
129
53
 
130
- Check yours with `opencode --version`, and install the matching CLI with:
54
+ Children started with the same `group` report together, in one message. A child that fails (a
55
+ rate or quota limit with when it lifts), or ends its turn without reporting, is reported to its
56
+ parent. A message that does not wake its session is sent once more, then shown as `stalled`. Children, held reports and scheduled messages
57
+ survive a restart. The `courier-orchestrate` skill, listed in every session, tells a model when to
58
+ split a task and how to check and merge the parts. Every detail: [the reference](docs/reference.md).
131
59
 
132
- ```bash
133
- npm install -g @opencode/cli@2.1.2
134
- ```
60
+ ## Install
135
61
 
136
- ### The plugin
62
+ Requires OpenCode V2: the `opencode` command from `@opencode/cli`. Each release is tested against
63
+ one OpenCode version; this one is tested on **OpenCode 2.1.3** (check with `opencode --version`).
137
64
 
138
65
  ```bash
66
+ npm install -g @opencode/cli@2.1.3
139
67
  opencode plugin add opencode-courier
140
68
  ```
141
69
 
142
- This installs the package from npm and adds `"opencode-courier"` to `plugins` in the global
143
- configuration (`~/.config/opencode/opencode.json`); its options ([session trees](#session-trees),
144
- [webhooks](#webhooks)) go in the object form of that entry. Install it by name, without a version:
145
- OpenCode only checks plugins for updates when their entry is not an exact version
70
+ This adds `"opencode-courier"` to `plugins` in `~/.config/opencode/opencode.json`. Install it by
71
+ name, without a version: OpenCode offers updates only for an entry without an exact version
146
72
  ([updating](docs/reference.md#updating-the-plugin)).
147
73
 
148
- From a local clone, `bun install && npm run build`, then list its `dist` **directory** in
149
- `opencode.json` (V2 uses `plugins`, plural; a path to a file is ignored with a warning):
74
+ ### Supported OpenCode version
150
75
 
151
- ```jsonc
152
- {
153
- "plugins": ["/absolute/path/to/opencode-courier/dist"]
154
- }
155
- ```
76
+ <!-- compat-table:start rows=3: generated by script/compat-table.ts from the release tags -->
77
+ | opencode-courier | OpenCode V2 (`opencode` and `@opencode/plugin`) |
78
+ |---|---|
79
+ | 0.11.0 | 2.1.3 |
80
+ | 0.10.2, 0.10.3 | 2.1.2 |
81
+ | 0.10.1 | 2.1.0 |
82
+ <!-- compat-table:end -->
156
83
 
157
- ## Using it
158
-
159
- 1. Keep the background server running so sessions can be woken while you are away (`opencode
160
- service start`), and run one OpenCode server per data directory where you can: a second one
161
- cannot pass your answer on to a child the other runs
162
- ([why](docs/reference.md#two-servers-on-one-data-directory)).
163
- 2. Give the agents that run children the permissions their work needs: a child that hits an approval
164
- prompt waits until you answer OpenCode's prompt for it in the parent's session. Let them use the
165
- `question` tool if they should be able to ask you. Choose a web search provider before children
166
- search the web (run one web search in your own session, or use OpenCode's "Third-party search"
167
- setting), or the first child to search shows that prompt in its own session, which only you can
168
- answer there.
169
- 3. Your own session's prompts are relayed nowhere, and each one stalls the whole tree until you
170
- answer it. Before an unattended run, allow the orchestrating session's toolchain up front: one rule
171
- on `bun run *` or `npm run *` (the skill has checks run through the project's scripts), `git`, and
172
- whatever else its checks need.
173
- 4. Use `isolate: true` whenever children edit files in parallel. The worktree is made from the last
174
- commit (from an isolated session, its HEAD), so an uncommitted `opencode.json` is not there: keep
175
- providers and models in the global config, or commit the file. A worktree is kept until you
176
- `courier_cleanup` it, which refuses to drop uncommitted changes or unbranched commits unless told to.
177
- 5. For a long-running child, schedule a `courier_later` check-in for yourself and `courier_cancel` it
178
- when the child reports: it covers what the plugin cannot see, such as a server that stops while a
179
- child runs.
84
+ Every release: [the full table](docs/reference.md#the-opencode-version). The server part loads on
85
+ any V2 host from 2.0.4 on. The terminal part is tested on OpenCode 2.1.3 only.
180
86
 
181
- ## Tools
87
+ ## Options
182
88
 
183
- | Tool | Does |
184
- |---|---|
185
- | `courier_spawn` | Starts a session on the parent's model with the task and a brief, and returns at once; `isolate: true` gives it its own git worktree, `group` holds its report with its siblings'. |
186
- | `courier_send` | Messages a session, waking it if idle; from a child to its parent, a `status` and `artifacts` make it the child's [report](docs/reference.md#a-childs-report). |
187
- | `courier_status` | One look at a session: outcome, idle time, when it was archived, last reply and what it waits on. For check-ins, not for waiting. |
188
- | `courier_children` | Lists the sessions this one (or a given one) started, with their state, worktree and join group. |
189
- | `courier_tree` | Lists the whole tree under a session, depth first, with each one's depth and report state. |
190
- | `courier_stop` | Interrupts a session below you and everything under it, and cancels their scheduled messages; `cleanup: true` removes their worktrees too. |
191
- | `courier_cleanup` | Removes an isolated child's worktree, unless it holds unsaved work, and forgets the child. |
192
- | `courier_answer` | Answers a permission request that a session under this one waits on, after OpenCode has asked you in this session, or passes your answer to its question. |
193
- | `courier_later` | Schedules a message for a session (this one by default) in `delayMinutes` or `at` a time. |
194
- | `courier_cancel` | Drops a message scheduled with `courier_later`. |
195
- | `courier_subscribe` | Subscribes a session to webhook deliveries for a topic (`owner/repo`, `owner/repo#12` or a name). |
196
- | `courier_unsubscribe` | Drops one topic, or all of a session's. |
197
- | `courier_recall` | One look at what earlier session trees in this project did and the lesson each left; with `sessionID`, one tree's [record](#memory) in full. |
198
- | `courier_note` | Writes this tree's one lesson on its [record](#memory), for the next orchestrator in the project: one line of at most 300 characters, naming the job and the reason. A longer one is refused, a second call replaces the first. From the top or a sub-orchestrator, never a leaf. |
199
-
200
- Each tool's parameters: [the reference](docs/reference.md#the-tools).
201
-
202
- ## How children reach you
203
-
204
- - **A child reports once, with a status**: `done`, `partial`, `blocked` (it needs a decision from
205
- its parent, and says what) or `failed`, with its artifacts in the message. A message without a
206
- status is progress, not the report. [More](docs/reference.md#a-childs-report).
207
- - **A join group wakes you once.** Start a batch of sessions with the same `group` name, and their
208
- `done`, `partial` and `failed` reports are held until the last one is in, then delivered in one
209
- message; a `blocked` report, a failure, a question or a permission request still reaches you at
210
- once. A member that will not report holds the others back: drop it with `courier_cleanup`.
211
- [More](docs/reference.md#join-groups).
212
- - **A child that fails, or ends its turn without reporting, is reported** to its parent, with the
213
- error or its last reply, unless it waits on a request, its own children, a scheduled message or a
214
- webhook. [More](docs/reference.md#a-child-that-fails).
215
- - **A message that fails to wake its session is sent again** after 30 seconds; if the session still
216
- does not take it, the footer and the tree panel show it as `stalled`, for you to prompt it.
217
- [More](docs/reference.md#a-missed-wake).
218
- - **Permission requests and questions reach you** through the session at the top of the tree, any
219
- number of levels down. For a request, that session proposes an answer with `courier_answer`, and
220
- OpenCode asks you in its own permission prompt there before anything is passed on: its model
221
- cannot approve, only propose. For a question, it asks you and passes your answer back, or links
222
- its own question to the child's so your pick goes straight to it.
223
- [More](docs/reference.md#a-child-that-asks-for-permission).
224
- - **Some forms only you can answer**, such as OpenCode's web search asking for a provider: the top
225
- session is told, and you answer in the child's session. [More](docs/reference.md#a-child-that-shows-a-form).
226
- - **You can see and stop a whole subtree** with `courier_tree` and `courier_stop`, or in the
227
- [terminal](#in-the-terminal). [More](docs/reference.md#seeing-and-stopping-a-subtree).
228
-
229
- Children, held reports, scheduled messages and open questions survive a compaction or a restart;
230
- entries are dropped after 14 days, or at once when OpenCode deletes the child, unless it is isolated
231
- ([roster](docs/reference.md#roster)).
232
-
233
- ## Session trees
234
-
235
- A spawned session may start sessions of its own, so the sessions form a tree, bounded by three
236
- options in the plugin's `options`, in the global config:
89
+ Set the server options on the plugin's entry in the **global** config,
90
+ `~/.config/opencode/opencode.json`:
237
91
 
238
92
  ```jsonc
239
93
  {
@@ -243,136 +97,58 @@ options in the plugin's `options`, in the global config:
243
97
 
244
98
  | Option | Default | |
245
99
  |---|---|---|
246
- | `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. |
247
- | `maxChildren` | `5` | How many sessions one session started may have live at once. |
248
- | `maxTotal` | `20` | How many spawned sessions may be live at once in one tree. |
249
-
250
- `courier_spawn` refuses a spawn past a limit, naming it and saying what to do instead. A session
251
- counts while it is live: its turn runs, it owes its parent a report, a message the plugin sent to
252
- wake it waits undelivered in its inbox (from the moment `courier_send` returns, so a `courier_spawn` in the same step counts it),
253
- or a session below it is live; renaming, archiving or
254
- otherwise changing it in OpenCode does not make it run.
255
- Each spawned session's brief says when to split its task and when to do it itself
256
- ([more](docs/reference.md#session-trees-and-their-limits)).
257
-
258
- ## Memory
259
-
260
- The plugin keeps **one record per session tree** in the project, from facts it already has: when the
261
- tree started and last moved, the top session's title and first `split:` line, with up to 3 later ones
262
- that read otherwise (when each was stated, and after how many parts), and for each session started
263
- in the tree its title, depth, isolation, join group, status, duration, cost, context use and spawns
264
- refused; never a report's body or artifacts. A session deleted since keeps its last status and
265
- duration, or reads `gone` when it had not settled, so it never holds its tree at `not finished`.
266
- The one thing a model writes into it is **the tree's
267
- lesson**, with `courier_note`: one line of at most 300 characters, from the session that verified the
268
- tree, with the job it comes from and the reason. `courier_recall` reads the records back, each line
269
- showing the split line as prose (its markdown marks dropped, the asterisks of its globs kept) cut at a
270
- word, then the latest later one (`then, after 1 part: split: 2 parts: …`), and saying how long the tree ran and how long ago it last moved; the `courier-orchestrate` skill asks for
271
- one call before the split decision and one `courier_note` before the top replies. Each record counts
272
- how often other trees' `courier_recall` calls listed it and opened it in full, and when last, so the
273
- person can tell a lesson still read from one nobody reads; a record the person pins in the terminal
274
- is listed first and never swept. Each project keeps 30 unpinned records, those with a lesson first,
275
- and none older than 90 days. `"memory":
276
- false` in the plugin's `options` turns it off ([more](docs/reference.md#memory)).
277
-
278
- ## In the terminal
279
-
280
- The package ships a part for OpenCode's terminal UI, loaded from the server's plugin list; the server
281
- half behaves the same with or without it. In a session that started sessions, the prompt's footer
282
- shows what the tree under it is doing: how many wait on you (a permission request, a question or a
283
- form), are [stalled](docs/reference.md#a-missed-wake), run or are idle, and each
284
- [join group](docs/reference.md#join-groups)'s members reported:
285
-
286
- ```
287
- courier 1 waiting on you · 2 running · group pair 1/2
288
- ```
100
+ | `maxDepth` | `3` | How deep a tree goes. Your session is depth 0. A session at `maxDepth` cannot start sessions. |
101
+ | `maxChildren` | `5` | Live children one session may have. |
102
+ | `maxTotal` | `20` | Live spawned sessions in one tree. |
103
+ | `memory` | `true` | Keep a record of each session tree for later orchestrators ([memory](docs/reference.md#memory)). |
104
+ | `nativeChildren` | `false` | Create non-isolated children as OpenCode subagents ([native children](docs/reference.md#native-child-sessions)). |
105
+ | `webhook` | off | Start the webhook receiver ([webhooks](docs/reference.md#webhooks)). |
289
106
 
290
- `/courier tree` opens a panel beside the session with the tree under it, a row per session with a
291
- mark for its state (`✓` reported, a spinner while it runs, `●` waiting on you, `!` stalled, `✗`
292
- failed or interrupted), what it does, its report, its group's standing and, for a leaf, how long it
293
- ran, its last tool call, its context use and its cost. `enter` or a click opens the session, `s`
294
- stops it and everything under it, `x` forgets a `gone` session, `q` closes the panel;
295
- `/courier stop <session>` stops from the prompt. Digby, the courier's mole, digs beside the header,
296
- his state following the tree's. `"animations": false` in the TUI part's options keeps the panel
297
- still; the TUI reads a plugin's options only from its own config, `~/.config/opencode/cli.json`
298
- ([more](docs/reference.md#in-the-terminal)):
107
+ The terminal part reads its options only from `~/.config/opencode/cli.json`, in the same entry
108
+ form. Its one option, `"animations": false`, keeps the tree panel still.
299
109
 
300
- ```jsonc
301
- {
302
- "plugins": [{ "package": "opencode-courier", "options": { "animations": false } }]
303
- }
304
- ```
110
+ ## Tools
305
111
 
306
- `ctrl+x k` (the leader key, then `k`), `/asks` (or `/ask`), the palette's "Courier: what waits on
307
- you" or a click on "waiting on you" opens the asks inbox: every permission request, question and form
308
- waiting anywhere in the tree, after the top session's own permission requests, with where each comes
309
- from, kept current while open and answered in a dialog with no model in between.
310
- The TUI plays OpenCode's sounds for a child's report, a group's release and a new ask. While a
311
- permission prompt or form is shown in the session, OpenCode hides the footer, so open the inbox with
312
- `ctrl+x k`.
313
-
314
- `/courier memory` (or the palette's "Courier: memory") lists the project's [tree records](#memory),
315
- pinned first, then newest first grouped by the day each tree last moved, one row each with how often
316
- other trees read it and its lesson under it. `enter` shows one in full; `p` pins or unpins the record,
317
- and a pinned one is listed first and never swept; `x` forgets the record under the row, or in a
318
- record on its lesson's rows only the lesson, after a confirm; `esc` goes back and closes. The search
319
- takes every letter but `x` and `p` ([more](docs/reference.md#the-memory-view)).
320
-
321
- ## Native child sessions
322
-
323
- Off by default. With `"nativeChildren": true` in the plugin's `options`, a child started without
324
- `isolate` is also created with OpenCode's own `parentID`, so OpenCode treats it as the parent's
325
- subagent: the TUI's view of the top session, and the paired app's request dock, list and answer the
326
- permission requests, questions and forms of every session under it, and the top session's model is
327
- only told. Deleting a session then deletes its native children, the child gets an empty ruleset
328
- rather than its parent's, and providers see a subagent (not tested on GitHub Copilot). Turn it on
329
- only with OpenCode's TUI or the paired app, which show those prompts
330
- ([more](docs/reference.md#native-child-sessions)).
331
-
332
- ## Webhooks
333
-
334
- GitHub reviews, comments, pull requests and completed CI runs, or anything signed posted to
335
- `/hook/<name>`, can wake a session. The receiver is off unless the plugin has a `webhook` option, in
336
- the **global** config, since there is one receiver per OpenCode server (`"webhook": true` takes
337
- every default):
112
+ | Tool | Does |
113
+ |---|---|
114
+ | `courier_spawn` | Starts a child session with a task and returns at once. `isolate`, `group`, `agent`, `title`. |
115
+ | `courier_send` | Messages a session, waking it if idle. With a `status`, it is a child's report. |
116
+ | `courier_status` / `courier_children` / `courier_tree` | One look at a session, at the sessions it started, or at its whole tree. |
117
+ | `courier_stop` | Stops a session below you and everything under it. |
118
+ | `courier_cleanup` | Removes an isolated child's worktree and forgets the child. |
119
+ | `courier_answer` | Proposes an answer to a child's permission request, or passes on your answer to its question. |
120
+ | `courier_later` / `courier_cancel` | Schedules a message for a session, or drops one. |
121
+ | `courier_subscribe` / `courier_unsubscribe` | Subscribes a session to webhook deliveries for a topic, or drops it. |
122
+ | `courier_recall` / `courier_note` | Reads what earlier trees in this project did, or writes this tree's one-line lesson. |
123
+
124
+ Parameters and behaviour: [the reference](docs/reference.md#the-tools).
338
125
 
339
- ```jsonc
340
- {
341
- "plugins": [
342
- {
343
- "package": "opencode-courier",
344
- "options": { "webhook": { "port": 4097, "secretFile": "~/.config/opencode/courier-webhook-secret" } }
345
- }
346
- ]
347
- }
348
- ```
126
+ ## In the terminal
349
127
 
350
- 1. Make the secret with `openssl rand -hex 32 > ~/.config/opencode/courier-webhook-secret` and
351
- `chmod 600` it. It is never read from `opencode.json` (a `secret` key is refused), so the config
352
- can be committed; without a usable secret the receiver does not start, and the server log says why.
353
- 2. On GitHub, add a webhook to the repository (Settings → Webhooks) with content type
354
- `application/json`, the same secret, the events you want, and `<public URL>/github` as the payload
355
- URL, forwarded to `127.0.0.1:4097` by a tunnel you trust (`smee`, `cloudflared`, `ngrok`). Only
356
- signed deliveries are acted on.
357
- 3. `courier_subscribe` a session to `owner/repo`, `owner/repo#N` or `<name>`: each matching delivery
358
- arrives as a short summary marked as outside text, queued behind any running turn and waking the
359
- session if idle.
128
+ - **Footer:** `courier 1 waiting on you · 2 running · group pair 1/2`.
129
+ - **`/courier tree`:** a panel with the tree. `enter` opens a session, `s` stops it, `q` closes.
130
+ - **`/courier stop <session>`:** stops a session and everything under it.
131
+ - **`ctrl+x k`** or **`/asks`:** every ask waiting on you in the tree, answered in a dialog.
132
+ - **`/courier memory`:** the project's tree records. `p` pins one, `x` forgets one.
360
133
 
361
- The options, which events wake whom, the signing scheme and status codes: [the
362
- reference](docs/reference.md#webhooks).
134
+ More: [in the terminal](docs/reference.md#in-the-terminal).
363
135
 
364
- ## Development
136
+ ## Tips
365
137
 
366
- Commands, the suites and CI: [docs/maintainers/development.md](docs/maintainers/development.md). How
367
- the code is laid out: [architecture.md](docs/maintainers/architecture.md); what the plugin API does
368
- that courier works around: [plugin-api-notes.md](docs/maintainers/plugin-api-notes.md); releases:
369
- [releasing.md](docs/maintainers/releasing.md).
138
+ - Keep `opencode service start` running, so sessions can be woken while you are away. Run one
139
+ OpenCode server per data directory ([why](docs/reference.md#two-servers-on-one-data-directory)).
140
+ - Allow up front what the work needs, and the `question` tool if children should ask you. A
141
+ child's approval prompt waits for you, and the top session's own prompts stall the whole tree
142
+ (allow `bun run *` or `npm run *` and `git`).
143
+ - Choose a web search provider before children search, or the first one shows you a form.
144
+ - Use `isolate: true` when children edit files in parallel. The worktree starts from the last
145
+ commit, so keep providers in the global config or commit `opencode.json`.
146
+ - For a long-running child, schedule a `courier_later` check-in, in case the server stops.
370
147
 
371
148
  ## Contributing
372
149
 
373
- Questions and bug reports go to the [issues](https://github.com/ivopogace/opencode-courier/issues).
374
- Pull requests are welcome: a change in behaviour comes with a unit test, an `e2e/run.sh` scenario
375
- and its line in this README or [docs/reference.md](docs/reference.md), and CI must be green.
150
+ Questions and bugs: [issues](https://github.com/ivopogace/opencode-courier/issues). Pull requests
151
+ are welcome; start with [development.md](docs/maintainers/development.md).
376
152
 
377
153
  ## License
378
154
 
@@ -145,4 +145,8 @@ export type Standing = "held" | "out" | LeftMember["by"] | "released";
145
145
  export declare function standingOf(member: Member | undefined): Standing;
146
146
  /** Whether every member of a group has a held report or has left: released once the parent's turn has ended. */
147
147
  export declare const complete: (members: ReadonlyArray<Member>) => boolean;
148
+ /** How long the provider asked to wait after a failed turn, if it did: a positive number of milliseconds, at most `RETRY_MAX_MS`. */
149
+ export declare function retryDelay(error: {
150
+ readonly retryAfterMs?: unknown;
151
+ }): number | undefined;
148
152
  export {};
@@ -111,3 +111,10 @@ export function standingOf(member) {
111
111
  }
112
112
  /** Whether every member of a group has a held report or has left: released once the parent's turn has ended. */
113
113
  export const complete = (members) => members.every((member) => member.report || member.left);
114
+ /** Longer than this is no wait a notice can plan around; OpenCode clamps a header to 2^53 ms, past what a `Date` holds. */
115
+ const RETRY_MAX_MS = 30 * 24 * 3_600_000;
116
+ /** How long the provider asked to wait after a failed turn, if it did: a positive number of milliseconds, at most `RETRY_MAX_MS`. */
117
+ export function retryDelay(error) {
118
+ const wait = error.retryAfterMs;
119
+ return typeof wait === "number" && Number.isFinite(wait) && wait > 0 && wait <= RETRY_MAX_MS ? wait : undefined;
120
+ }
@@ -1,6 +1,6 @@
1
1
  import { envelope } from "../kernel/protocol.js";
2
2
  import { claim, SEEN_MAX, setBounded } from "../platform/bounded.js";
3
- import { endsIdle } from "./domain.js";
3
+ import { endsIdle, retryDelay } from "./domain.js";
4
4
  import { forgetChild, forgetDeleted } from "./forget.js";
5
5
  import { hasCompleteGroup, leave, outIn } from "./group.js";
6
6
  import { awaited, reportOf, stoppedSincePrompt } from "./report.js";
@@ -19,9 +19,10 @@ export async function reportFailure(ports, seen, event) {
19
19
  const children = childStore(ports.storage);
20
20
  const entries = await children.entriesOf(sessionID);
21
21
  const at = event.created ?? ports.now();
22
- // Not in the way of the notice: kept only so the limits stop counting the child.
22
+ const wait = retryDelay(error);
23
+ // Not in the way of the notice: kept only so the limits stop counting the child, and the panel can say when its limit lifts.
23
24
  if (entries.length)
24
- await children.settled(sessionID, "failed", at).catch((error) => ports.log(`courier watch: could not note the failed turn of ${sessionID}: ${String(error)}`));
25
+ await children.settled(sessionID, "failed", at, undefined, wait === undefined ? undefined : at + wait).catch((error) => ports.log(`courier watch: could not note the failed turn of ${sessionID}: ${String(error)}`));
25
26
  const out = await Promise.all(entries.map((entry) => outIn(ports, entry)));
26
27
  // Left before the notice, so the parent waits on the group meanwhile; nudged after it, so the group's
27
28
  // message does not overtake the notice, short of an interval tick falling in between.
@@ -33,7 +34,7 @@ export async function reportFailure(ports, seen, event) {
33
34
  return [];
34
35
  await Promise.all(entries.map((entry, index) => ports.session.synthetic({
35
36
  sessionID: entry.parentID,
36
- text: envelope(sessionID, failureNotice(entry.title, error, out[index]?.group), { failed: error.type }),
37
+ text: envelope(sessionID, failureNotice(entry.title, error, out[index]?.group, at), { failed: error.type }),
37
38
  description: `Session ${sessionID} failed`,
38
39
  metadata: { source: "courier", from: sessionID, failed: true },
39
40
  delivery: "steer",
@@ -65,14 +65,18 @@ async function row(ports, windows, { entry, depth, info, running, endedAt, pendi
65
65
  ]);
66
66
  const tool = lastTool(messages);
67
67
  const context = contextOf(messages, windows);
68
+ const state = stateOf(info, pending.length + forms.length, stalled !== undefined, running);
69
+ // A prompt since the failure drops it from the report, so `resumesAt` here is that of the last turn alone.
70
+ const resumes = state === "idle" && !stopped && report?.resumesAt !== undefined && report.resumesAt > ports.now() ? report.resumesAt : undefined;
68
71
  return {
69
72
  sessionID,
70
73
  parentID: entry.parentID,
71
74
  title: info?.title ?? entry.title,
72
75
  depth,
73
- state: stateOf(info, pending.length + forms.length, stalled !== undefined, running),
76
+ state,
74
77
  ...(stalled !== undefined ? { stalled } : {}),
75
78
  ...(info?.outcome ? { outcome: info.outcome } : {}),
79
+ ...(resumes !== undefined ? { resumes } : {}),
76
80
  ...(stopped ? { stopped } : {}),
77
81
  ...reportRow(report, status),
78
82
  ...(entry.group && standing ? { group: { name: entry.group, standing } } : {}),
@@ -13,11 +13,12 @@ export type RosterStorage = Storage;
13
13
  export type Settled = "report" | "failed" | "interrupted";
14
14
  /** The notes kept of a spawned session's report state; docs/maintainers/architecture.md has each. */
15
15
  export type ReportField = "prompt" | "settled" | "progress" | "told" | "stopped";
16
- /** A report note as read: when it was made, and for `settled` how the wait ended and the report's status, as stored. */
16
+ /** A report note as read: when it was made, and for `settled` how the wait ended, the report's status or a failure's `resumesAt`, as stored. */
17
17
  export interface ReportNote {
18
18
  readonly at: number | undefined;
19
19
  readonly by: unknown;
20
20
  readonly status: unknown;
21
+ readonly resumesAt: unknown;
21
22
  }
22
23
  /** A stored value, as `Storage` gives and takes it. */
23
24
  export type Stored = Parameters<Storage["set"]>[1];
@@ -59,8 +60,11 @@ export interface ChildStore {
59
60
  }>;
60
61
  /** Notes that a prompt or message reached a spawned session at `at`, unless a later one is noted. */
61
62
  prompted(sessionID: string, at: number): Promise<void>;
62
- /** Notes how a spawned session's wait for a report ended at `at`, with its report's status, unless a later end is noted. */
63
- settled(sessionID: string, by: Settled, at: number, status?: Status): Promise<void>;
63
+ /**
64
+ * Notes how a spawned session's wait for a report ended at `at`, with its report's status or, for a failed turn, when the
65
+ * provider's limit lifts (`resumesAt`), unless a later end is noted.
66
+ */
67
+ settled(sessionID: string, by: Settled, at: number, status?: Status, resumesAt?: number): Promise<void>;
64
68
  /** Notes that a spawned session messaged its parent without a status at `at`, unless a later one is noted. */
65
69
  progressed(sessionID: string, at: number): Promise<void>;
66
70
  /** Notes that a spawned session's parent was told at `at` that a turn ended without a report, unless a later telling is noted. */
@@ -283,6 +287,8 @@ export interface ExecutionError {
283
287
  readonly type: string;
284
288
  readonly message: string;
285
289
  readonly status?: number;
290
+ /** How long the provider asked to wait before trying again, counted from the event's `created`; set by rate and quota limits and some provider errors. */
291
+ readonly retryAfterMs?: number;
286
292
  }
287
293
  export interface WatchPorts {
288
294
  readonly storage: RosterStorage;
@@ -7,9 +7,10 @@ export declare function stoppedSincePrompt(children: Notes, sessionID: string):
7
7
  export declare function lastStatus(children: Notes, sessionID: string): Promise<Status | undefined>;
8
8
  /**
9
9
  * What is known of a spawned session's report: when a prompt last reached it, and whether since then it owes one, has
10
- * messaged its parent without a status, or failed or was interrupted. Undefined for one spawned before this was kept.
10
+ * messaged its parent without a status, or failed (`resumesAt`: until when) or was interrupted. Undefined before this was kept.
11
11
  */
12
12
  export declare function reportOf(children: Notes, sessionID: string): Promise<{
13
+ resumesAt?: number | undefined;
13
14
  prompt: number;
14
15
  owes: boolean;
15
16
  progressed: boolean;
@@ -13,7 +13,7 @@ export async function lastStatus(children, sessionID) {
13
13
  }
14
14
  /**
15
15
  * What is known of a spawned session's report: when a prompt last reached it, and whether since then it owes one, has
16
- * messaged its parent without a status, or failed or was interrupted. Undefined for one spawned before this was kept.
16
+ * messaged its parent without a status, or failed (`resumesAt`: until when) or was interrupted. Undefined before this was kept.
17
17
  */
18
18
  export async function reportOf(children, sessionID) {
19
19
  const { prompt, settled, progress } = await children.notes(sessionID, ["prompt", "settled", "progress"]);
@@ -21,7 +21,14 @@ export async function reportOf(children, sessionID) {
21
21
  if (promptAt === undefined)
22
22
  return undefined;
23
23
  const done = since(settled.at, promptAt);
24
- return { prompt: promptAt, owes: !done, progressed: since(progress.at, promptAt), ended: done && settled.by !== "report" };
24
+ const resumesAt = done && settled.by === "failed" && typeof settled.resumesAt === "number" ? settled.resumesAt : undefined;
25
+ return {
26
+ prompt: promptAt,
27
+ owes: !done,
28
+ progressed: since(progress.at, promptAt),
29
+ ended: done && settled.by !== "report",
30
+ ...(resumesAt === undefined ? {} : { resumesAt }),
31
+ };
25
32
  }
26
33
  /**
27
34
  * Whether its parent still waits on a spawned session's report: it owes one, and the parent has not
@@ -91,6 +91,7 @@ export declare function registerTreeRpc(rpc: Pick<Plugin.Context["rpc"], "regist
91
91
  readonly name: string;
92
92
  readonly at: number;
93
93
  } | undefined;
94
+ readonly resumes?: number | undefined;
94
95
  }[];
95
96
  readonly stalled?: number | undefined;
96
97
  readonly truncated?: number | undefined;
@@ -133,6 +134,7 @@ export declare function registerTreeRpc(rpc: Pick<Plugin.Context["rpc"], "regist
133
134
  readonly name: string;
134
135
  readonly at: number;
135
136
  } | undefined;
137
+ readonly resumes?: number | undefined;
136
138
  }[];
137
139
  readonly stalled?: number | undefined;
138
140
  readonly truncated?: number | undefined;
@@ -145,6 +147,7 @@ export declare function registerTreeRpc(rpc: Pick<Plugin.Context["rpc"], "regist
145
147
  readonly state: import("effect/Schema").Literals<readonly ["running", "waiting", "idle", "stalled", "gone"]>;
146
148
  readonly stalled: import("effect/Schema").optionalKey<import("effect/Schema").Number>;
147
149
  readonly outcome: import("effect/Schema").optionalKey<import("effect/Schema").Literals<readonly ["succeeded", "failed", "interrupted"]>>;
150
+ readonly resumes: import("effect/Schema").optionalKey<import("effect/Schema").Number>;
148
151
  readonly stopped: import("effect/Schema").optionalKey<import("effect/Schema").Boolean>;
149
152
  readonly report: import("effect/Schema").optionalKey<import("effect/Schema").Struct<{
150
153
  readonly state: import("effect/Schema").Literals<readonly ["owed", "reported", "ended"]>;
@@ -44,6 +44,8 @@ declare const Row: Schema.Struct<{
44
44
  readonly stalled: Schema.optionalKey<Schema.Number>;
45
45
  /** How its last turn ended. */
46
46
  readonly outcome: Schema.optionalKey<Schema.Literals<readonly ["succeeded", "failed", "interrupted"]>>;
47
+ /** When the provider limit that failed its last turn lifts; left out once that has passed, or when it runs again or was stopped. */
48
+ readonly resumes: Schema.optionalKey<Schema.Number>;
47
49
  /** Set when courier_stop stopped it since its last prompt. */
48
50
  readonly stopped: Schema.optionalKey<Schema.Boolean>;
49
51
  /** Its report since its last prompt: still owed, arrived (held with its group or delivered), or its turn ended without one. */
@@ -99,6 +101,8 @@ declare const Tree: Schema.Struct<{
99
101
  readonly stalled: Schema.optionalKey<Schema.Number>;
100
102
  /** How its last turn ended. */
101
103
  readonly outcome: Schema.optionalKey<Schema.Literals<readonly ["succeeded", "failed", "interrupted"]>>;
104
+ /** When the provider limit that failed its last turn lifts; left out once that has passed, or when it runs again or was stopped. */
105
+ readonly resumes: Schema.optionalKey<Schema.Number>;
102
106
  /** Set when courier_stop stopped it since its last prompt. */
103
107
  readonly stopped: Schema.optionalKey<Schema.Boolean>;
104
108
  /** Its report since its last prompt: still owed, arrived (held with its group or delivered), or its turn ended without one. */
@@ -258,6 +262,7 @@ export declare const treeRpc: {
258
262
  readonly name: string;
259
263
  readonly at: number;
260
264
  } | undefined;
265
+ readonly resumes?: number | undefined;
261
266
  }[];
262
267
  readonly stalled?: number | undefined;
263
268
  readonly truncated?: number | undefined;
@@ -300,6 +305,7 @@ export declare const treeRpc: {
300
305
  readonly name: string;
301
306
  readonly at: number;
302
307
  } | undefined;
308
+ readonly resumes?: number | undefined;
303
309
  }[];
304
310
  readonly stalled?: number | undefined;
305
311
  readonly truncated?: number | undefined;
@@ -319,6 +325,8 @@ export declare const treeRpc: {
319
325
  readonly stalled: Schema.optionalKey<Schema.Number>;
320
326
  /** How its last turn ended. */
321
327
  readonly outcome: Schema.optionalKey<Schema.Literals<readonly ["succeeded", "failed", "interrupted"]>>;
328
+ /** When the provider limit that failed its last turn lifts; left out once that has passed, or when it runs again or was stopped. */
329
+ readonly resumes: Schema.optionalKey<Schema.Number>;
322
330
  /** Set when courier_stop stopped it since its last prompt. */
323
331
  readonly stopped: Schema.optionalKey<Schema.Boolean>;
324
332
  /** Its report since its last prompt: still owed, arrived (held with its group or delivered), or its turn ended without one. */
package/dist/tree/rpc.js CHANGED
@@ -41,6 +41,8 @@ const Row = Schema.Struct({
41
41
  stalled: Schema.optionalKey(Schema.Number),
42
42
  /** How its last turn ended. */
43
43
  outcome: Schema.optionalKey(Schema.Literals(["succeeded", "failed", "interrupted"])),
44
+ /** When the provider limit that failed its last turn lifts; left out once that has passed, or when it runs again or was stopped. */
45
+ resumes: Schema.optionalKey(Schema.Number),
44
46
  /** Set when courier_stop stopped it since its last prompt. */
45
47
  stopped: Schema.optionalKey(Schema.Boolean),
46
48
  /** Its report since its last prompt: still owed, arrived (held with its group or delivered), or its turn ended without one. */
@@ -18,7 +18,7 @@ export function reverseKey(sessionID) {
18
18
  }
19
19
  /**
20
20
  * Whether a spawned session has reported since its last prompt, under `report/<sessionID>/`: `prompt`, `settled` (with
21
- * the report's status), `progress` (a message to its parent without a status) and `told`; docs/maintainers/architecture.md has each.
21
+ * the report's status, or when a failed turn's wait lifts), `progress` (a message to its parent without a status) and `told`; docs/maintainers/architecture.md has each.
22
22
  */
23
23
  const REPORT_PREFIX = "report/";
24
24
  export const promptKey = (sessionID) => `${REPORT_PREFIX}${sessionID}/prompt`;
@@ -60,7 +60,7 @@ const timeOf = (value) => {
60
60
  const at = obj(value).at;
61
61
  return typeof at === "number" && Number.isFinite(at) ? at : undefined;
62
62
  };
63
- const noteOf = (value) => ({ at: timeOf(value), by: obj(value).by, status: obj(value).status });
63
+ const noteOf = (value) => ({ at: timeOf(value), by: obj(value).by, status: obj(value).status, resumesAt: obj(value).resumesAt });
64
64
  /** The `ChildStore` over the plugin's storage. */
65
65
  export function childStore(storage) {
66
66
  const entry = async (parentID, sessionID) => (await storage.get(rosterKey(parentID, sessionID)));
@@ -173,9 +173,9 @@ export function childStore(storage) {
173
173
  if ((timeOf(await storage.get(promptKey(sessionID))) ?? -Infinity) < at)
174
174
  await storage.set(promptKey(sessionID), { at });
175
175
  },
176
- async settled(sessionID, by, at, status) {
176
+ async settled(sessionID, by, at, status, resumesAt) {
177
177
  if ((timeOf(await storage.get(settledKey(sessionID))) ?? -Infinity) <= at)
178
- await storage.set(settledKey(sessionID), { at, by, ...(status ? { status } : {}) });
178
+ await storage.set(settledKey(sessionID), { at, by, ...(status ? { status } : {}), ...(resumesAt === undefined ? {} : { resumesAt }) });
179
179
  },
180
180
  async progressed(sessionID, at) {
181
181
  if ((timeOf(await storage.get(progressKey(sessionID))) ?? -Infinity) <= at)
@@ -61,7 +61,11 @@ export declare function keepReason(state: {
61
61
  readonly changes: readonly string[];
62
62
  readonly commits: readonly string[];
63
63
  }): string | undefined;
64
- export declare function failureNotice(title: string, error: ExecutionError, group?: string): string;
64
+ /**
65
+ * What the parent is told when a session it started failed its turn; `at` is when the failure happened, which the provider's
66
+ * retry time (`retryAfterMs`) counts from. Message the child before that and it fails again, so the notice says to wait.
67
+ */
68
+ export declare function failureNotice(title: string, error: ExecutionError, group?: string, at?: number): string;
65
69
  /**
66
70
  * What the parent is told when a session it started ends its turn without a report to it;
67
71
  * `progressed` when it messaged the parent without a status since its last prompt.
package/dist/tree/text.js CHANGED
@@ -1,6 +1,7 @@
1
1
  /** The tree's model-facing texts: the child brief, the role part, refusals, reports, notices and tool results. */
2
2
  import { obj, str } from "../platform/json.js";
3
3
  import { clipText, defuse, END_TURN, ROOT_SPLIT_LINE, SKILL_ID, SPLIT_LINE, STATUSES } from "../kernel/protocol.js";
4
+ import { retryDelay } from "./domain.js";
4
5
  // The envelope and the child brief.
5
6
  /** How a text names the statuses a report may carry. */
6
7
  const STATUS_LIST = `${STATUSES.slice(0, -1).join(", ")} or ${STATUSES.at(-1)}`;
@@ -217,12 +218,39 @@ function preview(items) {
217
218
  return items.length > 5 ? `${items.slice(0, 5).join(", ")}, ...` : items.join(", ");
218
219
  }
219
220
  // Turn failures.
220
- export function failureNotice(title, error, group) {
221
+ /** A wait in whole minutes, rounded up: "1 minute", "45 minutes", "2 hours 5 minutes". */
222
+ function wait(ms) {
223
+ const minutes = Math.ceil(ms / 60_000);
224
+ if (minutes < 60)
225
+ return count(minutes, "minute");
226
+ const rest = minutes % 60;
227
+ return [count(Math.floor(minutes / 60), "hour"), rest ? count(rest, "minute") : ""].filter(Boolean).join(" ");
228
+ }
229
+ /** A time as the notices say it, in UTC, with its date when it is not the day of `from`. */
230
+ function clock(at, from) {
231
+ const iso = new Date(at).toISOString();
232
+ return iso.slice(0, 10) === new Date(from).toISOString().slice(0, 10) ? `${iso.slice(11, 16)} UTC` : `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`;
233
+ }
234
+ /** The line saying how long the provider asked to wait, and until when if the failure's time `at` is known. */
235
+ function waitLine(delay, at) {
236
+ const until = at === undefined ? "" : `, until about ${clock(at + delay, at)}`;
237
+ return `The provider asked to wait about ${wait(delay)} before trying again${until}.`;
238
+ }
239
+ /**
240
+ * What the parent is told when a session it started failed its turn; `at` is when the failure happened, which the provider's
241
+ * retry time (`retryAfterMs`) counts from. Message the child before that and it fails again, so the notice says to wait.
242
+ */
243
+ export function failureNotice(title, error, group, at) {
221
244
  const status = error.status === undefined ? "" : `, status ${error.status}`;
245
+ const delay = retryDelay(error);
222
246
  return [
223
247
  `This session, "${title}", which you started with courier_spawn, failed: ${error.message} (${error.type}${status}).`,
248
+ ...(delay === undefined ? [] : [waitLine(delay, at)]),
224
249
  "Its turn ended without finishing, so it will not report back on its own.",
225
- "Message it with courier_send to have it try again, start a replacement, or carry on without it.",
250
+ delay === undefined
251
+ ? "Message it with courier_send to have it try again, start a replacement, or carry on without it."
252
+ : "Do not message it with courier_send before then, as the turn would fail again. Message it after, start a replacement, or carry on without it; " +
253
+ "courier_later can remind you when the wait is over.",
226
254
  ...(group
227
255
  ? [
228
256
  `It is a member of group "${group}", which no longer waits for it: its other members' reports reach you once they have ` +
@@ -98,9 +98,9 @@ export declare function bar(counts: {
98
98
  /** What a paused session waits on, such as "asks permission: shell echo hi" or "shows a form: Web Search". */
99
99
  export declare function waitLine(wait: Wait): string;
100
100
  /** What a row's session is doing, its report and its group standing: "idle · reported done · group pair held". */
101
- export declare function statusLine(row: Row): string;
101
+ export declare function statusLine(row: Row, now?: number): string;
102
102
  /** The parts of `statusLine`, each with the row part the panel colours it as. */
103
- export declare function statusParts(row: Row): {
103
+ export declare function statusParts(row: Row, now?: number): {
104
104
  text: string;
105
105
  kind: "state" | "report" | "plain";
106
106
  }[];
package/dist/tui.js CHANGED
@@ -1452,6 +1452,7 @@ var Row = Schema3.Struct({
1452
1452
  state: Schema3.Literals(["running", "waiting", "idle", "stalled", "gone"]),
1453
1453
  stalled: Schema3.optionalKey(Schema3.Number),
1454
1454
  outcome: Schema3.optionalKey(Schema3.Literals(["succeeded", "failed", "interrupted"])),
1455
+ resumes: Schema3.optionalKey(Schema3.Number),
1455
1456
  stopped: Schema3.optionalKey(Schema3.Boolean),
1456
1457
  report: Schema3.optionalKey(Schema3.Struct({ state: Schema3.Literals(["owed", "reported", "ended"]), status: Schema3.optionalKey(Schema3.String), progressed: Schema3.Boolean })),
1457
1458
  group: Schema3.optionalKey(Schema3.Struct({ name: Schema3.String, standing: Schema3.String })),
@@ -1723,12 +1724,12 @@ function waitLine(wait) {
1723
1724
  return `asks permission: ${[wait.action, ...wait.resources].join(" ")}`;
1724
1725
  return wait.type === "form" ? `shows a form: ${wait.title}` : `asks: ${wait.question}`;
1725
1726
  }
1726
- function statusLine(row) {
1727
- return statusParts(row).map((part) => part.text).join(" \xB7 ");
1727
+ function statusLine(row, now = Date.now()) {
1728
+ return statusParts(row, now).map((part) => part.text).join(" \xB7 ");
1728
1729
  }
1729
- function statusParts(row) {
1730
+ function statusParts(row, now = Date.now()) {
1730
1731
  const parts = [{
1731
- text: stateWord(row),
1732
+ text: stateWord(row, now),
1732
1733
  kind: "state"
1733
1734
  }, {
1734
1735
  text: reportWords(row.report, row.stopped),
@@ -1785,13 +1786,21 @@ function rowMark(row, theme, frame) {
1785
1786
  color: undefined
1786
1787
  };
1787
1788
  }
1788
- function stateWord(row) {
1789
+ function resumeTime(at, now) {
1790
+ const time = new Date(at);
1791
+ const pad = (value) => String(value).padStart(2, "0");
1792
+ const clock = `${pad(time.getHours())}:${pad(time.getMinutes())}`;
1793
+ return time.toDateString() === new Date(now).toDateString() ? clock : `${pad(time.getMonth() + 1)}-${pad(time.getDate())} ${clock}`;
1794
+ }
1795
+ function stateWord(row, now) {
1789
1796
  if (row.state === "waiting")
1790
1797
  return "waiting on you";
1791
1798
  if (row.state === "stalled")
1792
1799
  return "stalled";
1793
1800
  if (row.state === "idle" && row.stopped)
1794
1801
  return "stopped";
1802
+ if (row.state === "idle" && row.outcome === "failed" && row.resumes !== undefined && row.resumes > now)
1803
+ return `limit, resumes ${resumeTime(row.resumes, now)}`;
1795
1804
  if (row.state === "idle" && (row.outcome === "failed" || row.outcome === "interrupted"))
1796
1805
  return row.outcome;
1797
1806
  return row.state;
@@ -2485,7 +2494,7 @@ function TreePanel(props) {
2485
2494
  _$setProp(_el$30, "flexShrink", 0);
2486
2495
  _$insert(_el$25, _$createComponent(For, {
2487
2496
  get each() {
2488
- return statusParts(row);
2497
+ return statusParts(row, now());
2489
2498
  },
2490
2499
  children: (part, at) => [_$createComponent(Show, {
2491
2500
  get when() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-courier",
3
- "version": "0.10.3",
3
+ "version": "0.11.0",
4
4
  "description": "Multi-session orchestration for OpenCode V2: spawn child sessions, be woken by them. No polling.",
5
5
  "license": "MIT",
6
6
  "author": "Ivo Pogace <71701666+ivopogace@users.noreply.github.com>",
@@ -56,12 +56,12 @@
56
56
  "test:e2e": "e2e/run.sh"
57
57
  },
58
58
  "peerDependencies": {
59
- "@opencode/plugin": "2.1.2",
59
+ "@opencode/plugin": "2.1.3",
60
60
  "effect": "4.0.1"
61
61
  },
62
62
  "devDependencies": {
63
- "@opencode/plugin": "2.1.2",
64
- "@opencode/theme": "2.1.2",
63
+ "@opencode/plugin": "2.1.3",
64
+ "@opencode/theme": "2.1.3",
65
65
  "@opentui/core": "0.6.6",
66
66
  "@opentui/solid": "0.6.6",
67
67
  "@types/bun": "1.4.2",