opencode-courier 0.4.1 → 0.6.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 +38 -25
- package/dist/index.js +7 -4
- package/dist/person/form.d.ts +4 -1
- package/dist/person/form.js +5 -0
- package/dist/person/index.d.ts +2 -1
- package/dist/person/index.js +1 -0
- package/dist/person/permission.d.ts +37 -2
- package/dist/person/permission.js +91 -17
- package/dist/person/ports.d.ts +8 -2
- package/dist/person/rpc-handlers.d.ts +37 -30
- package/dist/person/rpc-handlers.js +26 -16
- package/dist/person/rpc.d.ts +43 -24
- package/dist/person/rpc.js +4 -3
- package/dist/person/text.d.ts +5 -0
- package/dist/person/text.js +21 -19
- package/dist/person/tools.d.ts +1 -1
- package/dist/person/tools.js +23 -12
- package/dist/person/tui.d.ts +23 -12
- package/dist/tree/domain.d.ts +23 -0
- package/dist/tree/domain.js +26 -0
- package/dist/tree/events.d.ts +2 -2
- package/dist/tree/events.js +5 -3
- package/dist/tree/index.d.ts +1 -1
- package/dist/tree/panel.d.ts +7 -2
- package/dist/tree/panel.js +19 -16
- package/dist/tree/ports.d.ts +7 -0
- package/dist/tree/roster.d.ts +5 -0
- package/dist/tree/roster.js +5 -3
- package/dist/tree/rpc-handlers.d.ts +45 -5
- package/dist/tree/rpc-handlers.js +30 -1
- package/dist/tree/rpc.d.ts +79 -19
- package/dist/tree/rpc.js +20 -8
- package/dist/tree/subtree.d.ts +40 -9
- package/dist/tree/subtree.js +40 -19
- package/dist/tree/summary.d.ts +2 -2
- package/dist/tree/summary.js +8 -16
- package/dist/tree/text.js +4 -1
- package/dist/tree/tools.js +2 -1
- package/dist/tree/tui.d.ts +12 -5
- package/dist/tui.js +585 -176
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ prompt](#demos)).
|
|
|
22
22
|
## Quickstart
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
|
-
npm install -g @opencode/cli@2.0.
|
|
25
|
+
npm install -g @opencode/cli@2.0.29 # OpenCode V2, at the version courier is tested on
|
|
26
26
|
opencode plugin add opencode-courier # by name, so OpenCode offers new releases
|
|
27
27
|
opencode # in any folder
|
|
28
28
|
```
|
|
@@ -44,9 +44,9 @@ sometimes stall on a request; if nothing moves for a few minutes, send the promp
|
|
|
44
44
|
|
|
45
45
|
Courier is built on OpenCode V2's plugin API (`@opencode/plugin`), not the V1 one
|
|
46
46
|
(`@opencode-ai/plugin`), and needs OpenCode V2. Each release is pinned to one V2 version and tested
|
|
47
|
-
against it end to end; the current one is tested on **OpenCode 2.0.
|
|
48
|
-
suite on the newest OpenCode release, so a host release that breaks the
|
|
49
|
-
first. Older releases and their versions: [Supported OpenCode version](#supported-opencode-version).
|
|
47
|
+
against it end to end; the current one is tested on **OpenCode 2.0.29**. CI also runs the live
|
|
48
|
+
suite on the newest OpenCode release, on `main` and nightly, so a host release that breaks the
|
|
49
|
+
plugin shows up there first. Older releases and their versions: [Supported OpenCode version](#supported-opencode-version).
|
|
50
50
|
|
|
51
51
|
## Courier or the built-in subagent tool?
|
|
52
52
|
|
|
@@ -64,8 +64,9 @@ a self-contained task that needs nothing from you on the way. Use courier when t
|
|
|
64
64
|
- **Worktree isolation:** `isolate: true` gives a child its own git worktree, so children can edit
|
|
65
65
|
files in parallel; each commits on `courier/<its session ID>` and reports that branch for its
|
|
66
66
|
parent to merge ([nested isolation](docs/reference.md#nested-isolation)).
|
|
67
|
-
- **Relaying to you:** a child's permission requests and questions reach you through the parent
|
|
68
|
-
|
|
67
|
+
- **Relaying to you:** a child's permission requests and questions reach you through the parent:
|
|
68
|
+
a request is approved in OpenCode's own permission prompt in the parent's session, never by its
|
|
69
|
+
model, and your answers go back; a form only you can answer is pointed out to you.
|
|
69
70
|
|
|
70
71
|
## How it works
|
|
71
72
|
|
|
@@ -116,6 +117,7 @@ line to the server log naming both ([more](docs/reference.md#the-opencode-versio
|
|
|
116
117
|
|
|
117
118
|
| opencode-courier | OpenCode V2 (`opencode` and `@opencode/plugin`) |
|
|
118
119
|
|---|---|
|
|
120
|
+
| 0.5.0, 0.6.0 | 2.0.29 |
|
|
119
121
|
| 0.4.0, 0.4.1 | 2.0.28 |
|
|
120
122
|
| 0.2.3, 0.3.0 | 2.0.26 |
|
|
121
123
|
| 0.2.2 | 2.0.24 |
|
|
@@ -124,12 +126,12 @@ line to the server log naming both ([more](docs/reference.md#the-opencode-versio
|
|
|
124
126
|
| 0.1.6 | 0.0.0-beta-19271 (the beta line: `opencode2` from `@opencode-ai/cli`, and `@opencode-ai/plugin`) |
|
|
125
127
|
|
|
126
128
|
The [terminal part](#in-the-terminal) relies on the host to provide its UI modules and is tested on
|
|
127
|
-
OpenCode 2.0.
|
|
129
|
+
OpenCode 2.0.29 only; the server part works as before on older V2 hosts from 2.0.4 on.
|
|
128
130
|
|
|
129
131
|
Check yours with `opencode --version`, and install the matching CLI with:
|
|
130
132
|
|
|
131
133
|
```bash
|
|
132
|
-
npm install -g @opencode/cli@2.0.
|
|
134
|
+
npm install -g @opencode/cli@2.0.29
|
|
133
135
|
```
|
|
134
136
|
|
|
135
137
|
### The plugin
|
|
@@ -161,7 +163,7 @@ From a local clone, `bun install && npm run build`, then list its `dist` **direc
|
|
|
161
163
|
as `opencode serve` next to it, cannot pass your answer on to a child the other runs
|
|
162
164
|
([why](docs/reference.md#two-servers-on-one-data-directory)).
|
|
163
165
|
2. Give the agents that run children the permissions their work needs: a child that hits an approval
|
|
164
|
-
prompt waits until you answer it
|
|
166
|
+
prompt waits until you answer OpenCode's prompt for it in the parent's session. Let them use the `question` tool if they
|
|
165
167
|
should be able to ask you. Choose a web search provider before children search the web (run one
|
|
166
168
|
web search in your own session, or use OpenCode's "Third-party search" setting), or the first
|
|
167
169
|
child to search shows that prompt in its own session, which only you can answer there.
|
|
@@ -188,7 +190,7 @@ From a local clone, `bun install && npm run build`, then list its `dist` **direc
|
|
|
188
190
|
| `courier_tree` | Lists the whole tree under a session, depth first, with each one's depth and report state. |
|
|
189
191
|
| `courier_stop` | Interrupts a session below you and everything under it, and cancels their scheduled messages; `cleanup: true` removes their worktrees too. |
|
|
190
192
|
| `courier_cleanup` | Removes an isolated child's worktree, unless it holds unsaved work, and forgets the child. |
|
|
191
|
-
| `courier_answer` |
|
|
193
|
+
| `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. |
|
|
192
194
|
| `courier_later` | Schedules a message for a session (this one by default) in `delayMinutes` or `at` a time. |
|
|
193
195
|
| `courier_cancel` | Drops a message scheduled with `courier_later`. |
|
|
194
196
|
| `courier_subscribe` | Subscribes a session to webhook deliveries for a topic (`owner/repo`, `owner/repo#12` or a name). |
|
|
@@ -212,9 +214,11 @@ Each tool's parameters: [the reference](docs/reference.md#the-tools).
|
|
|
212
214
|
children, a scheduled message or a webhook is not, nor is one that answers what you typed in its
|
|
213
215
|
session. [More](docs/reference.md#a-child-that-fails).
|
|
214
216
|
- **Permission requests and questions reach you.** A child's request or question is passed to the
|
|
215
|
-
session at the top of its tree, any number of levels down
|
|
216
|
-
answer
|
|
217
|
-
|
|
217
|
+
session at the top of its tree, any number of levels down. For a request, that session proposes an
|
|
218
|
+
answer with `courier_answer`, and OpenCode asks you in its own permission prompt, in that session
|
|
219
|
+
and under its rules, before anything is passed on: its model cannot approve, only propose. For a
|
|
220
|
+
question, it asks you and passes your answer back, or links its own question to the child's so your
|
|
221
|
+
pick goes straight to it. Whichever answer comes first counts, and the other is withdrawn.
|
|
218
222
|
[More](docs/reference.md#a-child-that-asks-for-permission).
|
|
219
223
|
- **Some forms only you can answer**, such as OpenCode's web search asking for a provider: the top
|
|
220
224
|
session is told, and you answer in the child's session. [More](docs/reference.md#a-child-that-shows-a-form).
|
|
@@ -222,7 +226,8 @@ Each tool's parameters: [the reference](docs/reference.md#the-tools).
|
|
|
222
226
|
[terminal](#in-the-terminal). [More](docs/reference.md#seeing-and-stopping-a-subtree).
|
|
223
227
|
|
|
224
228
|
Children, held reports, scheduled messages and open questions survive a compaction or a restart;
|
|
225
|
-
entries are dropped after 14 days
|
|
229
|
+
entries are dropped after 14 days, or at once when OpenCode deletes the child, unless it is isolated
|
|
230
|
+
([roster](docs/reference.md#roster)).
|
|
226
231
|
|
|
227
232
|
## Session trees
|
|
228
233
|
|
|
@@ -252,27 +257,34 @@ does not. Each spawned session's brief says when to split its task and when to d
|
|
|
252
257
|
|
|
253
258
|
The package ships a part for OpenCode's terminal UI, which the TUI loads from the server's plugin
|
|
254
259
|
list; the server half behaves the same whether or not a TUI is attached. In a session that started
|
|
255
|
-
sessions, the prompt's footer shows what the tree under it is doing: how many run, wait on you
|
|
256
|
-
are idle, and for each [join group](docs/reference.md#join-groups) how many of its members have
|
|
260
|
+
sessions, the prompt's footer shows what the tree under it is doing: how many run, wait on you (a
|
|
261
|
+
permission request, a question or a form they show) or are idle, and for each [join group](docs/reference.md#join-groups) how many of its members have
|
|
257
262
|
reported:
|
|
258
263
|
|
|
259
264
|
```
|
|
260
|
-
courier
|
|
265
|
+
courier 1 waiting on you · 2 running · group pair 1/2
|
|
261
266
|
```
|
|
262
267
|
|
|
263
268
|
`/courier tree` opens a panel beside the session with the tree under it, a row per session indented
|
|
264
|
-
by depth, with what it does, its report and its group's standing; a session
|
|
265
|
-
|
|
269
|
+
by depth, with what it does, its report and its group's standing; a session waiting on you gets a line per
|
|
270
|
+
request or form (`shows a form: Web Search`), a session that started others gets
|
|
271
|
+
a bar of the parts reported, a leaf how long it ran, its last tool call and its cost. A tree that has
|
|
272
|
+
started more than `maxTotal` sessions over time is listed live ones first, then newest, and the header
|
|
273
|
+
says how many older ones are left out; the footer counts them all. `↑`/`↓` select,
|
|
266
274
|
`enter` opens the session, `s` stops it and everything under it after you confirm, as `courier_stop`
|
|
267
|
-
would,
|
|
275
|
+
would, `x` forgets a `gone` session after you confirm, removing its worktree by `courier_cleanup`'s
|
|
276
|
+
rules, and `q` closes the panel; `/courier stop <session>` stops from the prompt. While open, the panel
|
|
268
277
|
follows you between sessions: into one of its tree it keeps that tree, the session on screen marked
|
|
269
|
-
`(on screen)`; elsewhere it shows that session's tree, or
|
|
278
|
+
`(on screen)`; elsewhere it shows that session's tree, or hides while you are in a session without one,
|
|
279
|
+
or on the home screen, and shows again once you are back in one; only `q` closes it for good.
|
|
270
280
|
|
|
271
281
|
`ctrl+x k` (the leader key, then `k`), `/asks` (or `/ask`), the palette's "Courier: what waits on you" or a click
|
|
272
282
|
on "waiting on you" opens the asks inbox: every permission request, question and form waiting
|
|
273
|
-
anywhere in the tree,
|
|
274
|
-
|
|
275
|
-
|
|
283
|
+
anywhere in the tree, however many sessions it has, oldest first, after the top session's own permission
|
|
284
|
+
requests, such as the prompt `courier_answer` has OpenCode show there, with where it comes from, kept current
|
|
285
|
+
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
|
|
286
|
+
sounds for a child's report and a group's release, and OpenCode's own for a new ask, which the courier
|
|
287
|
+
adds a notification to for a native child ([more](docs/reference.md#in-the-terminal)).
|
|
276
288
|
|
|
277
289
|
While a permission prompt or form is shown in the session, OpenCode hides the footer, so open the
|
|
278
290
|
inbox with `ctrl+x k` (or `/asks` once the prompt is back).
|
|
@@ -298,7 +310,8 @@ the courier's own tree, limits and relays work as without the option. What chang
|
|
|
298
310
|
top session's model is only told. `opencode run` and the TUI's auto-accept mode answer them as their
|
|
299
311
|
own subagents'.
|
|
300
312
|
- **Turn it on only with OpenCode's TUI or the paired app**, which show those prompts; with a bare
|
|
301
|
-
API client nobody prompts you, and only `courier_answer` is left
|
|
313
|
+
API client nobody prompts you, and only `courier_answer` is left, which prompts you in the top
|
|
314
|
+
session before it passes anything on.
|
|
302
315
|
|
|
303
316
|
## Webhooks
|
|
304
317
|
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import { watchFromHub } from "./platform/events.js";
|
|
|
7
7
|
import { hub, openInProcess, permissions as domains, sharedState } from "./platform/hub.js";
|
|
8
8
|
import { builtVersions, versionNotice } from "./platform/version.js";
|
|
9
9
|
import { addSchedulingTools, cancelScheduled, dueJob, isScheduled } from "./scheduling/index.js";
|
|
10
|
-
import { addPersonTools, asksPermission, asksQuestion, joinRelay, noticeCutOff, pendingOf, pendingQuestions, personEvents, portsAt, registerPersonRpc, relayQuestions, } from "./person/index.js";
|
|
10
|
+
import { addPersonTools, asksPermission, asksQuestion, formsShown, joinRelay, noticeCutOff, pendingOf, pendingQuestions, personEvents, portsAt, registerPersonRpc, relayQuestions, } from "./person/index.js";
|
|
11
11
|
import { addSkill } from "./skill.js";
|
|
12
12
|
import { addTreeTools, deliverReleased, dirtyOf, headOf, inspectWorktree, pruneExpired, readLimits, readNativeChildren, registerTreeRpc, shapeContext, together, treeEvents, waitsOn, } from "./tree/index.js";
|
|
13
13
|
import { addWebhookTools, joinReceiver, readConfig, receiving, subscribedSince, } from "./webhook/index.js";
|
|
@@ -15,6 +15,8 @@ import { addWebhookTools, joinReceiver, readConfig, receiving, subscribedSince,
|
|
|
15
15
|
const gate = sharedState("spawning", () => ({ reserved: new Set(), turn: Promise.resolve() }));
|
|
16
16
|
/** The type of each item enqueued for a spawned session, outside the hub like the gate. */
|
|
17
17
|
const inbox = sharedState("inbox", () => new Map());
|
|
18
|
+
/** The forms shown now, which the person events keep and the footer and tree panel count; outside the hub like the inbox. */
|
|
19
|
+
const shown = sharedState("formsShown", () => new Map());
|
|
18
20
|
/**
|
|
19
21
|
* What each tick of the scheduler does, in order, through the first loaded member, of whichever copy:
|
|
20
22
|
* due `courier_later` messages, then released join groups. Every hub-v2 member's `later` is its LaterPorts.
|
|
@@ -30,7 +32,7 @@ const permissions = domains;
|
|
|
30
32
|
// several subscriptions and copies see the same events. The scheduler's claim set is the hub's own.
|
|
31
33
|
const { watched } = hub;
|
|
32
34
|
const watchState = { seen: watched.seen, inbox };
|
|
33
|
-
const personState = { ...watched, forms: hub.forms };
|
|
35
|
+
const personState = { ...watched, forms: hub.forms, shown };
|
|
34
36
|
/**
|
|
35
37
|
* What a session waits on outside the tree, for the silent-turn rule, cheapest first: a request of its
|
|
36
38
|
* own to the person, then a scheduled message or a webhook it subscribed to since its last prompt.
|
|
@@ -93,9 +95,10 @@ export const courier = () => Plugin.define({
|
|
|
93
95
|
]);
|
|
94
96
|
return [...requests, ...questions];
|
|
95
97
|
},
|
|
98
|
+
forms: (sessionID) => Promise.resolve(formsShown(shown, sessionID)),
|
|
96
99
|
};
|
|
97
100
|
const cleanupPorts = { ...ports, inspect: inspectWorktree };
|
|
98
|
-
const answerPorts = { storage: ctx.storage, permissions };
|
|
101
|
+
const answerPorts = { storage: ctx.storage, permissions, assert: (input) => ctx.permission.assert(input) };
|
|
99
102
|
const later = {
|
|
100
103
|
storage: ctx.storage,
|
|
101
104
|
session: ctx.session,
|
|
@@ -123,7 +126,7 @@ export const courier = () => Plugin.define({
|
|
|
123
126
|
// What the TUI part reads and its stop; a host that cannot register it still gets the rest, the tools included.
|
|
124
127
|
await Promise.resolve()
|
|
125
128
|
.then(() => registerTreeRpc(ctx.rpc, stopPorts))
|
|
126
|
-
.then(() => registerPersonRpc(ctx.rpc, { questions: questionPorts, permissions
|
|
129
|
+
.then(() => registerPersonRpc(ctx.rpc, { questions: questionPorts, permissions }))
|
|
127
130
|
.catch((error) => log(`courier rpc: not registered, the TUI part shows nothing and cannot stop: ${String(error)}`));
|
|
128
131
|
await ctx.session.hook("context", (event) => shapeContext({ storage: ctx.storage, limits, roles, log }, event));
|
|
129
132
|
const pruned = pruneExpired(ctx.storage, Date.now()).catch((error) => log(`courier roster prune: ${String(error)}`));
|
package/dist/person/form.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type ShownForm } from "../tree/index.js";
|
|
2
|
+
import { type Form, type FormsShown, type PersonPorts, type PersonState } from "./ports.js";
|
|
2
3
|
/** The `metadata.kind` of the forms OpenCode's question tool asks with; the question relay passes those on. */
|
|
3
4
|
export declare const QUESTION_FORM = "question";
|
|
4
5
|
/** The part of OpenCode's `form.created` event the notice is made from. */
|
|
@@ -24,3 +25,5 @@ export interface FormSettled {
|
|
|
24
25
|
export declare function reportForm(ports: PersonPorts, state: PersonState, event: FormCreated): Promise<string[]>;
|
|
25
26
|
/** Tells the session told about a form that it was answered or withdrawn, so it does not send the person to a form that is gone. */
|
|
26
27
|
export declare function reportFormSettled(ports: PersonPorts, state: PersonState, event: FormSettled): Promise<string[]>;
|
|
28
|
+
/** The forms a session shows now, oldest first, as the tree's `forms` port lists them. */
|
|
29
|
+
export declare const formsShown: (shown: FormsShown, sessionID: string) => ShownForm[];
|
package/dist/person/form.js
CHANGED
|
@@ -15,6 +15,8 @@ export async function reportForm(ports, state, event) {
|
|
|
15
15
|
const form = event.data.form;
|
|
16
16
|
if (kindOf(form) === QUESTION_FORM)
|
|
17
17
|
return [];
|
|
18
|
+
if (!state.forms.settled.has(form.id))
|
|
19
|
+
setBounded(state.shown, form.id, { sessionID: form.sessionID, title: form.title }, SEEN_MAX);
|
|
18
20
|
const chain = await lineage(ports.storage, form.sessionID);
|
|
19
21
|
// As with a permission request, claimed after the lookup, so a form settled meanwhile is not told.
|
|
20
22
|
if (!chain.length || state.forms.settled.has(form.id) || state.forms.told.has(form.id))
|
|
@@ -41,6 +43,7 @@ export async function reportFormSettled(ports, state, event) {
|
|
|
41
43
|
if (!claim(state.seen, event.id))
|
|
42
44
|
return [];
|
|
43
45
|
const { id, sessionID } = event.data;
|
|
46
|
+
state.shown.delete(id);
|
|
44
47
|
const telling = state.forms.told.get(id);
|
|
45
48
|
if (!telling) {
|
|
46
49
|
claim(state.forms.settled, id);
|
|
@@ -62,3 +65,5 @@ export async function reportFormSettled(ports, state, event) {
|
|
|
62
65
|
});
|
|
63
66
|
return [topOf(chain)];
|
|
64
67
|
}
|
|
68
|
+
/** The forms a session shows now, oldest first, as the tree's `forms` port lists them. */
|
|
69
|
+
export const formsShown = (shown, sessionID) => [...shown].flatMap(([id, form]) => (form.sessionID === sessionID ? [{ id, title: form.title }] : []));
|
package/dist/person/index.d.ts
CHANGED
|
@@ -2,9 +2,10 @@ import type { PendingPrompt, WatchPorts } from "../tree/index.js";
|
|
|
2
2
|
import type { PersonPorts } from "./ports.js";
|
|
3
3
|
import type { Prompt } from "./question/index.js";
|
|
4
4
|
export { personEvents } from "./events.js";
|
|
5
|
+
export { formsShown } from "./form.js";
|
|
5
6
|
export { asksPermission, pendingOf } from "./permission.js";
|
|
6
7
|
export type { AnswerPorts, Permissions, Waiting } from "./permission.js";
|
|
7
|
-
export type { PersonPorts, PersonState } from "./ports.js";
|
|
8
|
+
export type { FormsShown, PersonPorts, PersonState } from "./ports.js";
|
|
8
9
|
export { registerPersonRpc } from "./rpc-handlers.js";
|
|
9
10
|
export { asksQuestion, joinRelay, noticeCutOff, pendingQuestions, portsAt, relayQuestions } from "./question/index.js";
|
|
10
11
|
export type { QuestionPorts } from "./question/index.js";
|
package/dist/person/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { personEvents } from "./events.js";
|
|
2
|
+
export { formsShown } from "./form.js";
|
|
2
3
|
export { asksPermission, pendingOf } from "./permission.js";
|
|
3
4
|
export { registerPersonRpc } from "./rpc-handlers.js";
|
|
4
5
|
export { asksQuestion, joinRelay, noticeCutOff, pendingQuestions, portsAt, relayQuestions } from "./question/index.js";
|
|
@@ -3,6 +3,12 @@ import { type Pending, type RosterStorage } from "../tree/index.js";
|
|
|
3
3
|
/** The answers to a permission request, as OpenCode's own prompt offers them. */
|
|
4
4
|
export declare const REPLIES: readonly ["once", "always", "reject"];
|
|
5
5
|
export type Reply = (typeof REPLIES)[number];
|
|
6
|
+
/**
|
|
7
|
+
* What courier_answer passes on: `once`, which OpenCode first asks the person about in the caller's
|
|
8
|
+
* session, or `reject`. Never `always`: the plugin grants no standing rule to the caller or the child.
|
|
9
|
+
*/
|
|
10
|
+
export declare const PROPOSALS: readonly ["once", "reject"];
|
|
11
|
+
export type Proposal = (typeof PROPOSALS)[number];
|
|
6
12
|
/** A permission request, as OpenCode's `permission.asked` event carries it. */
|
|
7
13
|
export interface PermissionRequest {
|
|
8
14
|
readonly id: string;
|
|
@@ -11,6 +17,7 @@ export interface PermissionRequest {
|
|
|
11
17
|
readonly resources: ReadonlyArray<string>;
|
|
12
18
|
readonly save?: ReadonlyArray<string>;
|
|
13
19
|
readonly message?: string;
|
|
20
|
+
readonly metadata?: Readonly<Record<string, unknown>>;
|
|
14
21
|
}
|
|
15
22
|
/**
|
|
16
23
|
* What answering a permission request did; `answered` is false when no location loaded here held the
|
|
@@ -25,6 +32,8 @@ export interface PermissionAnswered {
|
|
|
25
32
|
type Context = Plugin.Context;
|
|
26
33
|
/** One location's pending permission requests. OpenCode keeps them per location, so an isolated child's are in its worktree's. */
|
|
27
34
|
export type Permissions = Pick<Context["permission"], "list" | "reply">;
|
|
35
|
+
/** OpenCode's check of a session's permission rules, which asks the person in that session when they say so. */
|
|
36
|
+
export type Assert = Context["permission"]["assert"];
|
|
28
37
|
/** The part of OpenCode's `permission.asked` event the notice is made from. */
|
|
29
38
|
export interface PermissionAsked {
|
|
30
39
|
readonly id: string;
|
|
@@ -48,12 +57,20 @@ export interface AnswerPorts {
|
|
|
48
57
|
readonly storage: RosterStorage;
|
|
49
58
|
/** The permission domains of every loaded location; the request is answered where it is pending. */
|
|
50
59
|
readonly permissions: () => Iterable<Permissions>;
|
|
60
|
+
/** The caller's location's check, which prompts the person in the caller's session. */
|
|
61
|
+
readonly assert: Assert;
|
|
62
|
+
}
|
|
63
|
+
/** The courier_answer call a proposal comes from, which OpenCode's prompt in the caller's session is bound to. */
|
|
64
|
+
export interface AnswerCall {
|
|
65
|
+
readonly messageID: string;
|
|
66
|
+
readonly callID: string;
|
|
51
67
|
}
|
|
52
68
|
export interface AnswerInput {
|
|
53
69
|
readonly sessionID: string;
|
|
54
70
|
readonly requestID: string;
|
|
55
71
|
readonly reply: string;
|
|
56
72
|
readonly message?: string;
|
|
73
|
+
readonly call?: AnswerCall;
|
|
57
74
|
}
|
|
58
75
|
/** A session's pending requests in every location, with the domain holding each; a location that cannot be read gives its error. */
|
|
59
76
|
export declare function listEverywhere<D extends Pick<Permissions, "list">>(permissions: Iterable<D>, sessionID: string): Promise<({
|
|
@@ -69,10 +86,28 @@ export declare function asksPermission(ports: {
|
|
|
69
86
|
readonly permissions: () => Iterable<Pick<Permissions, "list">>;
|
|
70
87
|
}, sessionID: string): Promise<boolean>;
|
|
71
88
|
/**
|
|
72
|
-
*
|
|
73
|
-
* which
|
|
89
|
+
* Whether `permission.assert` failed because the person rejected the caller's prompt without a reason,
|
|
90
|
+
* which OpenCode ends the caller's step on; the tool rethrows it as it is, and the child's request waits on.
|
|
91
|
+
*/
|
|
92
|
+
export declare const isDeclined: (error: unknown) => boolean;
|
|
93
|
+
/**
|
|
94
|
+
* Answers a permission request of a session started, directly or through others, from the caller, which must be the top
|
|
95
|
+
* session, a proposed `once` only once OpenCode allowed it there; `answered` is false when no location loaded here holds it.
|
|
74
96
|
*/
|
|
75
97
|
export declare function answer(ports: AnswerPorts, waiting: Waiting, callerID: string, input: AnswerInput): Promise<PermissionAnswered>;
|
|
98
|
+
/** The child's request a prompt of courier_answer's asks about, read from the metadata `askCaller` gives it; undefined for any other request. */
|
|
99
|
+
export declare function passedOn(metadata: Readonly<Record<string, unknown>> | undefined): {
|
|
100
|
+
sessionID: string;
|
|
101
|
+
requestID: string;
|
|
102
|
+
} | undefined;
|
|
103
|
+
/** The requests a session waits on of its own, in whichever location holds them, each with the child's request it passes on when courier_answer asks it. */
|
|
104
|
+
export declare function ownRequests(permissions: Iterable<Permissions>, sessionID: string): Promise<{
|
|
105
|
+
request: PermissionRequest;
|
|
106
|
+
passesOn: {
|
|
107
|
+
sessionID: string;
|
|
108
|
+
requestID: string;
|
|
109
|
+
} | undefined;
|
|
110
|
+
}[]>;
|
|
76
111
|
/** The requests a session waits on, in whichever location holds them, for courier_status. */
|
|
77
112
|
export declare function pendingOf(permissions: Iterable<Permissions>, sessionID: string): Promise<Pending[]>;
|
|
78
113
|
export {};
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { describeFailure } from "../kernel/protocol.js";
|
|
2
|
-
import { REJECTED } from "./text.js";
|
|
2
|
+
import { REJECTED, refusedText } from "./text.js";
|
|
3
3
|
import { answeringTop } from "../tree/index.js";
|
|
4
4
|
/** The answers to a permission request, as OpenCode's own prompt offers them. */
|
|
5
5
|
export const REPLIES = ["once", "always", "reject"];
|
|
6
|
+
/**
|
|
7
|
+
* What courier_answer passes on: `once`, which OpenCode first asks the person about in the caller's
|
|
8
|
+
* session, or `reject`. Never `always`: the plugin grants no standing rule to the caller or the child.
|
|
9
|
+
*/
|
|
10
|
+
export const PROPOSALS = ["once", "reject"];
|
|
6
11
|
/** A session's pending requests in every location, with the domain holding each; a location that cannot be read gives its error. */
|
|
7
12
|
export function listEverywhere(permissions, sessionID) {
|
|
8
13
|
return Promise.all([...permissions].map((domain) => domain.list({ sessionID }).then((requests) => ({ domain, requests: requests.filter((request) => request.sessionID === sessionID) }), (error) => ({ domain, requests: [], error }))));
|
|
@@ -24,28 +29,58 @@ async function locate(ports, sessionID, requestID) {
|
|
|
24
29
|
throw failed.error;
|
|
25
30
|
return undefined;
|
|
26
31
|
}
|
|
32
|
+
const tagOf = (value) => value?._tag;
|
|
27
33
|
/**
|
|
28
|
-
*
|
|
29
|
-
* which
|
|
34
|
+
* Whether `permission.assert` failed because the person rejected the caller's prompt without a reason,
|
|
35
|
+
* which OpenCode ends the caller's step on; the tool rethrows it as it is, and the child's request waits on.
|
|
30
36
|
*/
|
|
31
|
-
export
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
export const isDeclined = (error) => tagOf(error) === "Permission.DeclinedError";
|
|
38
|
+
/** Why `permission.assert` refused: the caller's rules deny the action, or the person rejected the prompt with a reason. */
|
|
39
|
+
function refusalOf(error) {
|
|
40
|
+
if (tagOf(error) !== "Tool.Error")
|
|
41
|
+
return undefined;
|
|
42
|
+
const { message, error: cause } = error;
|
|
43
|
+
const tag = tagOf(cause);
|
|
44
|
+
if (tag !== "Permission.BlockedError" && tag !== "Permission.CorrectedError")
|
|
45
|
+
return undefined;
|
|
46
|
+
return typeof message === "string" && message ? message : "not allowed";
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* OpenCode's check of the child's request under the caller's rules, which asks the person in the caller's session, with the
|
|
50
|
+
* request's own action and resources and nothing to save: the reason when refused with one, nothing when allowed, else thrown.
|
|
51
|
+
*/
|
|
52
|
+
async function askCaller(ports, callerID, request, call) {
|
|
53
|
+
try {
|
|
54
|
+
await ports.assert({
|
|
55
|
+
sessionID: callerID,
|
|
56
|
+
action: request.action,
|
|
57
|
+
resources: [...request.resources],
|
|
58
|
+
metadata: { source: "courier", from: request.sessionID, asks: "permission", requestID: request.id },
|
|
59
|
+
...(call ? { source: { type: "tool", messageID: call.messageID, id: call.callID } } : {}),
|
|
60
|
+
});
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
const reason = refusalOf(error);
|
|
65
|
+
if (reason === undefined)
|
|
66
|
+
throw error;
|
|
67
|
+
return reason;
|
|
41
68
|
}
|
|
42
|
-
|
|
43
|
-
|
|
69
|
+
}
|
|
70
|
+
/** The reply as a proposal courier_answer passes on; anything else, `always` in particular, is refused. */
|
|
71
|
+
function proposalOf(reply) {
|
|
72
|
+
if (PROPOSALS.includes(reply))
|
|
73
|
+
return reply;
|
|
74
|
+
const always = reply === "always" ? ": courier_answer grants no standing rule; the person grants one in OpenCode's own prompt, in the asking session or the asks inbox" : "";
|
|
75
|
+
throw new Error(`reply must be once or reject, not "${reply}"${always}.`);
|
|
76
|
+
}
|
|
77
|
+
/** Replies where the request is pending; a reply that fails leaves the request waiting, unless it no longer does. */
|
|
78
|
+
async function passOn(ports, waiting, found, reply) {
|
|
79
|
+
const { sessionID, requestID, decision, message } = reply;
|
|
44
80
|
// Off the waiting list first, so the reply event this answer causes does not come back as a notice.
|
|
45
81
|
waiting.delete(requestID);
|
|
46
|
-
const message = reply === "reject" ? input.message || REJECTED : undefined;
|
|
47
82
|
try {
|
|
48
|
-
await found.domain.reply({ sessionID, requestID, decision
|
|
83
|
+
await found.domain.reply({ sessionID, requestID, decision, ...(message ? { message } : {}) });
|
|
49
84
|
}
|
|
50
85
|
catch (error) {
|
|
51
86
|
if (await locate(ports, sessionID, requestID).catch(() => found)) {
|
|
@@ -54,8 +89,47 @@ export async function answer(ports, waiting, callerID, input) {
|
|
|
54
89
|
}
|
|
55
90
|
throw new Error(`${describeFailure("permission.reply", error).message}, but ${requestID} no longer waits, so it was answered, possibly by this call.`);
|
|
56
91
|
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Answers a permission request of a session started, directly or through others, from the caller, which must be the top
|
|
95
|
+
* session, a proposed `once` only once OpenCode allowed it there; `answered` is false when no location loaded here holds it.
|
|
96
|
+
*/
|
|
97
|
+
export async function answer(ports, waiting, callerID, input) {
|
|
98
|
+
const reply = proposalOf(input.reply);
|
|
99
|
+
const { sessionID, requestID } = input;
|
|
100
|
+
await answeringTop(ports.storage, sessionID, callerID, "permission requests");
|
|
101
|
+
let found = await locate(ports, sessionID, requestID);
|
|
102
|
+
let refused;
|
|
103
|
+
if (found && reply === "once") {
|
|
104
|
+
refused = await askCaller(ports, callerID, found.request, input.call);
|
|
105
|
+
// Answered meanwhile, in the child's session or the asks inbox, while the person was asked.
|
|
106
|
+
found = await locate(ports, sessionID, requestID);
|
|
107
|
+
}
|
|
108
|
+
if (!found) {
|
|
109
|
+
waiting.delete(requestID);
|
|
110
|
+
return { sessionID, requestID, reply, answered: false };
|
|
111
|
+
}
|
|
112
|
+
const message = refused ?? (reply === "reject" ? input.message || REJECTED : undefined);
|
|
113
|
+
await passOn(ports, waiting, found, { sessionID, requestID, decision: refused === undefined ? reply : "reject", ...(message ? { message } : {}) });
|
|
114
|
+
if (refused !== undefined)
|
|
115
|
+
throw new Error(refusedText(sessionID, requestID, refused));
|
|
57
116
|
return { sessionID, requestID, reply, answered: true };
|
|
58
117
|
}
|
|
118
|
+
/** The child's request a prompt of courier_answer's asks about, read from the metadata `askCaller` gives it; undefined for any other request. */
|
|
119
|
+
export function passedOn(metadata) {
|
|
120
|
+
const { source, asks, from, requestID } = metadata ?? {};
|
|
121
|
+
if (source !== "courier" || asks !== "permission" || typeof from !== "string" || typeof requestID !== "string")
|
|
122
|
+
return undefined;
|
|
123
|
+
return { sessionID: from, requestID };
|
|
124
|
+
}
|
|
125
|
+
/** The requests a session waits on of its own, in whichever location holds them, each with the child's request it passes on when courier_answer asks it. */
|
|
126
|
+
export async function ownRequests(permissions, sessionID) {
|
|
127
|
+
const found = new Map();
|
|
128
|
+
for (const { requests } of await listEverywhere(permissions, sessionID))
|
|
129
|
+
for (const request of requests)
|
|
130
|
+
found.set(request.id, request);
|
|
131
|
+
return [...found.values()].map((request) => ({ request, passesOn: passedOn(request.metadata) }));
|
|
132
|
+
}
|
|
59
133
|
/** The requests a session waits on, in whichever location holds them, for courier_status. */
|
|
60
134
|
export async function pendingOf(permissions, sessionID) {
|
|
61
135
|
const found = new Map();
|
package/dist/person/ports.d.ts
CHANGED
|
@@ -19,15 +19,21 @@ export interface PersonPorts {
|
|
|
19
19
|
readonly now: () => number;
|
|
20
20
|
readonly log: (message: string) => void;
|
|
21
21
|
}
|
|
22
|
+
/** The forms spawned sessions and others show, which only the person can answer, by form id; not the question tool's. */
|
|
23
|
+
export type FormsShown = Map<string, {
|
|
24
|
+
readonly sessionID: string;
|
|
25
|
+
readonly title: string;
|
|
26
|
+
}>;
|
|
22
27
|
/**
|
|
23
|
-
* What every plugin instance in the process shares of the person events: the handled event ids,
|
|
24
|
-
*
|
|
28
|
+
* What every plugin instance in the process shares of the person events: the handled event ids (the tree's too),
|
|
29
|
+
* the requests and forms told about or answered first, and the forms shown now.
|
|
25
30
|
*/
|
|
26
31
|
export interface PersonState {
|
|
27
32
|
readonly seen: Set<string>;
|
|
28
33
|
readonly waiting: Waiting;
|
|
29
34
|
readonly answered: Set<string>;
|
|
30
35
|
readonly forms: FormsTold;
|
|
36
|
+
readonly shown: FormsShown;
|
|
31
37
|
}
|
|
32
38
|
/** The session at the top of a lineage: where its permission requests go, since that is where the person is. */
|
|
33
39
|
export declare const topOf: (chain: RosterEntry[]) => string;
|
|
@@ -6,12 +6,10 @@ import { type Answered, type Asks } from "./rpc.js";
|
|
|
6
6
|
export interface PersonRpcPorts {
|
|
7
7
|
readonly questions: QuestionPorts;
|
|
8
8
|
readonly permissions: () => Iterable<Permissions>;
|
|
9
|
-
/** At most this many sessions of a tree are read, as courier_tree reads them. */
|
|
10
|
-
readonly maxTotal: number;
|
|
11
9
|
}
|
|
12
10
|
/**
|
|
13
|
-
* The permission requests and questions waiting in the courier tree a session is in,
|
|
14
|
-
* top of its lineage down, so each session of the tree sees the same inbox.
|
|
11
|
+
* The permission requests and questions waiting in the courier tree a session is in, the top's own requests first, read from the
|
|
12
|
+
* roster from the top of its lineage down, so each session of the tree sees the same inbox.
|
|
15
13
|
*/
|
|
16
14
|
export declare function asks(ports: PersonRpcPorts, sessionID: string): Promise<Asks>;
|
|
17
15
|
/** Passes the person's answers to a question on, as courier_answer does; a refusal, such as answers that do not fit, comes back as `problem`. */
|
|
@@ -33,6 +31,25 @@ export declare function registerPersonRpc(rpc: Pick<Plugin.Context["rpc"], "regi
|
|
|
33
31
|
readonly sessionID: import("effect/Schema").String;
|
|
34
32
|
}>;
|
|
35
33
|
readonly output: import("effect/StandardSchema").StandardSchemaV1<{
|
|
34
|
+
readonly top: string;
|
|
35
|
+
readonly sessions: readonly {
|
|
36
|
+
readonly sessionID: string;
|
|
37
|
+
readonly title: string;
|
|
38
|
+
readonly parentID: string;
|
|
39
|
+
readonly depth: number;
|
|
40
|
+
readonly group?: string | undefined;
|
|
41
|
+
}[];
|
|
42
|
+
readonly permissions: readonly {
|
|
43
|
+
readonly sessionID: string;
|
|
44
|
+
readonly resources: readonly string[];
|
|
45
|
+
readonly action: string;
|
|
46
|
+
readonly requestID: string;
|
|
47
|
+
readonly save?: readonly string[] | undefined;
|
|
48
|
+
readonly passesOn?: {
|
|
49
|
+
readonly sessionID: string;
|
|
50
|
+
readonly requestID: string;
|
|
51
|
+
} | undefined;
|
|
52
|
+
}[];
|
|
36
53
|
readonly questions: readonly {
|
|
37
54
|
readonly questions: readonly {
|
|
38
55
|
readonly options: readonly {
|
|
@@ -47,13 +64,7 @@ export declare function registerPersonRpc(rpc: Pick<Plugin.Context["rpc"], "regi
|
|
|
47
64
|
readonly requestID: string;
|
|
48
65
|
readonly stopped?: boolean | undefined;
|
|
49
66
|
}[];
|
|
50
|
-
|
|
51
|
-
readonly sessionID: string;
|
|
52
|
-
readonly resources: readonly string[];
|
|
53
|
-
readonly action: string;
|
|
54
|
-
readonly requestID: string;
|
|
55
|
-
readonly save?: readonly string[] | undefined;
|
|
56
|
-
}[];
|
|
67
|
+
}, {
|
|
57
68
|
readonly top: string;
|
|
58
69
|
readonly sessions: readonly {
|
|
59
70
|
readonly sessionID: string;
|
|
@@ -62,8 +73,17 @@ export declare function registerPersonRpc(rpc: Pick<Plugin.Context["rpc"], "regi
|
|
|
62
73
|
readonly depth: number;
|
|
63
74
|
readonly group?: string | undefined;
|
|
64
75
|
}[];
|
|
65
|
-
readonly
|
|
66
|
-
|
|
76
|
+
readonly permissions: readonly {
|
|
77
|
+
readonly sessionID: string;
|
|
78
|
+
readonly resources: readonly string[];
|
|
79
|
+
readonly action: string;
|
|
80
|
+
readonly requestID: string;
|
|
81
|
+
readonly save?: readonly string[] | undefined;
|
|
82
|
+
readonly passesOn?: {
|
|
83
|
+
readonly sessionID: string;
|
|
84
|
+
readonly requestID: string;
|
|
85
|
+
} | undefined;
|
|
86
|
+
}[];
|
|
67
87
|
readonly questions: readonly {
|
|
68
88
|
readonly questions: readonly {
|
|
69
89
|
readonly options: readonly {
|
|
@@ -78,22 +98,6 @@ export declare function registerPersonRpc(rpc: Pick<Plugin.Context["rpc"], "regi
|
|
|
78
98
|
readonly requestID: string;
|
|
79
99
|
readonly stopped?: boolean | undefined;
|
|
80
100
|
}[];
|
|
81
|
-
readonly permissions: readonly {
|
|
82
|
-
readonly sessionID: string;
|
|
83
|
-
readonly resources: readonly string[];
|
|
84
|
-
readonly action: string;
|
|
85
|
-
readonly requestID: string;
|
|
86
|
-
readonly save?: readonly string[] | undefined;
|
|
87
|
-
}[];
|
|
88
|
-
readonly top: string;
|
|
89
|
-
readonly sessions: readonly {
|
|
90
|
-
readonly sessionID: string;
|
|
91
|
-
readonly title: string;
|
|
92
|
-
readonly parentID: string;
|
|
93
|
-
readonly depth: number;
|
|
94
|
-
readonly group?: string | undefined;
|
|
95
|
-
}[];
|
|
96
|
-
readonly truncated?: number | undefined;
|
|
97
101
|
}> & import("effect/Schema").Struct<{
|
|
98
102
|
readonly top: import("effect/Schema").String;
|
|
99
103
|
readonly sessions: import("effect/Schema").$Array<import("effect/Schema").Struct<{
|
|
@@ -109,6 +113,10 @@ export declare function registerPersonRpc(rpc: Pick<Plugin.Context["rpc"], "regi
|
|
|
109
113
|
readonly action: import("effect/Schema").String;
|
|
110
114
|
readonly resources: import("effect/Schema").$Array<import("effect/Schema").String>;
|
|
111
115
|
readonly save: import("effect/Schema").optionalKey<import("effect/Schema").$Array<import("effect/Schema").String>>;
|
|
116
|
+
readonly passesOn: import("effect/Schema").optionalKey<import("effect/Schema").Struct<{
|
|
117
|
+
readonly sessionID: import("effect/Schema").String;
|
|
118
|
+
readonly requestID: import("effect/Schema").String;
|
|
119
|
+
}>>;
|
|
112
120
|
}>>;
|
|
113
121
|
readonly questions: import("effect/Schema").$Array<import("effect/Schema").Struct<{
|
|
114
122
|
readonly sessionID: import("effect/Schema").String;
|
|
@@ -124,7 +132,6 @@ export declare function registerPersonRpc(rpc: Pick<Plugin.Context["rpc"], "regi
|
|
|
124
132
|
}>>;
|
|
125
133
|
readonly stopped: import("effect/Schema").optionalKey<import("effect/Schema").Boolean>;
|
|
126
134
|
}>>;
|
|
127
|
-
readonly truncated: import("effect/Schema").optionalKey<import("effect/Schema").Int>;
|
|
128
135
|
}>;
|
|
129
136
|
};
|
|
130
137
|
readonly answer: {
|