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.
Files changed (58) hide show
  1. package/README.md +122 -135
  2. package/dist/index.js +24 -4
  3. package/dist/memory/domain.d.ts +119 -0
  4. package/dist/memory/domain.js +84 -0
  5. package/dist/memory/events.d.ts +26 -0
  6. package/dist/memory/events.js +46 -0
  7. package/dist/memory/forget.d.ts +10 -0
  8. package/dist/memory/forget.js +20 -0
  9. package/dist/memory/index.d.ts +10 -0
  10. package/dist/memory/index.js +11 -0
  11. package/dist/memory/note.d.ts +37 -0
  12. package/dist/memory/note.js +29 -0
  13. package/dist/memory/ports.d.ts +44 -0
  14. package/dist/memory/ports.js +1 -0
  15. package/dist/memory/recall.d.ts +27 -0
  16. package/dist/memory/recall.js +48 -0
  17. package/dist/memory/record.d.ts +7 -0
  18. package/dist/memory/record.js +92 -0
  19. package/dist/memory/rpc-handlers.d.ts +134 -0
  20. package/dist/memory/rpc-handlers.js +23 -0
  21. package/dist/memory/rpc.d.ts +227 -0
  22. package/dist/memory/rpc.js +60 -0
  23. package/dist/memory/store.d.ts +10 -0
  24. package/dist/memory/store.js +90 -0
  25. package/dist/memory/sweep.d.ts +4 -0
  26. package/dist/memory/sweep.js +12 -0
  27. package/dist/memory/text.d.ts +27 -0
  28. package/dist/memory/text.js +95 -0
  29. package/dist/memory/tools.d.ts +16 -0
  30. package/dist/memory/tools.js +78 -0
  31. package/dist/memory/tui.d.ts +73 -0
  32. package/dist/person/rpc.d.ts +4 -4
  33. package/dist/platform/json.d.ts +1 -0
  34. package/dist/platform/json.js +1 -0
  35. package/dist/platform/version.js +2 -2
  36. package/dist/rpc.d.ts +1 -0
  37. package/dist/rpc.js +1 -0
  38. package/dist/skill.js +10 -3
  39. package/dist/tree/context.d.ts +2 -2
  40. package/dist/tree/context.js +4 -3
  41. package/dist/tree/index.d.ts +6 -5
  42. package/dist/tree/index.js +5 -4
  43. package/dist/tree/mascot.d.ts +50 -0
  44. package/dist/tree/mascot.js +171 -0
  45. package/dist/tree/panel.d.ts +3 -1
  46. package/dist/tree/panel.js +17 -3
  47. package/dist/tree/ports.d.ts +12 -2
  48. package/dist/tree/roster.d.ts +17 -0
  49. package/dist/tree/roster.js +24 -0
  50. package/dist/tree/rpc-handlers.d.ts +21 -18
  51. package/dist/tree/rpc.d.ts +26 -18
  52. package/dist/tree/rpc.js +2 -0
  53. package/dist/tree/status.d.ts +5 -5
  54. package/dist/tree/subtree.d.ts +2 -2
  55. package/dist/tree/text.js +2 -1
  56. package/dist/tree/tui.d.ts +115 -8
  57. package/dist/tui.js +1269 -222
  58. 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.31 # OpenCode V2, at the version courier is tested on
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`), and needs OpenCode V2. Each release is pinned to one V2 version and tested
46
- against it end to end; the current one is tested on **OpenCode 2.0.31**. CI also runs the live
47
- suite on the newest OpenCode release, on `main` and nightly, so a host release that breaks the
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 has its own [`subagent` tool](https://opencode.ai/v2/docs/agents): it starts an agent
53
- with fresh context in a child session, and the parent either waits for its final answer or, with
54
- `background: true`, carries on and is notified when the child finishes; passing the child's
55
- `sessionID` back prompts it again. Use it when one result back is all you need: a search, a review,
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, or asks its parent something,
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) (GitHub reviews, comments and CI runs, or any signed POST) starts a new turn
62
- in a session that has ended its own.
63
- - **Worktree isolation:** `isolate: true` gives a child its own git worktree, so children can edit
64
- files in parallel; each commits on `courier/<its session ID>` and reports that branch for its
65
- parent to merge ([nested isolation](docs/reference.md#nested-isolation)).
66
- - **Relaying to you:** a child's permission requests and questions reach you through the parent:
67
- a request is approved in OpenCode's own permission prompt in the parent's session, never by its
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 session calls `courier_spawn`, gets a session id back immediately and ends its turn. The
73
- child works on its own and, when it is done or stuck, calls `courier_send` with the parent's id, a
74
- status and what it produced. That message lands in the parent's inbox and OpenCode starts a new turn
75
- for the parent if it is idle. There is no polling anywhere: a message is admitted into the target
76
- session's inbox through OpenCode's own API, the way OpenCode's background subagents report to their
77
- parent. Delivery is `steer` by default (injected into the target's running turn, or starts one if
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 scripted stand-in model of
83
- the live test, with `e2e/tui.sh`'s setup, so each scene is real plugin behaviour:
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 plugin
99
- ships the playbook as an OpenCode skill, `courier-orchestrate`, listed in every session: models are
100
- told to load it at the start of an implementation task that may have independent parts, and
101
- `/skills` in the TUI attaches it to your prompt. What it says: [the
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
- the plugin installs and loads on any V2 host, and on a version other than the pinned one writes one
112
- line to the server log naming both ([more](docs/reference.md#the-opencode-version)).
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.31 only; the server part works as before on older V2 hosts from 2.0.4 on.
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.31
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, so
147
- `opencode-courier@0.2.1` is never offered a newer release ([moving such an
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, such
163
- as `opencode serve` next to it, cannot pass your answer on to a child the other runs
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 `question` tool if they
167
- should be able to ask you. Choose a web search provider before children search the web (run one
168
- web search in your own session, or use OpenCode's "Third-party search" setting), or the first
169
- child to search shows that prompt in its own session, which only you can answer there.
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 (branch, commits, files, checks) listed
205
- in the message. A message without a status is progress, not the report.
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 and your turn has ended,
209
- then delivered in one message. A `blocked` report, a failure, a question or a permission request
210
- still reaches you at once. A member that will not report holds the others back: drop it with
211
- `courier_cleanup`, and the group is released without it. [More](docs/reference.md#join-groups).
212
- - **A child that fails, or ends its turn without reporting, is reported** to its parent by the
213
- plugin, with the error or its last reply, waking it if idle; one that waits on a request, its own
214
- children, a scheduled message or a webhook is not, nor is one that answers what you typed in its
215
- session. [More](docs/reference.md#a-child-that-fails).
216
- - **A message that fails to wake its session is sent again.** A report, notice or scheduled message
217
- (but not a note that a request was answered elsewhere) that waits in an idle session's inbox for 30 seconds without a turn starting for it is sent once
218
- more under the same inbox id, which OpenCode takes for the same message, not a second one. If the
219
- session still does not take it, the footer and the tree panel show it as `stalled`, for you to
220
- prompt it. [More](docs/reference.md#a-missed-wake).
221
- - **Permission requests and questions reach you.** A child's request or question is passed to the
222
- session at the top of its tree, any number of levels down. For a request, that session proposes an
223
- answer with `courier_answer`, and OpenCode asks you in its own permission prompt, in that session
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: do the work
257
- itself, or wait for a child to report. A session counts while it is live: its turn runs, it owes
258
- its parent a report (its task or a message reached it after it last reported, failed or was
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, which the TUI loads from the server's plugin
266
- list; the server half behaves the same whether or not a TUI is attached. In a session that started
267
- sessions, the prompt's footer shows what the tree under it is doing: how many wait on you (a
268
- permission request, a question or a form they show), are [stalled](docs/reference.md#a-missed-wake),
269
- run or are idle, and for each [join group](docs/reference.md#join-groups) how many of its members have
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 indented
277
- by depth, with what it does, its report and its group's standing; a session waiting on you gets a line per
278
- request or form (`shows a form: Web Search`), a stalled one since when its message waits, a session that started others gets
279
- a bar of the parts reported, a leaf how long it ran, its last tool call and its cost. A tree that has
280
- started more than `maxTotal` sessions over time is listed live ones first, then newest, and the header
281
- says how many older ones are left out; the footer counts them all. `↑`/`↓` select,
282
- `enter` opens the session, `s` stops it and everything under it after you confirm, as `courier_stop`
283
- would, `x` forgets a `gone` session after you confirm, removing its worktree by `courier_cleanup`'s
284
- rules, and `q` closes the panel; `/courier stop <session>` stops from the prompt. While open, the panel
285
- follows you between sessions: into one of its tree it keeps that tree, the session on screen marked
286
- `(on screen)`; elsewhere it shows that session's tree, or hides while you are in a session without one,
287
- or on the home screen, and shows again once you are back in one; only `q` closes it for good.
288
-
289
- `ctrl+x k` (the leader key, then `k`), `/asks` (or `/ask`), the palette's "Courier: what waits on you" or a click
290
- on "waiting on you" opens the asks inbox: every permission request, question and form waiting
291
- anywhere in the tree, however many sessions it has, oldest first, after the top session's own permission
292
- requests, such as the prompt `courier_answer` has OpenCode show there, with where it comes from, kept current
293
- while it is open as asks arrive or are settled elsewhere, answered in a dialog with no model in between; the session that started the tree hears once that you settled it. The TUI plays OpenCode's
294
- sounds for a child's report and a group's release, and OpenCode's own for a new ask, which the courier
295
- adds a notification to for a native child ([more](docs/reference.md#in-the-terminal)).
296
-
297
- While a permission prompt or form is shown in the session, OpenCode hides the footer, so open the
298
- inbox with `ctrl+x k` (or `/asks` once the prompt is back).
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 lists and answers the permission requests and forms of
305
- every session under it, and the paired web, desktop or mobile app's request dock answers a
306
- grandchild's ask from the top session, on a phone too. Isolated children are created as before, and
307
- the courier's own tree, limits and relays work as without the option. What changes
308
- ([more](docs/reference.md#native-child-sessions)):
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 tunnels, the signing scheme and status codes: [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
- for (const problem of [...problems, ...nativeProblems])
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
- // The tools in a fixed order: the tree's, then the person's, the scheduled messages' and the webhooks'.
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[];