opencode-courier 0.5.0 → 0.7.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 (43) hide show
  1. package/README.md +39 -30
  2. package/dist/index.js +9 -5
  3. package/dist/person/form.d.ts +4 -1
  4. package/dist/person/form.js +5 -0
  5. package/dist/person/index.d.ts +2 -1
  6. package/dist/person/index.js +1 -0
  7. package/dist/person/permission.d.ts +14 -0
  8. package/dist/person/permission.js +15 -0
  9. package/dist/person/ports.d.ts +8 -2
  10. package/dist/person/rpc-handlers.d.ts +37 -30
  11. package/dist/person/rpc-handlers.js +26 -16
  12. package/dist/person/rpc.d.ts +43 -24
  13. package/dist/person/rpc.js +4 -3
  14. package/dist/person/tui.d.ts +23 -12
  15. package/dist/tree/domain.d.ts +23 -0
  16. package/dist/tree/domain.js +26 -0
  17. package/dist/tree/events.d.ts +2 -2
  18. package/dist/tree/events.js +31 -4
  19. package/dist/tree/index.d.ts +2 -1
  20. package/dist/tree/index.js +2 -1
  21. package/dist/tree/panel.d.ts +7 -2
  22. package/dist/tree/panel.js +29 -17
  23. package/dist/tree/ports.d.ts +36 -0
  24. package/dist/tree/roster.d.ts +5 -0
  25. package/dist/tree/roster.js +5 -3
  26. package/dist/tree/rpc-handlers.d.ts +55 -6
  27. package/dist/tree/rpc-handlers.js +30 -1
  28. package/dist/tree/rpc.d.ts +111 -22
  29. package/dist/tree/rpc.js +30 -9
  30. package/dist/tree/stop.js +2 -0
  31. package/dist/tree/store.d.ts +4 -1
  32. package/dist/tree/store.js +51 -0
  33. package/dist/tree/subtree.d.ts +40 -9
  34. package/dist/tree/subtree.js +40 -19
  35. package/dist/tree/summary.d.ts +2 -2
  36. package/dist/tree/summary.js +17 -16
  37. package/dist/tree/text.js +4 -1
  38. package/dist/tree/tools.js +2 -1
  39. package/dist/tree/tui.d.ts +17 -6
  40. package/dist/tree/wake.d.ts +68 -0
  41. package/dist/tree/wake.js +119 -0
  42. package/dist/tui.js +617 -197
  43. package/package.json +1 -1
