opencode-courier 0.10.3 → 0.11.1
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 +85 -309
- package/dist/tree/domain.d.ts +4 -0
- package/dist/tree/domain.js +7 -0
- package/dist/tree/events.js +5 -4
- package/dist/tree/panel.js +5 -1
- package/dist/tree/ports.d.ts +9 -3
- package/dist/tree/report.d.ts +2 -1
- package/dist/tree/report.js +9 -2
- package/dist/tree/rpc-handlers.d.ts +3 -0
- package/dist/tree/rpc.d.ts +8 -0
- package/dist/tree/rpc.js +2 -0
- package/dist/tree/spawn.js +4 -4
- package/dist/tree/store.js +4 -4
- package/dist/tree/text.d.ts +5 -1
- package/dist/tree/text.js +30 -2
- package/dist/tree/tui.d.ts +2 -2
- package/dist/tui.js +15 -6
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -6,9 +6,6 @@ polling.
|
|
|
6
6
|
|
|
7
7
|
[](https://github.com/ivopogace/opencode-courier/actions/workflows/ci.yml)
|
|
8
8
|
[](https://sonarcloud.io/summary/new_code?id=ivopogace_opencode-courier)
|
|
9
|
-
[](https://sonarcloud.io/summary/new_code?id=ivopogace_opencode-courier)
|
|
10
|
-
[](https://sonarcloud.io/summary/new_code?id=ivopogace_opencode-courier)
|
|
11
|
-
[](https://sonarcloud.io/summary/new_code?id=ivopogace_opencode-courier)
|
|
12
9
|
[](https://www.npmjs.com/package/opencode-courier)
|
|
13
10
|
[](https://socket.dev/npm/package/opencode-courier)
|
|
14
11
|
[](LICENSE)
|
|
@@ -16,15 +13,14 @@ polling.
|
|
|
16
13
|
|
|
17
14
|
https://github.com/user-attachments/assets/d7cf74f8-15b3-4cc1-866e-b75ba0ac3f8a
|
|
18
15
|
|
|
19
|
-
|
|
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.
|
|
26
|
-
opencode plugin add opencode-courier
|
|
27
|
-
opencode
|
|
21
|
+
npm install -g @opencode/cli@2.1.4 # 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
|
|
37
|
-
|
|
38
|
-
|
|
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)).
|
|
106
|
-
|
|
107
|
-
## Install
|
|
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.
|
|
108
35
|
|
|
109
|
-
|
|
36
|
+
## Why courier
|
|
110
37
|
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
48
|
+
## How it works
|
|
126
49
|
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
npm install -g @opencode/cli@2.1.2
|
|
134
|
-
```
|
|
60
|
+
## Install
|
|
135
61
|
|
|
136
|
-
|
|
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.4** (check with `opencode --version`).
|
|
137
64
|
|
|
138
65
|
```bash
|
|
66
|
+
npm install -g @opencode/cli@2.1.4
|
|
139
67
|
opencode plugin add opencode-courier
|
|
140
68
|
```
|
|
141
69
|
|
|
142
|
-
This
|
|
143
|
-
|
|
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
|
-
|
|
149
|
-
`opencode.json` (V2 uses `plugins`, plural; a path to a file is ignored with a warning):
|
|
74
|
+
### Supported OpenCode version
|
|
150
75
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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.1 | 2.1.4 |
|
|
80
|
+
| 0.11.0 | 2.1.3 |
|
|
81
|
+
| 0.10.2, 0.10.3 | 2.1.2 |
|
|
82
|
+
<!-- compat-table:end -->
|
|
156
83
|
|
|
157
|
-
|
|
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.4 only.
|
|
180
86
|
|
|
181
|
-
##
|
|
87
|
+
## Options
|
|
182
88
|
|
|
183
|
-
|
|
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
|
|
247
|
-
| `maxChildren` | `5` |
|
|
248
|
-
| `maxTotal` | `20` |
|
|
249
|
-
|
|
250
|
-
`
|
|
251
|
-
|
|
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
|
-
|
|
291
|
-
|
|
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
|
-
|
|
301
|
-
{
|
|
302
|
-
"plugins": [{ "package": "opencode-courier", "options": { "animations": false } }]
|
|
303
|
-
}
|
|
304
|
-
```
|
|
110
|
+
## Tools
|
|
305
111
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
`
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
362
|
-
reference](docs/reference.md#webhooks).
|
|
134
|
+
More: [in the terminal](docs/reference.md#in-the-terminal).
|
|
363
135
|
|
|
364
|
-
##
|
|
136
|
+
## Tips
|
|
365
137
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
|
374
|
-
|
|
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
|
|
package/dist/tree/domain.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/tree/domain.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/tree/events.js
CHANGED
|
@@ -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
|
-
|
|
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",
|
package/dist/tree/panel.js
CHANGED
|
@@ -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
|
|
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 } } : {}),
|
package/dist/tree/ports.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
63
|
-
|
|
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;
|
package/dist/tree/report.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/tree/report.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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"]>;
|
package/dist/tree/rpc.d.ts
CHANGED
|
@@ -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. */
|
package/dist/tree/spawn.js
CHANGED
|
@@ -9,15 +9,15 @@ function titleOf(task) {
|
|
|
9
9
|
return line.length > 60 ? `${line.slice(0, 57)}...` : line;
|
|
10
10
|
}
|
|
11
11
|
/**
|
|
12
|
-
* The model a child runs on
|
|
13
|
-
* the
|
|
12
|
+
* The model a child runs on, always passed on so no OpenCode version falls back to its default:
|
|
13
|
+
* the named agent's own, else its parent's.
|
|
14
14
|
*/
|
|
15
15
|
async function inheritedModel(ports, parentID, agent) {
|
|
16
16
|
const parent = await ports.session.get({ sessionID: parentID });
|
|
17
|
-
if (!
|
|
17
|
+
if (!agent)
|
|
18
18
|
return parent.model;
|
|
19
19
|
const named = await ports.agent.get({ agentID: agent, location: { directory: ports.directory } });
|
|
20
|
-
return named.data.model
|
|
20
|
+
return named.data.model ?? parent.model;
|
|
21
21
|
}
|
|
22
22
|
/** Removes a worktree made for a child that was never created; one that cannot be removed stays. */
|
|
23
23
|
async function dropWorktree(ports, directory) {
|
package/dist/tree/store.js
CHANGED
|
@@ -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)
|
package/dist/tree/text.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ` +
|
package/dist/tree/tui.d.ts
CHANGED
|
@@ -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
|
|
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.
|
|
3
|
+
"version": "0.11.1",
|
|
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,14 +56,14 @@
|
|
|
56
56
|
"test:e2e": "e2e/run.sh"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
|
-
"@opencode/plugin": "2.1.
|
|
59
|
+
"@opencode/plugin": "2.1.4",
|
|
60
60
|
"effect": "4.0.1"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
|
-
"@opencode/plugin": "2.1.
|
|
64
|
-
"@opencode/theme": "2.1.
|
|
65
|
-
"@opentui/core": "0.6.
|
|
66
|
-
"@opentui/solid": "0.6.
|
|
63
|
+
"@opencode/plugin": "2.1.4",
|
|
64
|
+
"@opencode/theme": "2.1.4",
|
|
65
|
+
"@opentui/core": "0.6.8",
|
|
66
|
+
"@opentui/solid": "0.6.8",
|
|
67
67
|
"@types/bun": "1.4.2",
|
|
68
68
|
"@types/node": "26.6.4",
|
|
69
69
|
"effect": "4.0.1",
|