opencode-courier 0.7.2 → 0.8.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 +122 -135
- package/dist/index.js +24 -4
- package/dist/memory/domain.d.ts +119 -0
- package/dist/memory/domain.js +84 -0
- package/dist/memory/events.d.ts +26 -0
- package/dist/memory/events.js +46 -0
- package/dist/memory/forget.d.ts +10 -0
- package/dist/memory/forget.js +20 -0
- package/dist/memory/index.d.ts +10 -0
- package/dist/memory/index.js +11 -0
- package/dist/memory/note.d.ts +37 -0
- package/dist/memory/note.js +29 -0
- package/dist/memory/ports.d.ts +44 -0
- package/dist/memory/ports.js +1 -0
- package/dist/memory/recall.d.ts +27 -0
- package/dist/memory/recall.js +48 -0
- package/dist/memory/record.d.ts +7 -0
- package/dist/memory/record.js +92 -0
- package/dist/memory/rpc-handlers.d.ts +134 -0
- package/dist/memory/rpc-handlers.js +23 -0
- package/dist/memory/rpc.d.ts +227 -0
- package/dist/memory/rpc.js +60 -0
- package/dist/memory/store.d.ts +10 -0
- package/dist/memory/store.js +90 -0
- package/dist/memory/sweep.d.ts +4 -0
- package/dist/memory/sweep.js +12 -0
- package/dist/memory/text.d.ts +27 -0
- package/dist/memory/text.js +95 -0
- package/dist/memory/tools.d.ts +16 -0
- package/dist/memory/tools.js +78 -0
- package/dist/memory/tui.d.ts +73 -0
- package/dist/person/rpc.d.ts +4 -4
- package/dist/platform/json.d.ts +1 -0
- package/dist/platform/json.js +1 -0
- package/dist/platform/version.js +2 -2
- package/dist/rpc.d.ts +1 -0
- package/dist/rpc.js +1 -0
- package/dist/skill.js +10 -3
- package/dist/tree/context.d.ts +2 -2
- package/dist/tree/context.js +4 -3
- package/dist/tree/index.d.ts +6 -5
- package/dist/tree/index.js +5 -4
- package/dist/tree/mascot.d.ts +50 -0
- package/dist/tree/mascot.js +171 -0
- package/dist/tree/panel.d.ts +3 -1
- package/dist/tree/panel.js +17 -3
- package/dist/tree/ports.d.ts +12 -2
- package/dist/tree/roster.d.ts +17 -0
- package/dist/tree/roster.js +24 -0
- package/dist/tree/rpc-handlers.d.ts +21 -18
- package/dist/tree/rpc.d.ts +26 -18
- package/dist/tree/rpc.js +2 -0
- package/dist/tree/status.d.ts +5 -5
- package/dist/tree/subtree.d.ts +2 -2
- package/dist/tree/text.js +2 -1
- package/dist/tree/tui.d.ts +115 -8
- package/dist/tui.js +1269 -222
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ The edge cases on the live terminal UI, narrated ([what it shows](#demos)).
|
|
|
21
21
|
## Quickstart
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
|
-
npm install -g @opencode/cli@2.0.
|
|
24
|
+
npm install -g @opencode/cli@2.0.32 # OpenCode V2, at the version courier is tested on
|
|
25
25
|
opencode plugin add opencode-courier # by name, so OpenCode offers new releases
|
|
26
26
|
opencode # in any folder
|
|
27
27
|
```
|
|
@@ -38,49 +38,44 @@ If OpenCode asks to allow a child's command, the parent passes the question to y
|
|
|
38
38
|
sometimes stall on a request; if nothing moves for a few minutes, send the prompt again. Next:
|
|
39
39
|
[Using it](#using-it).
|
|
40
40
|
|
|
41
|
-
|
|
42
41
|
## OpenCode V2 native
|
|
43
42
|
|
|
44
43
|
Courier is built on OpenCode V2's plugin API (`@opencode/plugin`), not the V1 one
|
|
45
|
-
(`@opencode-ai/plugin`)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
plugin shows up there first. Older releases and their versions: [Supported OpenCode version](#supported-opencode-version).
|
|
44
|
+
(`@opencode-ai/plugin`). Each release is pinned to one V2 version and tested against it end to end;
|
|
45
|
+
the current one is tested on **OpenCode 2.0.32**. CI also runs the live suite on the newest OpenCode
|
|
46
|
+
release, on `main` and nightly. Older releases: [every release](docs/reference.md#the-opencode-version).
|
|
49
47
|
|
|
50
48
|
## Courier or the built-in subagent tool?
|
|
51
49
|
|
|
52
|
-
OpenCode V2
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
a self-contained task that needs nothing from you on the way. Use courier when the work needs more:
|
|
50
|
+
OpenCode V2's own [`subagent` tool](https://opencode.ai/v2/docs/agents) starts an agent with fresh
|
|
51
|
+
context in a child session; the parent waits for its final answer or, with `background: true`, is
|
|
52
|
+
notified when the child finishes. Use it when one result back is all you need. Use courier when the
|
|
53
|
+
work needs more:
|
|
57
54
|
|
|
58
|
-
- **Messages from the child, mid-run:** a child reports progress
|
|
55
|
+
- **Messages from the child, mid-run:** a child reports progress or asks its parent something
|
|
59
56
|
whenever it likes with `courier_send`, and any session can message any other.
|
|
60
57
|
- **Waking an idle session from anywhere:** a message from a child, a schedule (`courier_later`) or
|
|
61
|
-
a [webhook](#webhooks)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
a
|
|
68
|
-
model, and your answers go back; a form only you can answer is pointed out to you.
|
|
58
|
+
a [webhook](#webhooks) starts a new turn in a session that has ended its own.
|
|
59
|
+
- **Worktree isolation:** `isolate: true` gives a child its own git worktree, so children edit files
|
|
60
|
+
in parallel; each commits on `courier/<its session ID>` and reports that branch for its parent to
|
|
61
|
+
merge ([nested isolation](docs/reference.md#nested-isolation)).
|
|
62
|
+
- **Relaying to you:** a child's permission requests and questions reach you through the parent. A
|
|
63
|
+
request is approved in OpenCode's own permission prompt in the parent's session, never by its
|
|
64
|
+
model; a form only you can answer is pointed out to you.
|
|
69
65
|
|
|
70
66
|
## How it works
|
|
71
67
|
|
|
72
|
-
A parent
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
idle); `queue: true` waits until the current turn ends.
|
|
68
|
+
A parent calls `courier_spawn`, gets a session id back at once and ends its turn. The child works
|
|
69
|
+
on its own and, when done or stuck, calls `courier_send` with the parent's id, a status and what it
|
|
70
|
+
produced. The message lands in the parent's inbox through OpenCode's own API, the way OpenCode's
|
|
71
|
+
background subagents report, and starts a new turn if the parent is idle. Delivery is `steer` by
|
|
72
|
+
default (into the target's running turn, or a new one if idle); `queue: true` waits until the current
|
|
73
|
+
turn ends.
|
|
79
74
|
|
|
80
75
|
## Demos
|
|
81
76
|
|
|
82
|
-
The video at the top (1:46, narrated) is the TUI driven in `tmux` on the
|
|
83
|
-
|
|
77
|
+
The video at the top (1:46, narrated) is the TUI driven in `tmux` on the live test's scripted
|
|
78
|
+
stand-in model, so each scene is real plugin behaviour:
|
|
84
79
|
|
|
85
80
|
- **A join group:** one member reports and its report is held with the group, while the other waits
|
|
86
81
|
on a permission request, so the footer says one is waiting on you. You allow it from the asks inbox
|
|
@@ -95,11 +90,10 @@ the live test, with `e2e/tui.sh`'s setup, so each scene is real plugin behaviour
|
|
|
95
90
|
## Recursive orchestration
|
|
96
91
|
|
|
97
92
|
A session can split a goal across child sessions, each child deciding in turn whether to split its
|
|
98
|
-
part again, with the results checked and integrated at every level on the way back up. The
|
|
99
|
-
ships
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
reference](docs/reference.md#the-courier-orchestrate-skill).
|
|
93
|
+
part again, with the results checked and integrated at every level on the way back up. The playbook
|
|
94
|
+
ships as the OpenCode skill `courier-orchestrate`, listed in every session: models load it at the
|
|
95
|
+
start of an implementation task that may have independent parts, and `/skills` in the TUI attaches
|
|
96
|
+
it to your prompt ([what it says](docs/reference.md#the-courier-orchestrate-skill)).
|
|
103
97
|
|
|
104
98
|
## Install
|
|
105
99
|
|
|
@@ -107,31 +101,27 @@ reference](docs/reference.md#the-courier-orchestrate-skill).
|
|
|
107
101
|
|
|
108
102
|
Requires OpenCode V2: the `opencode` command from `@opencode/cli` (also installed as `opencode2`).
|
|
109
103
|
Each release is built and tested against exactly one OpenCode V2 version; a row lists the releases
|
|
110
|
-
pinned to its version. OpenCode never checks it:
|
|
111
|
-
|
|
112
|
-
|
|
104
|
+
pinned to its version. OpenCode never checks it: the plugin loads on any V2 host, and on another
|
|
105
|
+
version than the pinned one writes one line to the server log
|
|
106
|
+
([more](docs/reference.md#the-opencode-version)).
|
|
113
107
|
|
|
114
|
-
<!-- compat-table:start: generated by script/compat-table.ts from the release tags -->
|
|
108
|
+
<!-- compat-table:start rows=3: generated by script/compat-table.ts from the release tags -->
|
|
115
109
|
| opencode-courier | OpenCode V2 (`opencode` and `@opencode/plugin`) |
|
|
116
110
|
|---|---|
|
|
111
|
+
| 0.7.3, 0.8.0 | 2.0.32 |
|
|
117
112
|
| 0.7.2 | 2.0.31 |
|
|
118
113
|
| 0.7.1 | 2.0.30 |
|
|
119
|
-
| 0.5.0, 0.6.0, 0.7.0 | 2.0.29 |
|
|
120
|
-
| 0.4.0, 0.4.1 | 2.0.28 |
|
|
121
|
-
| 0.2.3, 0.3.0 | 2.0.26 |
|
|
122
|
-
| 0.2.2 | 2.0.24 |
|
|
123
|
-
| 0.2.1 | 2.0.23 |
|
|
124
|
-
| 0.2.0 | 2.0.22 |
|
|
125
|
-
| 0.1.6 | 0.0.0-beta-19271 (the beta line: `opencode2` from `@opencode-ai/cli`, and `@opencode-ai/plugin`) |
|
|
126
114
|
<!-- compat-table:end -->
|
|
127
115
|
|
|
116
|
+
Older releases and their versions: [every release](docs/reference.md#the-opencode-version).
|
|
117
|
+
|
|
128
118
|
The [terminal part](#in-the-terminal) relies on the host to provide its UI modules and is tested on
|
|
129
|
-
OpenCode 2.0.
|
|
119
|
+
OpenCode 2.0.32 only; the server part works on older V2 hosts from 2.0.4 on.
|
|
130
120
|
|
|
131
121
|
Check yours with `opencode --version`, and install the matching CLI with:
|
|
132
122
|
|
|
133
123
|
```bash
|
|
134
|
-
npm install -g @opencode/cli@2.0.
|
|
124
|
+
npm install -g @opencode/cli@2.0.32
|
|
135
125
|
```
|
|
136
126
|
|
|
137
127
|
### The plugin
|
|
@@ -143,9 +133,8 @@ opencode plugin add opencode-courier
|
|
|
143
133
|
This installs the package from npm and adds `"opencode-courier"` to `plugins` in the global
|
|
144
134
|
configuration (`~/.config/opencode/opencode.json`); its options ([session trees](#session-trees),
|
|
145
135
|
[webhooks](#webhooks)) go in the object form of that entry. Install it by name, without a version:
|
|
146
|
-
OpenCode only checks plugins for updates when their entry is not an exact version
|
|
147
|
-
|
|
148
|
-
entry](docs/reference.md#updating-the-plugin)).
|
|
136
|
+
OpenCode only checks plugins for updates when their entry is not an exact version
|
|
137
|
+
([updating](docs/reference.md#updating-the-plugin)).
|
|
149
138
|
|
|
150
139
|
From a local clone, `bun install && npm run build`, then list its `dist` **directory** in
|
|
151
140
|
`opencode.json` (V2 uses `plugins`, plural; a path to a file is ignored with a warning):
|
|
@@ -159,14 +148,15 @@ From a local clone, `bun install && npm run build`, then list its `dist` **direc
|
|
|
159
148
|
## Using it
|
|
160
149
|
|
|
161
150
|
1. Keep the background server running so sessions can be woken while you are away (`opencode
|
|
162
|
-
service start`), and run one OpenCode server per data directory where you can: a second one
|
|
163
|
-
|
|
151
|
+
service start`), and run one OpenCode server per data directory where you can: a second one
|
|
152
|
+
cannot pass your answer on to a child the other runs
|
|
164
153
|
([why](docs/reference.md#two-servers-on-one-data-directory)).
|
|
165
154
|
2. Give the agents that run children the permissions their work needs: a child that hits an approval
|
|
166
|
-
prompt waits until you answer OpenCode's prompt for it in the parent's session. Let them use the
|
|
167
|
-
should be able to ask you. Choose a web search provider before children
|
|
168
|
-
web search in your own session, or use OpenCode's "Third-party search"
|
|
169
|
-
child to search shows that prompt in its own session, which only you can
|
|
155
|
+
prompt waits until you answer OpenCode's prompt for it in the parent's session. Let them use the
|
|
156
|
+
`question` tool if they should be able to ask you. Choose a web search provider before children
|
|
157
|
+
search the web (run one web search in your own session, or use OpenCode's "Third-party search"
|
|
158
|
+
setting), or the first child to search shows that prompt in its own session, which only you can
|
|
159
|
+
answer there.
|
|
170
160
|
3. Your own session's prompts are relayed nowhere, and each one stalls the whole tree until you
|
|
171
161
|
answer it. Before an unattended run, allow the orchestrating session's toolchain up front: one rule
|
|
172
162
|
on `bun run *` or `npm run *` (the skill has checks run through the project's scripts), `git`, and
|
|
@@ -195,38 +185,33 @@ From a local clone, `bun install && npm run build`, then list its `dist` **direc
|
|
|
195
185
|
| `courier_cancel` | Drops a message scheduled with `courier_later`. |
|
|
196
186
|
| `courier_subscribe` | Subscribes a session to webhook deliveries for a topic (`owner/repo`, `owner/repo#12` or a name). |
|
|
197
187
|
| `courier_unsubscribe` | Drops one topic, or all of a session's. |
|
|
188
|
+
| `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. |
|
|
189
|
+
| `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. |
|
|
198
190
|
|
|
199
191
|
Each tool's parameters: [the reference](docs/reference.md#the-tools).
|
|
200
192
|
|
|
201
193
|
## How children reach you
|
|
202
194
|
|
|
203
195
|
- **A child reports once, with a status**: `done`, `partial`, `blocked` (it needs a decision from
|
|
204
|
-
its parent, and says what) or `failed`, with its artifacts
|
|
205
|
-
|
|
206
|
-
[More](docs/reference.md#a-childs-report).
|
|
196
|
+
its parent, and says what) or `failed`, with its artifacts in the message. A message without a
|
|
197
|
+
status is progress, not the report. [More](docs/reference.md#a-childs-report).
|
|
207
198
|
- **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
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
- **A child that fails, or ends its turn without reporting, is reported** to its parent
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
(
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
prompt
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
and under its rules, before anything is passed on: its model cannot approve, only propose. For a
|
|
225
|
-
question, it asks you and passes your answer back, or links its own question to the child's so your
|
|
226
|
-
pick goes straight to it. Whichever answer comes first counts, and the other is withdrawn.
|
|
227
|
-
A request starts a turn in the top session if it is idle; if you answer it somewhere else, the
|
|
228
|
-
note that it is settled waits for that session's next turn, usually the child's report or its
|
|
229
|
-
silence, instead of starting one of its own. [More](docs/reference.md#a-child-that-asks-for-permission).
|
|
199
|
+
`done`, `partial` and `failed` reports are held until the last one is in, then delivered in one
|
|
200
|
+
message; a `blocked` report, a failure, a question or a permission request still reaches you at
|
|
201
|
+
once. A member that will not report holds the others back: drop it with `courier_cleanup`.
|
|
202
|
+
[More](docs/reference.md#join-groups).
|
|
203
|
+
- **A child that fails, or ends its turn without reporting, is reported** to its parent, with the
|
|
204
|
+
error or its last reply, unless it waits on a request, its own children, a scheduled message or a
|
|
205
|
+
webhook. [More](docs/reference.md#a-child-that-fails).
|
|
206
|
+
- **A message that fails to wake its session is sent again** after 30 seconds; if the session still
|
|
207
|
+
does not take it, the footer and the tree panel show it as `stalled`, for you to prompt it.
|
|
208
|
+
[More](docs/reference.md#a-missed-wake).
|
|
209
|
+
- **Permission requests and questions reach you** through the session at the top of the tree, any
|
|
210
|
+
number of levels down. For a request, that session proposes an answer with `courier_answer`, and
|
|
211
|
+
OpenCode asks you in its own permission prompt there before anything is passed on: its model
|
|
212
|
+
cannot approve, only propose. For a question, it asks you and passes your answer back, or links
|
|
213
|
+
its own question to the child's so your pick goes straight to it.
|
|
214
|
+
[More](docs/reference.md#a-child-that-asks-for-permission).
|
|
230
215
|
- **Some forms only you can answer**, such as OpenCode's web search asking for a provider: the top
|
|
231
216
|
session is told, and you answer in the child's session. [More](docs/reference.md#a-child-that-shows-a-form).
|
|
232
217
|
- **You can see and stop a whole subtree** with `courier_tree` and `courier_stop`, or in the
|
|
@@ -253,73 +238,75 @@ options in the plugin's `options`, in the global config:
|
|
|
253
238
|
| `maxChildren` | `5` | How many sessions one session started may have live at once. |
|
|
254
239
|
| `maxTotal` | `20` | How many spawned sessions may be live at once in one tree. |
|
|
255
240
|
|
|
256
|
-
`courier_spawn` refuses a spawn past a limit, naming it and saying what to do instead
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
interrupted), or a session below it is live. One that has reported, with nothing live below it,
|
|
260
|
-
does not. Each spawned session's brief says when to split its task and when to do it itself
|
|
241
|
+
`courier_spawn` refuses a spawn past a limit, naming it and saying what to do instead. A session
|
|
242
|
+
counts while it is live: its turn runs, it owes its parent a report, or a session below it is live.
|
|
243
|
+
Each spawned session's brief says when to split its task and when to do it itself
|
|
261
244
|
([more](docs/reference.md#session-trees-and-their-limits)).
|
|
262
245
|
|
|
246
|
+
## Memory
|
|
247
|
+
|
|
248
|
+
The plugin keeps **one record per session tree** in the project, from facts it already has: when the
|
|
249
|
+
tree started and last moved, the top session's title and `split:` line, and for each session started
|
|
250
|
+
in the tree its title, depth, isolation, join group, status, duration, cost, context use and spawns
|
|
251
|
+
refused; never a report's body or artifacts. The one thing a model writes into it is **the tree's
|
|
252
|
+
lesson**, with `courier_note`: one line of at most 300 characters, from the session that verified the
|
|
253
|
+
tree, with the job it comes from and the reason. `courier_recall` reads the records back; the
|
|
254
|
+
`courier-orchestrate` skill asks for one call before the split decision and one `courier_note` before
|
|
255
|
+
the top replies. Each project keeps its newest 30 records and none older than 90 days. `"memory":
|
|
256
|
+
false` in the plugin's `options` turns it off ([more](docs/reference.md#memory)).
|
|
257
|
+
|
|
263
258
|
## In the terminal
|
|
264
259
|
|
|
265
|
-
The package ships a part for OpenCode's terminal UI,
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
reported:
|
|
260
|
+
The package ships a part for OpenCode's terminal UI, loaded from the server's plugin list; the server
|
|
261
|
+
half behaves the same with or without it. In a session that started sessions, the prompt's footer
|
|
262
|
+
shows what the tree under it is doing: how many wait on you (a permission request, a question or a
|
|
263
|
+
form), are [stalled](docs/reference.md#a-missed-wake), run or are idle, and each
|
|
264
|
+
[join group](docs/reference.md#join-groups)'s members reported:
|
|
271
265
|
|
|
272
266
|
```
|
|
273
267
|
courier 1 waiting on you · 2 running · group pair 1/2
|
|
274
268
|
```
|
|
275
269
|
|
|
276
|
-
`/courier tree` opens a panel beside the session with the tree under it, a row per session
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
270
|
+
`/courier tree` opens a panel beside the session with the tree under it, a row per session with a
|
|
271
|
+
mark for its state (`✓` reported, a spinner while it runs, `●` waiting on you, `!` stalled, `✗`
|
|
272
|
+
failed or interrupted), what it does, its report, its group's standing and, for a leaf, how long it
|
|
273
|
+
ran, its last tool call, its context use and its cost. `enter` or a click opens the session, `s`
|
|
274
|
+
stops it and everything under it, `x` forgets a `gone` session, `q` closes the panel;
|
|
275
|
+
`/courier stop <session>` stops from the prompt. Digby, the courier's mole, digs beside the header,
|
|
276
|
+
his state following the tree's. `"animations": false` in the TUI part's options keeps the panel
|
|
277
|
+
still; the TUI reads a plugin's options only from its own config, `~/.config/opencode/cli.json`
|
|
278
|
+
([more](docs/reference.md#in-the-terminal)):
|
|
279
|
+
|
|
280
|
+
```jsonc
|
|
281
|
+
{
|
|
282
|
+
"plugins": [{ "package": "opencode-courier", "options": { "animations": false } }]
|
|
283
|
+
}
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
`ctrl+x k` (the leader key, then `k`), `/asks` (or `/ask`), the palette's "Courier: what waits on
|
|
287
|
+
you" or a click on "waiting on you" opens the asks inbox: every permission request, question and form
|
|
288
|
+
waiting anywhere in the tree, after the top session's own permission requests, with where each comes
|
|
289
|
+
from, kept current while open and answered in a dialog with no model in between.
|
|
290
|
+
The TUI plays OpenCode's sounds for a child's report, a group's release and a new ask. While a
|
|
291
|
+
permission prompt or form is shown in the session, OpenCode hides the footer, so open the inbox with
|
|
292
|
+
`ctrl+x k`.
|
|
293
|
+
|
|
294
|
+
`/courier memory` (or the palette's "Courier: memory") lists the project's [tree records](#memory),
|
|
295
|
+
newest first, grouped by the day each tree last moved, one row each with its lesson under it.
|
|
296
|
+
`enter` shows one in full; `x` forgets the record under the row, or in a record on its lesson's rows
|
|
297
|
+
only the lesson, after a confirm; `esc` goes back and closes. The search takes every letter but `x`
|
|
298
|
+
([more](docs/reference.md#the-memory-view)).
|
|
299
299
|
|
|
300
300
|
## Native child sessions
|
|
301
301
|
|
|
302
302
|
Off by default. With `"nativeChildren": true` in the plugin's `options`, a child started without
|
|
303
303
|
`isolate` is also created with OpenCode's own `parentID`, so OpenCode treats it as the parent's
|
|
304
|
-
subagent: the TUI's view of the top session
|
|
305
|
-
every session under it, and the
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
- **Deleting a session deletes its native children**, as OpenCode does with subagents.
|
|
311
|
-
- **A child does not take its parent's session rules**: courier passes it an empty ruleset, so it
|
|
312
|
-
may do what it could without the option.
|
|
313
|
-
- **Providers see a subagent**: the child's model requests carry its parent's id for cache routing
|
|
314
|
-
and two headers naming the parent; **on GitHub Copilot**, which meters such requests differently
|
|
315
|
-
from a person's, courier was not tested.
|
|
316
|
-
- **A child's asks show in the top session's view**, not in its own, and it leaves the TUI's session
|
|
317
|
-
lists. Its permission requests and questions are answered once, in OpenCode's own prompt there; the
|
|
318
|
-
top session's model is only told. `opencode run` and the TUI's auto-accept mode answer them as their
|
|
319
|
-
own subagents'.
|
|
320
|
-
- **Turn it on only with OpenCode's TUI or the paired app**, which show those prompts; with a bare
|
|
321
|
-
API client nobody prompts you, and only `courier_answer` is left, which prompts you in the top
|
|
322
|
-
session before it passes anything on.
|
|
304
|
+
subagent: the TUI's view of the top session, and the paired app's request dock, list and answer the
|
|
305
|
+
permission requests, questions and forms of every session under it, and the top session's model is
|
|
306
|
+
only told. Deleting a session then deletes its native children, the child gets an empty ruleset
|
|
307
|
+
rather than its parent's, and providers see a subagent (not tested on GitHub Copilot). Turn it on
|
|
308
|
+
only with OpenCode's TUI or the paired app, which show those prompts
|
|
309
|
+
([more](docs/reference.md#native-child-sessions)).
|
|
323
310
|
|
|
324
311
|
## Webhooks
|
|
325
312
|
|
|
@@ -350,7 +337,7 @@ every default):
|
|
|
350
337
|
arrives as a short summary marked as outside text, queued behind any running turn and waking the
|
|
351
338
|
session if idle.
|
|
352
339
|
|
|
353
|
-
The options, which events wake whom, the
|
|
340
|
+
The options, which events wake whom, the signing scheme and status codes: [the
|
|
354
341
|
reference](docs/reference.md#webhooks).
|
|
355
342
|
|
|
356
343
|
## Development
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { hub, openInProcess, permissions as domains, sharedState } from "./platf
|
|
|
8
8
|
import { builtVersions, versionNotice } from "./platform/version.js";
|
|
9
9
|
import { addSchedulingTools, cancelScheduled, dueJob, isScheduled } from "./scheduling/index.js";
|
|
10
10
|
import { addPersonTools, asksPermission, asksQuestion, formsShown, joinRelay, noticeCutOff, pendingOf, pendingQuestions, personEvents, portsAt, registerPersonRpc, relayQuestions, } from "./person/index.js";
|
|
11
|
+
import { addMemoryTools, memoryEvents, NOTE_TOOL, readMemory, RECALL_TOOL, registerMemoryRpc, sweepRecords } from "./memory/index.js";
|
|
11
12
|
import { addSkill } from "./skill.js";
|
|
12
13
|
import { addTreeTools, deliverReleased, wakeMissed, dirtyOf, headOf, inspectWorktree, pruneExpired, readLimits, readNativeChildren, registerTreeRpc, shapeContext, together, treeEvents, waitsOn, } from "./tree/index.js";
|
|
13
14
|
import { addWebhookTools, joinReceiver, readConfig, receiving, subscribedSince, } from "./webhook/index.js";
|
|
@@ -17,6 +18,8 @@ const gate = sharedState("spawning", () => ({ reserved: new Set(), turn: Promise
|
|
|
17
18
|
const inbox = sharedState("inbox", () => new Map());
|
|
18
19
|
/** The forms shown now, which the person events keep and the footer and tree panel count; outside the hub like the inbox. */
|
|
19
20
|
const shown = sharedState("formsShown", () => new Map());
|
|
21
|
+
/** What the memory shares: the models' windows, the locations with memory off, the tops that recalled and the turn ends recorded. */
|
|
22
|
+
const memoryState = sharedState("memory", () => ({ windows: new Map(), off: new Set(), tops: new Set(), seen: new Set() }));
|
|
20
23
|
/**
|
|
21
24
|
* What each tick of the scheduler does, in order, through the first loaded member, of whichever copy:
|
|
22
25
|
* due `courier_later` messages, released join groups, then missed wakes. Every hub-v2 member's `later` is its LaterPorts.
|
|
@@ -40,7 +43,7 @@ const personState = { ...watched, forms: hub.forms, shown };
|
|
|
40
43
|
*/
|
|
41
44
|
const outside = waitsOn(asksPermission, asksQuestion, together(isScheduled, subscribedSince));
|
|
42
45
|
/** What the hub's subscriptions do with each event of OpenCode's, module by module, in this order. */
|
|
43
|
-
const eventModules = [treeEvents(watchState, outside), personEvents(personState)];
|
|
46
|
+
const eventModules = [treeEvents(watchState, outside), personEvents(personState), memoryEvents(memoryState)];
|
|
44
47
|
/**
|
|
45
48
|
* The courier tools and webhook receiver, as a promise plugin joining the hub that runs the scheduler
|
|
46
49
|
* and the watcher; its member carries the location by which the question relay finds its ports.
|
|
@@ -70,7 +73,8 @@ export const courier = () => Plugin.define({
|
|
|
70
73
|
};
|
|
71
74
|
const { limits, problems } = readLimits(ctx.options);
|
|
72
75
|
const { nativeChildren, problems: nativeProblems } = readNativeChildren(ctx.options);
|
|
73
|
-
|
|
76
|
+
const { memory, problems: memoryProblems } = readMemory(ctx.options);
|
|
77
|
+
for (const problem of [...problems, ...nativeProblems, ...memoryProblems])
|
|
74
78
|
log(`courier: ${problem}`);
|
|
75
79
|
const roles = new Map();
|
|
76
80
|
const ports = {
|
|
@@ -87,6 +91,7 @@ export const courier = () => Plugin.define({
|
|
|
87
91
|
directory: ctx.location.directory,
|
|
88
92
|
projectID: ctx.location.project.id,
|
|
89
93
|
now: Date.now,
|
|
94
|
+
windows: async () => (await ctx.model.list()).data.map((model) => ({ providerID: model.providerID, id: model.id, context: model.limit.context })),
|
|
90
95
|
head: headOf,
|
|
91
96
|
dirty: dirtyOf,
|
|
92
97
|
pending: async (sessionID) => {
|
|
@@ -116,21 +121,32 @@ export const courier = () => Plugin.define({
|
|
|
116
121
|
later.log(`courier webhook: not started: ${error instanceof Error ? error.message : String(error)}`);
|
|
117
122
|
}
|
|
118
123
|
const stopPorts = { courier: ports, cancelScheduled: (sessionID) => cancelScheduled(later, sessionID), cleanup: cleanupPorts };
|
|
119
|
-
|
|
124
|
+
const memoryPorts = { storage: ctx.storage, session: ctx.session, now: Date.now, log, projectID: ctx.location.project.id, memory, maxDepth: limits.maxDepth };
|
|
125
|
+
// The tools in a fixed order: the tree's, then the person's, the scheduled messages', the webhooks' and the memory's.
|
|
120
126
|
await ctx.tool.transform((tools) => {
|
|
121
127
|
addTreeTools(tools, { courier: ports, cleanup: cleanupPorts, stop: stopPorts });
|
|
122
128
|
addPersonTools(tools, { answer: answerPorts, questions: questionPorts, waiting: watched.waiting });
|
|
123
129
|
addSchedulingTools(tools, later);
|
|
124
130
|
addWebhookTools(tools, { hooks, receiving });
|
|
131
|
+
addMemoryTools(tools, { memory: memoryPorts, state: memoryState });
|
|
125
132
|
});
|
|
126
133
|
await ctx.skill.transform(addSkill);
|
|
127
134
|
// What the TUI part reads and its stop; a host that cannot register it still gets the rest, the tools included.
|
|
128
135
|
await Promise.resolve()
|
|
129
136
|
.then(() => registerTreeRpc(ctx.rpc, stopPorts))
|
|
130
137
|
.then(() => registerPersonRpc(ctx.rpc, { questions: questionPorts, permissions }))
|
|
138
|
+
.then(() => registerMemoryRpc(ctx.rpc, memoryPorts))
|
|
131
139
|
.catch((error) => log(`courier rpc: not registered, the TUI part shows nothing and cannot stop: ${String(error)}`));
|
|
132
|
-
await ctx.session.hook("context", (event) => shapeContext({ storage: ctx.storage, limits, roles, log }, event));
|
|
140
|
+
await ctx.session.hook("context", (event) => shapeContext({ storage: ctx.storage, limits, roles, hiddenFromLeaf: [RECALL_TOOL, NOTE_TOOL], log }, event));
|
|
133
141
|
const pruned = pruneExpired(ctx.storage, Date.now()).catch((error) => log(`courier roster prune: ${String(error)}`));
|
|
142
|
+
const swept = sweepRecords(ctx.storage, Date.now()).catch((error) => log(`courier memory sweep: ${String(error)}`));
|
|
143
|
+
// The windows the memory reads each part's context use by; a tree whose top runs in a location with memory off is not recorded.
|
|
144
|
+
const windowed = ports
|
|
145
|
+
.windows()
|
|
146
|
+
.then((windows) => windows.forEach((window) => memoryState.windows.set(`${window.providerID}/${window.id}`, window)))
|
|
147
|
+
.catch((error) => log(`courier memory: cannot list the models' context windows: ${String(error)}`));
|
|
148
|
+
if (!memory)
|
|
149
|
+
memoryState.off.add(ctx.location.directory);
|
|
134
150
|
const leaveRelay = joinRelay(questionPorts);
|
|
135
151
|
const noticed = noticeCutOff(questionPorts).catch((error) => questionPorts.log(`courier question: stored questions: ${String(error)}`));
|
|
136
152
|
const watchPorts = {
|
|
@@ -157,11 +173,15 @@ export const courier = () => Plugin.define({
|
|
|
157
173
|
// The leave waits for them, so they do not run on against a location that is closing.
|
|
158
174
|
track(member, pruned);
|
|
159
175
|
track(member, noticed);
|
|
176
|
+
track(member, swept);
|
|
177
|
+
track(member, windowed);
|
|
160
178
|
const leave = webhook ? joinReceiver(webhook, hooks) : undefined;
|
|
161
179
|
return async () => {
|
|
162
180
|
// Waits, for a moment at most, for the work this instance leaves behind, and on the last
|
|
163
181
|
// leave for the scheduler's owner key to be released, so another server takes over at once.
|
|
164
182
|
const leftHub = leaveHub();
|
|
183
|
+
if (!memory)
|
|
184
|
+
memoryState.off.delete(ctx.location.directory);
|
|
165
185
|
leaveRelay();
|
|
166
186
|
await leave?.();
|
|
167
187
|
await leftHub;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The memory's pure rules, on plain data: the option, a record's shape, each part's status, the split line, the
|
|
3
|
+
* retention and how a refresh keeps the parts the roster has forgotten. Imports nothing of the plugin's but `kernel/` and `platform/json.ts`.
|
|
4
|
+
*/
|
|
5
|
+
import { type Status } from "../kernel/protocol.js";
|
|
6
|
+
/** How many records a project keeps, the newest by `movedAt`. */
|
|
7
|
+
export declare const MAX_RECORDS = 30;
|
|
8
|
+
/** Records that last moved longer ago than this are dropped when the plugin loads. */
|
|
9
|
+
export declare const RETENTION_MS: number;
|
|
10
|
+
/** A record's `split` while its top has stated no split line. */
|
|
11
|
+
export declare const NOT_STATED = "not stated";
|
|
12
|
+
/** The most characters a lesson holds: courier_note refuses what is longer, and a record written by hand is clipped when read. */
|
|
13
|
+
export declare const LESSON_MAX = 300;
|
|
14
|
+
/**
|
|
15
|
+
* How a part stands: its last report's status, how its wait ended without one (`failed`, `interrupted`), `stopped` by
|
|
16
|
+
* courier_stop, `stalled` on a message it will not take, or `out`: it owes a report, running or not.
|
|
17
|
+
*/
|
|
18
|
+
export declare const PART_STATUSES: readonly ["done", "partial", "blocked", "failed", "interrupted", "stopped", "stalled", "out"];
|
|
19
|
+
export type PartStatus = (typeof PART_STATUSES)[number];
|
|
20
|
+
export declare const isPartStatus: (value: unknown) => value is PartStatus;
|
|
21
|
+
/** One session started in a tree, as its record keeps it; `duration` is in milliseconds, `context` a share of its model's window. */
|
|
22
|
+
export interface PartRecord {
|
|
23
|
+
readonly sessionID: string;
|
|
24
|
+
readonly title: string;
|
|
25
|
+
/** 1 for a session the top started. */
|
|
26
|
+
readonly depth: number;
|
|
27
|
+
readonly isolated: boolean;
|
|
28
|
+
readonly group?: string;
|
|
29
|
+
readonly status: PartStatus;
|
|
30
|
+
/** From its start to the end of its last turn, or to the refresh while it runs. */
|
|
31
|
+
readonly duration?: number;
|
|
32
|
+
readonly cost?: number;
|
|
33
|
+
/** How full its context was at the end, 0 to 1, when its model's window is known. */
|
|
34
|
+
readonly context?: number;
|
|
35
|
+
/** How many courier_spawn calls of its own failed, refused by a limit mostly. */
|
|
36
|
+
readonly refused?: number;
|
|
37
|
+
}
|
|
38
|
+
/** Whether and when the top called courier_recall (`at`, its first call), and how many calls the tree made in all. */
|
|
39
|
+
export interface Recalled {
|
|
40
|
+
readonly at?: number;
|
|
41
|
+
readonly calls: number;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The one line the tree's orchestrator wrote for the next one with courier_note: when, and from which depth when it was
|
|
45
|
+
* not the top's own session (1 for a session the top started).
|
|
46
|
+
*/
|
|
47
|
+
export interface Lesson {
|
|
48
|
+
readonly text: string;
|
|
49
|
+
readonly at: number;
|
|
50
|
+
readonly depth?: number;
|
|
51
|
+
}
|
|
52
|
+
/** What is remembered of one tree: its top session and each session started in it, never a report's body or artifacts. */
|
|
53
|
+
export interface TreeRecord {
|
|
54
|
+
/** When the top session was created, epoch milliseconds. */
|
|
55
|
+
readonly startedAt: number;
|
|
56
|
+
/** When a turn in the tree last ended, or the record was first written. */
|
|
57
|
+
readonly movedAt: number;
|
|
58
|
+
readonly title: string;
|
|
59
|
+
/** The top's split line, as `e2e/real-model-recursive.mjs` reads it, or `not stated`. */
|
|
60
|
+
readonly split: string;
|
|
61
|
+
/** Whether, at the last refresh, no session in the tree ran and every part had settled: none was out or stalled. */
|
|
62
|
+
readonly finished: boolean;
|
|
63
|
+
readonly recalled?: Recalled;
|
|
64
|
+
/** Absent until a session of the tree that is not a leaf wrote one; a second note replaces the first. */
|
|
65
|
+
readonly lesson?: Lesson;
|
|
66
|
+
readonly parts: ReadonlyArray<PartRecord>;
|
|
67
|
+
}
|
|
68
|
+
/** A record with where it is kept: its project and its top session. */
|
|
69
|
+
export interface StoredRecord extends TreeRecord {
|
|
70
|
+
readonly projectID: string;
|
|
71
|
+
readonly sessionID: string;
|
|
72
|
+
}
|
|
73
|
+
/** The `memory` option: whether records are kept and listed; on unless `false`. */
|
|
74
|
+
export declare function readMemory(options: unknown): {
|
|
75
|
+
memory: boolean;
|
|
76
|
+
problems: string[];
|
|
77
|
+
};
|
|
78
|
+
/** The split line a text states, as the real-model checker reads it: a line beginning with `split:` after any markdown, else one holding it. */
|
|
79
|
+
export declare function splitLineOf(text: string): string | undefined;
|
|
80
|
+
/** What a part's status is read from: whether courier_stop stopped it, whether it is stalled, and its report state. */
|
|
81
|
+
export interface Standing {
|
|
82
|
+
readonly stopped: boolean;
|
|
83
|
+
readonly stalled: boolean;
|
|
84
|
+
readonly report?: {
|
|
85
|
+
readonly known: boolean;
|
|
86
|
+
readonly owes: boolean;
|
|
87
|
+
readonly ended?: "failed" | "interrupted";
|
|
88
|
+
readonly status?: Status;
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/** A part's status by `Standing`; one whose report state is not known is still out. */
|
|
92
|
+
export declare function partStatus({ stopped, stalled, report }: Standing): PartStatus;
|
|
93
|
+
/**
|
|
94
|
+
* The parts of a refreshed record: those recorded before, in their order, each as read now when the roster still has it,
|
|
95
|
+
* else as last recorded, then those first seen now, in the order the tree is drawn.
|
|
96
|
+
*/
|
|
97
|
+
export declare function mergeParts(previous: ReadonlyArray<PartRecord>, current: ReadonlyArray<PartRecord>): PartRecord[];
|
|
98
|
+
/** `recalled` after one more call at `at`, the top's first call noted when `byTop`. */
|
|
99
|
+
export declare function noteRecall(recalled: Recalled | undefined, byTop: boolean, at: number): Recalled;
|
|
100
|
+
/** A text as the one line a lesson is: whitespace and line breaks collapsed to spaces, trimmed. */
|
|
101
|
+
export declare const oneLine: (text: string) => string;
|
|
102
|
+
/**
|
|
103
|
+
* A hand-written lesson's text as one line, clipped at `LESSON_MAX`; `clipped` is the length it had when it was clipped.
|
|
104
|
+
* courier_note does not clip: it refuses a line over `LESSON_MAX` (`note.ts`).
|
|
105
|
+
*/
|
|
106
|
+
export declare function lessonText(text: string): {
|
|
107
|
+
text: string;
|
|
108
|
+
clipped?: undefined;
|
|
109
|
+
} | {
|
|
110
|
+
text: string;
|
|
111
|
+
clipped: number;
|
|
112
|
+
};
|
|
113
|
+
/** The records a project keeps at `now`: the newest `MAX_RECORDS` by `movedAt`, none older than `RETENTION_MS`; the rest are dropped. */
|
|
114
|
+
export declare function keep<T extends Pick<StoredRecord, "movedAt">>(records: ReadonlyArray<T>, now: number): {
|
|
115
|
+
kept: T[];
|
|
116
|
+
dropped: T[];
|
|
117
|
+
};
|
|
118
|
+
/** Records newest first, by when each last moved. */
|
|
119
|
+
export declare const newestFirst: <T extends Pick<StoredRecord, "movedAt">>(records: ReadonlyArray<T>) => T[];
|