package/README.md CHANGED
@@ -14,10 +14,9 @@ polling.
14
14
  [![License: MIT](https://img.shields.io/npm/l/opencode-courier)](LICENSE)
15
15
  [![Supported OpenCode V2 version](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fivopogace%2Fopencode-courier%2Fmain%2Fpackage.json&query=%24.peerDependencies%5B%27%40opencode%2Fplugin%27%5D&label=opencode&color=blue)](#supported-opencode-version)
16
16
 
17
- ![A parent spawns two slow children, writes a README itself, and is woken by each report](docs/demo-async-tui.gif)
17
+ https://github.com/user-attachments/assets/74a50b9c-462a-43f5-8737-bc7dd1917988
18
18
 
19
- A parent writes a README while two slow children work, and each report wakes it ([the
20
- prompt](#demos)).
19
+ The edge cases on the live terminal UI, narrated ([what it shows](#demos)).
21
20
 
22
21
  ## Quickstart
23
22
 
@@ -45,8 +44,8 @@ sometimes stall on a request; if nothing moves for a few minutes, send the promp
45
44
  Courier is built on OpenCode V2's plugin API (`@opencode/plugin`), not the V1 one
46
45
  (`@opencode-ai/plugin`), and needs OpenCode V2. Each release is pinned to one V2 version and tested
47
46
  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, so a host release that breaks the plugin shows up there
49
- first. Older releases and their versions: [Supported OpenCode version](#supported-opencode-version).
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).
50
49
 
51
50
  ## Courier or the built-in subagent tool?
52
51
 
@@ -80,21 +79,18 @@ idle); `queue: true` waits until the current turn ends.
80
79
 
81
80
  ## Demos
82
81
 
83
- **The parent does not wait.** Prompt: *"Spawn two children: one runs `sleep 30` then lists the
84
- exports of math.js, the other runs `sleep 45` then lists the exports of text.js. Don't wait for
85
- them: meanwhile write a short README.md for this folder yourself, and add their reports to it when
86
- they arrive."* The recording at the top is this run: the parent writes its README and finishes its
87
- turn 16 seconds in, while both children still sleep; each report (the `Message from ses_…` line)
88
- then starts a new turn on its own, and the parent folds it into the README.
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:
89
84
 
90
- **A child's question reaches you.** Prompt: *"Spawn a child session to add a multiply function to
91
- math.js. It must ask me first whether to name it multiply, times or product."*
92
-
93
- ![A child's question opens as a form in the parent's session, times is picked, and the child carries on](docs/demo-question-tui.gif)
94
-
95
- The child's question opens in the parent's session as OpenCode's own question form, `times` is
96
- picked, and the child adds `times` to math.js and reports. Both recordings are real runs with a free
97
- model on [OpenCode Zen](https://opencode.ai/zen) (Muse Spark 1.3).
85
+ - **A join group:** one member reports and its report is held with the group, while the other waits
86
+ on a permission request, so the footer says one is waiting on you. You allow it from the asks inbox
87
+ (`ctrl+x k`), and both reports reach the parent in one message.
88
+ - **A child whose turn fails:** OpenCode shows "Session failed", the parent is told, and the
89
+ `/courier tree` row reads `failed`.
90
+ - **A child that ends its turn without a report:** the parent is told, and the row says so.
91
+ - **`/courier stop`:** pick the session, confirm, and it is stopped.
92
+ - **A form only you can answer:** a child's web search shows a form; the footer, the panel row and
93
+ the asks inbox point to it, and answering it there wakes the parent.
98
94
 
99
95
  ## Recursive orchestration
100
96
 
@@ -117,7 +113,7 @@ line to the server log naming both ([more](docs/reference.md#the-opencode-versio
117
113
 
118
114
  | opencode-courier | OpenCode V2 (`opencode` and `@opencode/plugin`) |
119
115
  |---|---|
120
- | 0.5.0 | 2.0.29 |
116
+ | 0.5.0, 0.6.0, 0.7.0 | 2.0.29 |
121
117
  | 0.4.0, 0.4.1 | 2.0.28 |
122
118
  | 0.2.3, 0.3.0 | 2.0.26 |
123
119
  | 0.2.2 | 2.0.24 |
@@ -213,6 +209,11 @@ Each tool's parameters: [the reference](docs/reference.md#the-tools).
213
209
  plugin, with the error or its last reply, waking it if idle; one that waits on a request, its own
214
210
  children, a scheduled message or a webhook is not, nor is one that answers what you typed in its
215
211
  session. [More](docs/reference.md#a-child-that-fails).
212
+ - **A message that fails to wake its session is sent again.** A report, notice or scheduled message
213
+ that waits in an idle session's inbox for 30 seconds without a turn starting for it is sent once
214
+ more under the same inbox id, which OpenCode takes for the same message, not a second one. If the
215
+ session still does not take it, the footer and the tree panel show it as `stalled`, for you to
216
+ prompt it. [More](docs/reference.md#a-missed-wake).
216
217
  - **Permission requests and questions reach you.** A child's request or question is passed to the
217
218
  session at the top of its tree, any number of levels down. For a request, that session proposes an
218
219
  answer with `courier_answer`, and OpenCode asks you in its own permission prompt, in that session
@@ -226,7 +227,8 @@ Each tool's parameters: [the reference](docs/reference.md#the-tools).
226
227
  [terminal](#in-the-terminal). [More](docs/reference.md#seeing-and-stopping-a-subtree).
227
228
 
228
229
  Children, held reports, scheduled messages and open questions survive a compaction or a restart;
229
- entries are dropped after 14 days ([roster](docs/reference.md#roster)).
230
+ entries are dropped after 14 days, or at once when OpenCode deletes the child, unless it is isolated
231
+ ([roster](docs/reference.md#roster)).
230
232
 
231
233
  ## Session trees
232
234
 
@@ -256,28 +258,35 @@ does not. Each spawned session's brief says when to split its task and when to d
256
258
 
257
259
  The package ships a part for OpenCode's terminal UI, which the TUI loads from the server's plugin
258
260
  list; the server half behaves the same whether or not a TUI is attached. In a session that started
259
- sessions, the prompt's footer shows what the tree under it is doing: how many run, wait on you or
260
- are idle, and for each [join group](docs/reference.md#join-groups) how many of its members have
261
+ sessions, the prompt's footer shows what the tree under it is doing: how many wait on you (a
262
+ permission request, a question or a form they show), are [stalled](docs/reference.md#a-missed-wake),
263
+ run or are idle, and for each [join group](docs/reference.md#join-groups) how many of its members have
261
264
  reported:
262
265
 
263
266
  ```
264
- courier 2 running · 1 waiting on you · group pair 1/2
267
+ courier 1 waiting on you · 2 running · group pair 1/2
265
268
  ```
266
269
 
267
270
  `/courier tree` opens a panel beside the session with the tree under it, a row per session indented
268
- by depth, with what it does, its report and its group's standing; a session that started others gets
269
- a bar of the parts reported, a leaf how long it ran, its last tool call and its cost. `↑`/`↓` select,
271
+ by depth, with what it does, its report and its group's standing; a session waiting on you gets a line per
272
+ request or form (`shows a form: Web Search`), a stalled one since when its message waits, a session that started others gets
273
+ a bar of the parts reported, a leaf how long it ran, its last tool call and its cost. A tree that has
274
+ started more than `maxTotal` sessions over time is listed live ones first, then newest, and the header
275
+ says how many older ones are left out; the footer counts them all. `↑`/`↓` select,
270
276
  `enter` opens the session, `s` stops it and everything under it after you confirm, as `courier_stop`
271
- would, and `q` closes the panel; `/courier stop <session>` stops from the prompt. While open, the panel
277
+ would, `x` forgets a `gone` session after you confirm, removing its worktree by `courier_cleanup`'s
278
+ rules, and `q` closes the panel; `/courier stop <session>` stops from the prompt. While open, the panel
272
279
  follows you between sessions: into one of its tree it keeps that tree, the session on screen marked
273
280
  `(on screen)`; elsewhere it shows that session's tree, or hides while you are in a session without one,
274
281
  or on the home screen, and shows again once you are back in one; only `q` closes it for good.
275
282
 
276
283
  `ctrl+x k` (the leader key, then `k`), `/asks` (or `/ask`), the palette's "Courier: what waits on you" or a click
277
284
  on "waiting on you" opens the asks inbox: every permission request, question and form waiting
278
- anywhere in the tree, oldest first, with where it comes from, answered in a dialog with no model in
279
- between; the session that started the tree hears once that you settled it. The TUI plays OpenCode's
280
- sounds for a child's report, a group's release and a new ask ([more](docs/reference.md#in-the-terminal)).
285
+ anywhere in the tree, however many sessions it has, oldest first, after the top session's own permission
286
+ requests, such as the prompt `courier_answer` has OpenCode show there, with where it comes from, kept current
287
+ 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
288
+ sounds for a child's report and a group's release, and OpenCode's own for a new ask, which the courier
289
+ adds a notification to for a native child ([more](docs/reference.md#in-the-terminal)).
281
290
 
282
291
  While a permission prompt or form is shown in the session, OpenCode hides the footer, so open the
283
292
  inbox with `ctrl+x k` (or `/asks` once the prompt is back).
package/dist/index.js CHANGED
@@ -7,21 +7,24 @@ 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
- import { addTreeTools, deliverReleased, dirtyOf, headOf, inspectWorktree, pruneExpired, readLimits, readNativeChildren, registerTreeRpc, shapeContext, together, treeEvents, waitsOn, } from "./tree/index.js";
12
+ import { addTreeTools, deliverReleased, wakeMissed, 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";
14
14
  /** The spawns under way in the process, outside the hub, so spawns in other locations, copies and hub versions count them. */
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
- * due `courier_later` messages, then released join groups. Every hub-v2 member's `later` is its LaterPorts.
22
+ * due `courier_later` messages, released join groups, then missed wakes. Every hub-v2 member's `later` is its LaterPorts.
21
23
  */
22
24
  const jobs = [
23
25
  dueJob,
24
26
  (owner, claimed) => deliverReleased(owner.later, claimed),
27
+ (owner, claimed) => wakeMissed(owner.later, claimed),
25
28
  ];
26
29
  const { join, track, nudge } = openInProcess(jobs);
27
30
  /** The permission domains of the loaded instances: every member's is its location's, whichever copy joined it. */
@@ -30,7 +33,7 @@ const permissions = domains;
30
33
  // several subscriptions and copies see the same events. The scheduler's claim set is the hub's own.
31
34
  const { watched } = hub;
32
35
  const watchState = { seen: watched.seen, inbox };
33
- const personState = { ...watched, forms: hub.forms };
36
+ const personState = { ...watched, forms: hub.forms, shown };
34
37
  /**
35
38
  * What a session waits on outside the tree, for the silent-turn rule, cheapest first: a request of its
36
39
  * own to the person, then a scheduled message or a webhook it subscribed to since its last prompt.
@@ -93,6 +96,7 @@ export const courier = () => Plugin.define({
93
96
  ]);
94
97
  return [...requests, ...questions];
95
98
  },
99
+ forms: (sessionID) => Promise.resolve(formsShown(shown, sessionID)),
96
100
  };
97
101
  const cleanupPorts = { ...ports, inspect: inspectWorktree };
98
102
  const answerPorts = { storage: ctx.storage, permissions, assert: (input) => ctx.permission.assert(input) };
@@ -123,7 +127,7 @@ export const courier = () => Plugin.define({
123
127
  // What the TUI part reads and its stop; a host that cannot register it still gets the rest, the tools included.
124
128
  await Promise.resolve()
125
129
  .then(() => registerTreeRpc(ctx.rpc, stopPorts))
126
- .then(() => registerPersonRpc(ctx.rpc, { questions: questionPorts, permissions, maxTotal: limits.maxTotal }))
130
+ .then(() => registerPersonRpc(ctx.rpc, { questions: questionPorts, permissions }))
127
131
  .catch((error) => log(`courier rpc: not registered, the TUI part shows nothing and cannot stop: ${String(error)}`));
128
132
  await ctx.session.hook("context", (event) => shapeContext({ storage: ctx.storage, limits, roles, log }, event));
129
133
  const pruned = pruneExpired(ctx.storage, Date.now()).catch((error) => log(`courier roster prune: ${String(error)}`));
@@ -1,4 +1,5 @@
1
- import { type Form, type PersonPorts, type PersonState } from "./ports.js";
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[];
@@ -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 }] : []));
@@ -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";
@@ -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";
@@ -17,6 +17,7 @@ export interface PermissionRequest {
17
17
  readonly resources: ReadonlyArray<string>;
18
18
  readonly save?: ReadonlyArray<string>;
19
19
  readonly message?: string;
20
+ readonly metadata?: Readonly<Record<string, unknown>>;
20
21
  }
21
22
  /**
22
23
  * What answering a permission request did; `answered` is false when no location loaded here held the
@@ -94,6 +95,19 @@ export declare const isDeclined: (error: unknown) => boolean;
94
95
  * session, a proposed `once` only once OpenCode allowed it there; `answered` is false when no location loaded here holds it.
95
96
  */
96
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
+ }[]>;
97
111
  /** The requests a session waits on, in whichever location holds them, for courier_status. */
98
112
  export declare function pendingOf(permissions: Iterable<Permissions>, sessionID: string): Promise<Pending[]>;
99
113
  export {};
@@ -115,6 +115,21 @@ export async function answer(ports, waiting, callerID, input) {
115
115
  throw new Error(refusedText(sessionID, requestID, refused));
116
116
  return { sessionID, requestID, reply, answered: true };
117
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
+ }
118
133
  /** The requests a session waits on, in whichever location holds them, for courier_status. */
119
134
  export async function pendingOf(permissions, sessionID) {
120
135
  const found = new Map();
@@ -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, shared
24
- * with the tree's, and the requests and forms the top sessions were told about or that were answered first.
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, from the roster: read from the
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
- readonly permissions: readonly {
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 truncated?: number | undefined;
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: {
@@ -1,15 +1,16 @@
1
1
  import { lineage, subtree } from "../tree/index.js";
2
- import { pendingOf } from "./permission.js";
2
+ import { ownRequests, pendingOf } from "./permission.js";
3
3
  import { answerForPerson, pendingQuestions } from "./question/index.js";
4
4
  import { personRpc } from "./rpc.js";
5
5
  /**
6
- * The permission requests and questions waiting in the courier tree a session is in, from the roster: read from the
7
- * top of its lineage down, so each session of the tree sees the same inbox.
6
+ * The permission requests and questions waiting in the courier tree a session is in, the top's own requests first, read from the
7
+ * roster from the top of its lineage down, so each session of the tree sees the same inbox.
8
8
  */
9
9
  export async function asks(ports, sessionID) {
10
10
  const { storage } = ports.questions;
11
11
  const top = (await lineage(storage, sessionID)).at(-1)?.parentID ?? sessionID;
12
- const { nodes, truncated } = await subtree(storage, top, ports.maxTotal);
12
+ const { nodes } = await subtree(storage, top, { now: ports.questions.now() });
13
+ const own = nodes.length ? await ownRequests(ports.permissions(), top).catch(() => []) : [];
13
14
  const waiting = await Promise.all(nodes.map(({ entry }) => Promise.all([
14
15
  pendingOf(ports.permissions(), entry.sessionID).catch(() => []),
15
16
  pendingQuestions(storage, entry.sessionID).catch(() => []),
@@ -23,24 +24,33 @@ export async function asks(ports, sessionID) {
23
24
  depth,
24
25
  ...(entry.group ? { group: entry.group } : {}),
25
26
  })),
26
- permissions: nodes.flatMap(({ entry }, index) => waiting[index][0].flatMap((pending) => pending.type === "permission"
27
- ? [
28
- {
29
- sessionID: entry.sessionID,
30
- requestID: pending.requestID,
31
- action: pending.action,
32
- resources: pending.resources,
33
- ...(pending.save ? { save: pending.save } : {}),
34
- },
35
- ]
36
- : [])),
27
+ permissions: [
28
+ ...own.map(({ request, passesOn }) => ({
29
+ sessionID: top,
30
+ requestID: request.id,
31
+ action: request.action,
32
+ resources: [...request.resources],
33
+ ...(request.save?.length ? { save: [...request.save] } : {}),
34
+ ...(passesOn ? { passesOn } : {}),
35
+ })),
36
+ ...nodes.flatMap(({ entry }, index) => waiting[index][0].flatMap((pending) => pending.type === "permission"
37
+ ? [
38
+ {
39
+ sessionID: entry.sessionID,
40
+ requestID: pending.requestID,
41
+ action: pending.action,
42
+ resources: pending.resources,
43
+ ...(pending.save ? { save: pending.save } : {}),
44
+ },
45
+ ]
46
+ : [])),
47
+ ],
37
48
  questions: nodes.flatMap(({ entry }, index) => waiting[index][1].map(({ requestID, questions, stopped }) => ({
38
49
  sessionID: entry.sessionID,
39
50
  requestID,
40
51
  questions,
41
52
  ...(stopped ? { stopped } : {}),
42
53
  }))),
43
- ...(truncated ? { truncated: ports.maxTotal } : {}),
44
54
  };
45
55
  }
46
56
  /** 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`. */