opencode-courier 0.2.2 → 0.3.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 (60) hide show
  1. package/README.md +247 -108
  2. package/dist/bounded.d.ts +8 -0
  3. package/dist/bounded.js +19 -0
  4. package/dist/cleanup.d.ts +21 -21
  5. package/dist/cleanup.js +61 -30
  6. package/dist/courier.d.ts +116 -15
  7. package/dist/courier.js +239 -74
  8. package/dist/group.d.ts +86 -0
  9. package/dist/group.js +247 -0
  10. package/dist/hub.d.ts +300 -0
  11. package/dist/hub.js +343 -0
  12. package/dist/index.d.ts +5 -13
  13. package/dist/index.js +82 -313
  14. package/dist/json.d.ts +8 -0
  15. package/dist/json.js +15 -0
  16. package/dist/later.d.ts +7 -7
  17. package/dist/later.js +8 -7
  18. package/dist/limits.d.ts +65 -0
  19. package/dist/limits.js +109 -0
  20. package/dist/notices.d.ts +356 -0
  21. package/dist/notices.js +678 -0
  22. package/dist/question/answer.d.ts +35 -0
  23. package/dist/question/answer.js +214 -0
  24. package/dist/question/index.d.ts +6 -0
  25. package/dist/question/index.js +7 -0
  26. package/dist/question/lifecycle.d.ts +43 -0
  27. package/dist/question/lifecycle.js +91 -0
  28. package/dist/question/pure.d.ts +48 -0
  29. package/dist/question/pure.js +74 -0
  30. package/dist/question/relay.d.ts +5 -0
  31. package/dist/question/relay.js +269 -0
  32. package/dist/question/runtime.d.ts +20 -0
  33. package/dist/question/runtime.js +74 -0
  34. package/dist/question/shared.d.ts +24 -0
  35. package/dist/question/shared.js +24 -0
  36. package/dist/relay.d.ts +32 -36
  37. package/dist/relay.js +6 -56
  38. package/dist/report.d.ts +48 -0
  39. package/dist/report.js +88 -0
  40. package/dist/roster.d.ts +48 -8
  41. package/dist/roster.js +140 -17
  42. package/dist/skill.d.ts +13 -0
  43. package/dist/skill.js +9 -0
  44. package/dist/stop.d.ts +41 -0
  45. package/dist/stop.js +120 -0
  46. package/dist/storage.d.ts +8 -1
  47. package/dist/storage.js +8 -5
  48. package/dist/tools.d.ts +94 -0
  49. package/dist/tools.js +297 -0
  50. package/dist/tree.d.ts +102 -0
  51. package/dist/tree.js +58 -0
  52. package/dist/version.d.ts +4 -10
  53. package/dist/version.js +4 -10
  54. package/dist/watch.d.ts +70 -42
  55. package/dist/watch.js +346 -51
  56. package/dist/webhook.d.ts +11 -28
  57. package/dist/webhook.js +46 -146
  58. package/package.json +16 -5
  59. package/dist/question.d.ts +0 -158
  60. package/dist/question.js +0 -768
package/dist/watch.d.ts CHANGED
@@ -1,70 +1,98 @@
1
- import type { Plugin } from "@opencode/plugin";
2
- import { type PermissionAsked, type PermissionReplied, type Waiting } from "./relay.js";
3
- import { type RosterStorage } from "./roster.js";
4
- type Context = Plugin.Context;
1
+ import type { Hub, WatchPorts, WatchState } from "./hub.js";
2
+ import { type ExecutionError } from "./notices.js";
3
+ import { type FormCreated, type FormSettled, type PermissionAsked, type PermissionReplied } from "./relay.js";
4
+ export type { FormsTold, WatchPorts, WatchState } from "./hub.js";
5
5
  /** How long to wait before subscribing again after the event stream ended or broke. */
6
6
  export declare const RESUBSCRIBE_MS = 5000;
7
- export interface WatchPorts {
8
- readonly storage: RosterStorage;
9
- readonly session: Pick<Context["session"], "synthetic">;
10
- readonly event: Pick<Context["event"], "subscribe">;
11
- /** This location's pending permission requests, relayed when the watcher (re)subscribes. */
12
- readonly permission: Pick<Context["permission"], "list">;
13
- readonly log: (message: string) => void;
14
- }
15
- /**
16
- * What every plugin instance in the process shares: OpenCode sets the plugin up once per project
17
- * location, all in one process, and each instance may see the same event, so an event id is
18
- * claimed synchronously and handled once. `waiting` holds the permission requests a session was
19
- * told about and has not answered; `answered`, requests answered before anyone was told, so a
20
- * notice whose roster lookup was overtaken by the answer is not sent.
21
- */
22
- export interface WatchState {
23
- readonly seen: Set<string>;
24
- readonly waiting: Waiting;
25
- readonly answered: Set<string>;
26
- }
27
7
  /** The part of OpenCode's `session.execution.failed` event the notice is made from. */
28
8
  export interface ExecutionFailed {
29
9
  readonly id: string;
10
+ /** When OpenCode published it, in epoch milliseconds. */
11
+ readonly created?: number;
30
12
  readonly data: {
31
13
  readonly sessionID: string;
32
- readonly error: {
33
- readonly type: string;
34
- readonly message: string;
35
- readonly status?: number;
36
- };
14
+ readonly error: ExecutionError;
15
+ };
16
+ }
17
+ /** OpenCode's events that concern one session's turns and inbox: `session.execution.succeeded` and the like. */
18
+ export interface SessionEvent {
19
+ readonly id: string;
20
+ readonly created?: number;
21
+ readonly data: {
22
+ readonly sessionID: string;
23
+ readonly reason?: string;
37
24
  };
38
25
  }
39
- export declare function failureNotice(title: string, error: ExecutionFailed["data"]["error"]): string;
40
- /** Adds to a bounded set, dropping the oldest entry; false when the value was there already. */
41
- export declare function claim(set: Set<string>, value: string): boolean;
42
26
  /**
43
27
  * Tells the parent of a child whose turn failed, since a child that cannot reach its model cannot
44
28
  * call courier_send. Returns the parents told.
45
29
  */
46
30
  export declare function reportFailure(ports: WatchPorts, seen: Set<string>, event: ExecutionFailed): Promise<string[]>;
31
+ /** OpenCode's `session.inbox.enqueued`: the item's type, which its `session.inbox.delivered` does not carry. */
32
+ export interface InboxEnqueued {
33
+ readonly data: {
34
+ readonly sessionID: string;
35
+ readonly inboxID: string;
36
+ readonly item: {
37
+ readonly type: string;
38
+ };
39
+ };
40
+ }
41
+ /** Remembers the type of an item enqueued for a spawned session, for `notePrompt`; other sessions cost one read. */
42
+ export declare function noteEnqueued(ports: WatchPorts, inbox: Map<string, string>, event: InboxEnqueued): Promise<boolean>;
43
+ /**
44
+ * Notes that a message reached a spawned session, which then owes its parent a report, unless `inbox` knows it for one of
45
+ * `UNOWED` (`spawn` notes the task itself); reads the reverse index alone, and claims nothing: the note is the same twice.
46
+ */
47
+ export declare function notePrompt(ports: WatchPorts, event: SessionEvent & {
48
+ readonly data: {
49
+ readonly inboxID?: string;
50
+ };
51
+ }, inbox?: Map<string, string>): Promise<boolean>;
52
+ /**
53
+ * Notes that a spawned session's turn was stopped, which takes it out of its group without a report; one
54
+ * stopped by a shutdown resumes on the next start.
55
+ */
56
+ export declare function noteInterrupted(ports: WatchPorts, event: SessionEvent): Promise<boolean>;
57
+ /**
58
+ * Drops the report state, and the groups, of a session OpenCode deleted, and the report state of the sessions it
59
+ * started, whose reports can no longer be delivered; it leaves its own group. Returns the sessions forgotten.
60
+ */
61
+ export declare function noteDeleted(ports: WatchPorts, event: SessionEvent): Promise<string[]>;
62
+ /**
63
+ * Tells the parent of a spawned session whose turn ended without reporting to it since its last
64
+ * prompt, unless it waits; finds the session by the reverse index alone. Returns the parents told.
65
+ */
66
+ export declare function reportSilent(ports: WatchPorts, seen: Set<string>, event: SessionEvent): Promise<string[]>;
47
67
  /**
48
68
  * Tells the session at the top, the parent of a child or the session a chain of children started
49
69
  * from, what a waiting child asks and which answers there are, waking it. Returns the sessions told.
50
70
  */
51
71
  export declare function reportAsked(ports: WatchPorts, state: WatchState, event: PermissionAsked): Promise<string[]>;
52
72
  /**
53
- * Tells the session that was told about a request that it has been answered some other way, in the
54
- * child's own session or along with another answer, so it does not pass on a stale question.
55
- * Requests answered through courier_answer are no longer waiting and are skipped.
73
+ * Tells the session told about a request that it was answered some other way, so it does not pass on
74
+ * a stale question; requests answered through courier_answer no longer wait and are skipped.
56
75
  */
57
76
  export declare function reportReplied(ports: WatchPorts, state: WatchState, event: PermissionReplied): Promise<string[]>;
58
77
  /**
59
- * Relays the requests that spawned sessions in this location already wait on, which the event
60
- * stream does not repeat: those asked while it was down, before the watcher (re)subscribed.
78
+ * Tells the top session about a form OpenCode shows in a spawned session, such as web search asking
79
+ * for its provider, which only the person can answer; question forms are left to the question relay.
80
+ */
81
+ export declare function reportForm(ports: WatchPorts, state: WatchState, event: FormCreated): Promise<string[]>;
82
+ /** 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. */
83
+ export declare function reportFormSettled(ports: WatchPorts, state: WatchState, event: FormSettled): Promise<string[]>;
84
+ /**
85
+ * Relays the requests that spawned sessions already wait on, in every loaded location, which the
86
+ * event stream does not repeat: those asked while it was down, before the watcher (re)subscribed.
61
87
  */
62
88
  export declare function relayPending(ports: WatchPorts, state: WatchState): Promise<void>;
63
89
  /**
64
- * Follows OpenCode's events until `signal` aborts, telling parents when a spawned child's turn
65
- * fails, when it waits for a permission and when that request is answered without them, and
66
- * noting for the question relay the question forms shown, which it waits for, and the locations
67
- * shutting down, whose withdrawn forms must not pass for dismissals.
90
+ * Follows OpenCode's events until `signal` aborts: tells parents of a spawned child's failed or silent
91
+ * turn, its pending permission or form, notes forms and shutdowns for the relay; ends after its current event.
68
92
  */
69
93
  export declare function watchChildren(ports: WatchPorts, state: WatchState, signal: AbortSignal, retryMs?: number): Promise<void>;
70
- export {};
94
+ /**
95
+ * Makes the hub start its subscriptions to OpenCode's events with this copy's watcher, the one loaded
96
+ * last; `join` starts them. The second is a standby, connected before the first's member leaves.
97
+ */
98
+ export declare function watchFromHub(hub: Pick<Hub, "subscribe">, state: WatchState, retryMs?: number): void;
package/dist/watch.js CHANGED
@@ -1,28 +1,19 @@
1
- import { envelope } from "./courier.js";
2
- import { eventsFollowed, eventsLeft, formShown, formsMayHaveBeenMissed, locationClosing } from "./question.js";
3
- import { permissionNotice, settledNotice } from "./relay.js";
4
- import { allEntries, entriesOf, lineage } from "./roster.js";
1
+ import { addBounded, setBounded } from "./bounded.js";
2
+ import { lastReply } from "./courier.js";
3
+ import { dropGroups, hasCompleteGroup, markLeft, membershipOf } from "./group.js";
4
+ import { envelope, failureNotice, formNotice, formSettledNotice, kindOf, permissionNotice, permissionSettledNotice, silentNotice, } from "./notices.js";
5
+ import { eventsFollowed, eventsLeft, formShown, formsMayHaveBeenMissed, locationClosing, pendingQuestions } from "./question/index.js";
6
+ import { listEverywhere, QUESTION_FORM, } from "./relay.js";
7
+ import { scheduledFor } from "./later.js";
8
+ import { awaited, forgetReport, prompted, reportOf, settled, stoppedSincePrompt, told, untold } from "./report.js";
9
+ import { allEntries, children, entriesOf, entryUnder, indexedEntry, indexedParent, lineage } from "./roster.js";
10
+ import { subscriptions } from "./webhook.js";
5
11
  /** How long to wait before subscribing again after the event stream ended or broke. */
6
12
  export const RESUBSCRIBE_MS = 5_000;
7
13
  /** How many handled event ids, and waiting or answered requests, are remembered. */
8
14
  const SEEN_MAX = 1_000;
9
- export function failureNotice(title, error) {
10
- const status = error.status === undefined ? "" : `, status ${error.status}`;
11
- return [
12
- `This session, "${title}", which you started with courier_spawn, failed: ${error.message} (${error.type}${status}).`,
13
- "Its turn ended without finishing, so it will not report back on its own.",
14
- "Message it with courier_send to have it try again, start a replacement, or carry on without it.",
15
- ].join("\n");
16
- }
17
- /** Adds to a bounded set, dropping the oldest entry; false when the value was there already. */
18
- export function claim(set, value) {
19
- if (set.has(value))
20
- return false;
21
- set.add(value);
22
- if (set.size > SEEN_MAX)
23
- set.delete(set.values().next().value);
24
- return true;
25
- }
15
+ /** Claims a value in one of the shared sets; false when it was claimed already. */
16
+ const claim = (set, value) => addBounded(set, value, SEEN_MAX);
26
17
  /**
27
18
  * Tells the parent of a child whose turn failed, since a child that cannot reach its model cannot
28
19
  * call courier_send. Returns the parents told.
@@ -32,15 +23,174 @@ export async function reportFailure(ports, seen, event) {
32
23
  return [];
33
24
  const { sessionID, error } = event.data;
34
25
  const entries = await entriesOf(ports.storage, sessionID);
35
- await Promise.all(entries.map((entry) => ports.session.synthetic({
36
- sessionID: entry.parentID,
37
- text: envelope(sessionID, failureNotice(entry.title, error), { failed: error.type }),
38
- description: `Session ${sessionID} failed`,
39
- metadata: { source: "courier", from: sessionID, failed: true },
40
- delivery: "steer",
41
- })));
26
+ const at = event.created ?? ports.now();
27
+ // Not in the way of the notice: kept only so the limits stop counting the child.
28
+ if (entries.length)
29
+ await settled(ports.storage, sessionID, "failed", at).catch((error) => ports.log(`courier watch: could not note the failed turn of ${sessionID}: ${String(error)}`));
30
+ const out = await Promise.all(entries.map((entry) => outIn(ports, entry)));
31
+ // Left before the notice, so the parent waits on the group meanwhile; nudged after it, so the group's
32
+ // message does not overtake the notice, short of an interval tick falling in between.
33
+ const completed = await Promise.all(out.map((member) => (member ? leave(ports, member, "failed", at) : Promise.resolve(false))));
34
+ // A session courier_stop stopped is not a failure to tell: its parent was told of the stop, or ordered it.
35
+ const quiet = entries.length > 0 && (await stoppedSincePrompt(ports.storage, sessionID).catch(() => false));
36
+ try {
37
+ if (quiet)
38
+ return [];
39
+ await Promise.all(entries.map((entry, index) => ports.session.synthetic({
40
+ sessionID: entry.parentID,
41
+ text: envelope(sessionID, failureNotice(entry.title, error, out[index]?.group), { failed: error.type }),
42
+ description: `Session ${sessionID} failed`,
43
+ metadata: { source: "courier", from: sessionID, failed: true },
44
+ delivery: "steer",
45
+ })));
46
+ }
47
+ finally {
48
+ if (completed.includes(true))
49
+ ports.nudge();
50
+ }
42
51
  return entries.map((entry) => entry.parentID);
43
52
  }
53
+ /** A child's membership of an open group it is still out in: the group waits for it, and its end takes it out without a report. */
54
+ async function outIn(ports, entry) {
55
+ if (!entry.group)
56
+ return undefined;
57
+ try {
58
+ const member = await membershipOf(ports.storage, entry.parentID, entry.group, entry.sessionID);
59
+ return member?.out ? member : undefined;
60
+ }
61
+ catch (error) {
62
+ ports.log(`courier watch: could not read ${entry.sessionID}'s place in group ${entry.group}: ${String(error)}`);
63
+ return undefined;
64
+ }
65
+ }
66
+ /** Takes a member out of its group without a report; true when that completes the group, for the caller to nudge the scheduler. */
67
+ async function leave(ports, member, by, at) {
68
+ try {
69
+ return await markLeft(ports.storage, member, by, at);
70
+ }
71
+ catch (error) {
72
+ ports.log(`courier watch: could not take ${member.sessionID} out of group ${member.group}: ${String(error)}`);
73
+ return false;
74
+ }
75
+ }
76
+ /** Inbox items that are not courier's or the parent's business: the person's own prompts, compactions and moves. */
77
+ const UNOWED = new Set(["user", "compaction", "move"]);
78
+ /** Remembers the type of an item enqueued for a spawned session, for `notePrompt`; other sessions cost one read. */
79
+ export async function noteEnqueued(ports, inbox, event) {
80
+ const { sessionID, inboxID, item } = event.data;
81
+ if ((await indexedParent(ports.storage, sessionID)) === undefined)
82
+ return false;
83
+ setBounded(inbox, inboxID, item.type, SEEN_MAX);
84
+ return true;
85
+ }
86
+ /**
87
+ * Notes that a message reached a spawned session, which then owes its parent a report, unless `inbox` knows it for one of
88
+ * `UNOWED` (`spawn` notes the task itself); reads the reverse index alone, and claims nothing: the note is the same twice.
89
+ */
90
+ export async function notePrompt(ports, event, inbox) {
91
+ const { sessionID, inboxID } = event.data;
92
+ // Kept, not deleted: the hub's other subscription looks it up for the same delivery.
93
+ if (inboxID !== undefined && UNOWED.has(inbox?.get(inboxID) ?? ""))
94
+ return false;
95
+ if ((await indexedParent(ports.storage, sessionID)) === undefined)
96
+ return false;
97
+ await prompted(ports.storage, sessionID, event.created ?? ports.now());
98
+ return true;
99
+ }
100
+ /**
101
+ * Notes that a spawned session's turn was stopped, which takes it out of its group without a report; one
102
+ * stopped by a shutdown resumes on the next start.
103
+ */
104
+ export async function noteInterrupted(ports, event) {
105
+ if (event.data.reason === "shutdown")
106
+ return false;
107
+ const { sessionID } = event.data;
108
+ const entry = await indexedEntry(ports.storage, sessionID);
109
+ if (entry === undefined)
110
+ return false;
111
+ const at = event.created ?? ports.now();
112
+ await settled(ports.storage, sessionID, "interrupted", at);
113
+ const member = await outIn(ports, entry);
114
+ if (member && (await leave(ports, member, "interrupted", at)))
115
+ ports.nudge();
116
+ return true;
117
+ }
118
+ /**
119
+ * Drops the report state, and the groups, of a session OpenCode deleted, and the report state of the sessions it
120
+ * started, whose reports can no longer be delivered; it leaves its own group. Returns the sessions forgotten.
121
+ */
122
+ export async function noteDeleted(ports, event) {
123
+ const { sessionID } = event.data;
124
+ const [parentID, started] = await Promise.all([indexedParent(ports.storage, sessionID), children(ports.storage, sessionID)]);
125
+ const forgotten = [...(parentID === undefined ? [] : [sessionID]), ...started.map((child) => child.sessionID)];
126
+ const entry = parentID === undefined ? undefined : await entryUnder(ports.storage, parentID, sessionID);
127
+ const member = entry && (await outIn(ports, entry));
128
+ // Its groups go whether or not it has children on the roster: a held report outlives the member's entry.
129
+ const [completed] = await Promise.all([
130
+ member ? leave(ports, member, "deleted", event.created ?? ports.now()) : Promise.resolve(false),
131
+ ...forgotten.map((id) => forgetReport(ports.storage, id)),
132
+ dropGroups(ports.storage, sessionID),
133
+ ]);
134
+ if (completed)
135
+ ports.nudge();
136
+ return forgotten;
137
+ }
138
+ /**
139
+ * Whether a session prompted at `prompt` waits: on an untold report of a session it started, a complete group of
140
+ * its about to be delivered, a request of its own, a scheduled message, or a webhook it subscribed to since. Cheapest first.
141
+ */
142
+ async function waits(ports, sessionID, prompt) {
143
+ const started = await children(ports.storage, sessionID);
144
+ if ((await Promise.all(started.map((entry) => awaited(ports.storage, entry.sessionID)))).includes(true))
145
+ return true;
146
+ if ((await listEverywhere([...ports.permissions()], sessionID)).some((found) => found.requests.length))
147
+ return true;
148
+ // A group with a member still out is waited on through that member, above; one whose every out member was told of is not.
149
+ if (started.some((entry) => entry.group) && (await hasCompleteGroup(ports.storage, sessionID)))
150
+ return true;
151
+ if ((await pendingQuestions(ports.storage, sessionID)).length)
152
+ return true;
153
+ const [scheduled, subscribed] = await Promise.all([scheduledFor(ports.storage, sessionID), subscriptions(ports)]);
154
+ // An older subscription is no wait: a prompt, most likely its delivery, has come since.
155
+ return scheduled.length > 0 || subscribed.some((subscription) => subscription.sessionID === sessionID && subscription.createdAt >= prompt);
156
+ }
157
+ /**
158
+ * Tells the parent of a spawned session whose turn ended without reporting to it since its last
159
+ * prompt, unless it waits; finds the session by the reverse index alone. Returns the parents told.
160
+ */
161
+ export async function reportSilent(ports, seen, event) {
162
+ const { sessionID } = event.data;
163
+ const entry = await indexedEntry(ports.storage, sessionID);
164
+ // Claimed after the lookup, so other sessions' turns do not crowd `seen`, and before anything is told.
165
+ if (!entry || !claim(seen, event.id))
166
+ return [];
167
+ const report = await reportOf(ports.storage, sessionID);
168
+ if (!report?.owes || (await waits(ports, sessionID, report.prompt)))
169
+ return [];
170
+ if (await stoppedSincePrompt(ports.storage, sessionID).catch(() => false))
171
+ return [];
172
+ // The notice goes out without the reply rather than not at all.
173
+ const lastText = await ports.session.context({ sessionID }).then(lastReply, () => undefined);
174
+ const group = (await outIn(ports, entry))?.group;
175
+ // Before the notice, which may end the parent's turn at once: once told, it no longer waits for this one.
176
+ const toldAt = event.created ?? ports.now();
177
+ await told(ports.storage, sessionID, toldAt).catch((error) => ports.log(`courier watch: could not note that the parent of ${sessionID} was told: ${String(error)}`));
178
+ try {
179
+ await ports.session.synthetic({
180
+ sessionID: entry.parentID,
181
+ text: envelope(sessionID, silentNotice(entry.title, lastText, report.progressed, group), { ended: "without-report" }),
182
+ description: `Session ${sessionID} ended without a report`,
183
+ metadata: { source: "courier", from: sessionID, ended: "without-report" },
184
+ delivery: "steer",
185
+ });
186
+ }
187
+ catch (error) {
188
+ // Not told after all: the parent still waits for it.
189
+ await untold(ports.storage, sessionID, toldAt).catch(() => undefined);
190
+ throw error;
191
+ }
192
+ return [entry.parentID];
193
+ }
44
194
  /** The session at the top of a lineage: where its permission requests go, since that is where the person is. */
45
195
  const topOf = (chain) => chain.at(-1).parentID;
46
196
  /**
@@ -70,9 +220,8 @@ export async function reportAsked(ports, state, event) {
70
220
  return [topOf(chain)];
71
221
  }
72
222
  /**
73
- * Tells the session that was told about a request that it has been answered some other way, in the
74
- * child's own session or along with another answer, so it does not pass on a stale question.
75
- * Requests answered through courier_answer are no longer waiting and are skipped.
223
+ * Tells the session told about a request that it was answered some other way, so it does not pass on
224
+ * a stale question; requests answered through courier_answer no longer wait and are skipped.
76
225
  */
77
226
  export async function reportReplied(ports, state, event) {
78
227
  if (!claim(state.seen, event.id))
@@ -87,7 +236,7 @@ export async function reportReplied(ports, state, event) {
87
236
  return [];
88
237
  await ports.session.synthetic({
89
238
  sessionID: topOf(chain),
90
- text: envelope(sessionID, settledNotice(chain[0].title, requestID, reply), { answered: reply, request: requestID }),
239
+ text: envelope(sessionID, permissionSettledNotice(chain[0].title, requestID, reply), { answered: reply, request: requestID }),
91
240
  description: `Session ${sessionID} no longer asks for permission`,
92
241
  metadata: { source: "courier", from: sessionID, answered: reply, requestID },
93
242
  delivery: "steer",
@@ -95,15 +244,74 @@ export async function reportReplied(ports, state, event) {
95
244
  return [topOf(chain)];
96
245
  }
97
246
  /**
98
- * Relays the requests that spawned sessions in this location already wait on, which the event
99
- * stream does not repeat: those asked while it was down, before the watcher (re)subscribed.
247
+ * Tells the top session about a form OpenCode shows in a spawned session, such as web search asking
248
+ * for its provider, which only the person can answer; question forms are left to the question relay.
249
+ */
250
+ export async function reportForm(ports, state, event) {
251
+ if (!claim(state.seen, event.id))
252
+ return [];
253
+ const form = event.data.form;
254
+ if (kindOf(form) === QUESTION_FORM)
255
+ return [];
256
+ const chain = await lineage(ports.storage, form.sessionID);
257
+ // As with a permission request, claimed after the lookup, so a form settled meanwhile is not told.
258
+ if (!chain.length || state.forms.settled.has(form.id) || state.forms.told.has(form.id))
259
+ return [];
260
+ const startedBy = chain.length > 1 ? chain[0].parentID : undefined;
261
+ const kind = kindOf(form);
262
+ const telling = ports.session.synthetic({
263
+ sessionID: topOf(chain),
264
+ text: envelope(form.sessionID, formNotice(chain[0].title, form, startedBy), {
265
+ asks: "form",
266
+ form: form.id,
267
+ ...(kind ? { kind } : {}),
268
+ }),
269
+ description: `Session ${form.sessionID} shows a form`,
270
+ metadata: { source: "courier", from: form.sessionID, asks: "form", formID: form.id, ...(kind ? { kind } : {}) },
271
+ delivery: "steer",
272
+ });
273
+ setBounded(state.forms.told, form.id, telling, SEEN_MAX);
274
+ await telling;
275
+ return [topOf(chain)];
276
+ }
277
+ /** 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. */
278
+ export async function reportFormSettled(ports, state, event) {
279
+ if (!claim(state.seen, event.id))
280
+ return [];
281
+ const { id, sessionID } = event.data;
282
+ const telling = state.forms.told.get(id);
283
+ if (!telling) {
284
+ claim(state.forms.settled, id);
285
+ return [];
286
+ }
287
+ state.forms.told.delete(id);
288
+ const settled = event.type === "form.replied" ? "answered" : "cancelled";
289
+ // After the notice that the form is shown, which another instance may still be sending, so the
290
+ // settling never arrives first; and not at all when that notice did not go out.
291
+ const [chain, told] = await Promise.all([lineage(ports.storage, sessionID), telling.then(() => true, () => false)]);
292
+ if (!chain.length || !told)
293
+ return [];
294
+ await ports.session.synthetic({
295
+ sessionID: topOf(chain),
296
+ text: envelope(sessionID, formSettledNotice(chain[0].title, id, settled), { settled, form: id }),
297
+ description: `Session ${sessionID} no longer shows a form`,
298
+ metadata: { source: "courier", from: sessionID, settled, formID: id },
299
+ delivery: "steer",
300
+ });
301
+ return [topOf(chain)];
302
+ }
303
+ /**
304
+ * Relays the requests that spawned sessions already wait on, in every loaded location, which the
305
+ * event stream does not repeat: those asked while it was down, before the watcher (re)subscribed.
100
306
  */
101
307
  export async function relayPending(ports, state) {
102
308
  const sessions = new Set((await allEntries(ports.storage)).map((entry) => entry.sessionID));
103
- // Each request is claimed before its notice goes out, so relaying them all at once tells each once.
309
+ const domains = [...ports.permissions()];
310
+ // Each request is claimed before its notice goes out, so relaying them all at once tells each
311
+ // once, however many locations list it; a location that cannot be read lists none.
104
312
  const relay = async (sessionID) => {
105
- const requests = await ports.permission.list({ sessionID }).catch(() => []);
106
- await Promise.all(requests.map((request) => reportAsked(ports, state, { id: `pending:${request.id}`, data: request })));
313
+ const listed = (await listEverywhere(domains, sessionID)).flatMap((found) => found.requests);
314
+ await Promise.all(listed.map((request) => reportAsked(ports, state, { id: `pending:${request.id}`, data: request })));
107
315
  };
108
316
  await Promise.all([...sessions].map(relay));
109
317
  }
@@ -116,53 +324,126 @@ const pause = (ms, signal) => new Promise((resolve) => {
116
324
  }
117
325
  signal.addEventListener("abort", done);
118
326
  });
327
+ const TURN_ENDS = new Set(["session.execution.succeeded", "session.execution.failed", "session.execution.interrupted"]);
328
+ /** A session's turn ended, sealing its groups: one that is complete has the scheduler deliver it now. */
329
+ async function sealGroups(ports, event) {
330
+ if (event.data.reason === "shutdown")
331
+ return;
332
+ try {
333
+ if (await hasCompleteGroup(ports.storage, event.data.sessionID))
334
+ ports.nudge();
335
+ }
336
+ catch (error) {
337
+ ports.log(`courier watch: could not read the groups of ${event.data.sessionID}: ${String(error)}`);
338
+ }
339
+ }
119
340
  /**
120
341
  * Handles one event of OpenCode's stream; those that cannot concern a spawned child, or a question
121
342
  * relayed for one, are ignored.
122
343
  */
123
344
  async function handle(ports, state, event) {
345
+ try {
346
+ return await route(ports, state, event);
347
+ }
348
+ finally {
349
+ // After: a silent end is judged while the group the nudge may deliver is still there to wait on.
350
+ if (TURN_ENDS.has(event.type))
351
+ await sealGroups(ports, event);
352
+ }
353
+ }
354
+ async function route(ports, state, event) {
124
355
  if (event.type === "session.execution.failed")
125
356
  return reportFailure(ports, state.seen, event);
357
+ if (event.type === "session.execution.succeeded")
358
+ return reportSilent(ports, state.seen, event);
359
+ if (event.type === "session.execution.interrupted")
360
+ return noteInterrupted(ports, event);
361
+ if (event.type === "session.inbox.enqueued")
362
+ return noteEnqueued(ports, state.inbox, event);
363
+ if (event.type === "session.inbox.delivered")
364
+ return notePrompt(ports, event, state.inbox);
365
+ if (event.type === "session.deleted")
366
+ return noteDeleted(ports, event);
126
367
  if (event.type === "permission.asked")
127
368
  return reportAsked(ports, state, event);
128
369
  if (event.type === "permission.replied")
129
370
  return reportReplied(ports, state, event);
130
- // Not claimed: every instance may resolve the same waiting call, which is harmless.
131
- if (event.type === "form.created")
371
+ if (event.type === "form.created") {
372
+ // Not claimed: the hub's other subscription, or another copy's watcher, may resolve the same
373
+ // waiting call, which is harmless.
132
374
  formShown(event);
375
+ return reportForm(ports, state, event);
376
+ }
377
+ if (event.type === "form.replied" || event.type === "form.cancelled")
378
+ return reportFormSettled(ports, state, event);
133
379
  // Not claimed either: a location closing withdraws its forms, which must not pass for dismissals.
134
380
  // The event's location is optional; without one, the shutdown counts for every location.
135
381
  if (event.type === "location.shutdown") {
136
382
  const directory = event.location?.directory;
137
- locationClosing(typeof directory === "string" ? directory : undefined);
383
+ locationClosing(ports.now(), typeof directory === "string" ? directory : undefined);
138
384
  }
139
385
  return [];
140
386
  }
387
+ const ENDED = { done: true, value: undefined };
141
388
  /**
142
- * Follows OpenCode's events until `signal` aborts, telling parents when a spawned child's turn
143
- * fails, when it waits for a permission and when that request is answered without them, and
144
- * noting for the question relay the question forms shown, which it waits for, and the locations
145
- * shutting down, whose withdrawn forms must not pass for dismissals.
389
+ * `events` until it ends or `signal` aborts, the stream left open; each wait hears the abort only while
390
+ * it waits, so a watcher that follows events for the life of the process holds nothing per event.
391
+ */
392
+ const until = (events, signal) => ({
393
+ [Symbol.asyncIterator]: () => ({
394
+ next: () => new Promise((resolve, reject) => {
395
+ if (signal.aborted)
396
+ return resolve(ENDED);
397
+ const stop = () => resolve(ENDED);
398
+ signal.addEventListener("abort", stop, { once: true });
399
+ // Once the signal has aborted, whatever the stream ends this wait with is not wanted.
400
+ events.next().then((next) => {
401
+ signal.removeEventListener("abort", stop);
402
+ resolve(next);
403
+ }, (error) => {
404
+ signal.removeEventListener("abort", stop);
405
+ reject(error);
406
+ });
407
+ }),
408
+ }),
409
+ });
410
+ /** Relays the requests already pending, kept in `relaying` until done, for the watcher's end to wait for. */
411
+ function relayAlongside(ports, state, relaying) {
412
+ const relayed = relayPending(ports, state)
413
+ .catch((error) => ports.log(`courier watch: could not relay pending requests: ${String(error)}`))
414
+ .finally(() => relaying.delete(relayed));
415
+ relaying.add(relayed);
416
+ }
417
+ /** Handles one event, logging a failure rather than ending the watch. */
418
+ function handleLogged(ports, state, event) {
419
+ return handle(ports, state, event).catch((error) => {
420
+ const data = event.data;
421
+ const sessionID = data?.sessionID ?? data?.form?.sessionID;
422
+ ports.log(`courier watch: could not handle ${event.type} of ${sessionID}: ${String(error)}`);
423
+ });
424
+ }
425
+ /**
426
+ * Follows OpenCode's events until `signal` aborts: tells parents of a spawned child's failed or silent
427
+ * turn, its pending permission or form, notes forms and shutdowns for the relay; ends after its current event.
146
428
  */
147
429
  export async function watchChildren(ports, state, signal, retryMs = RESUBSCRIBE_MS) {
430
+ const relaying = new Set();
148
431
  while (!signal.aborted) {
149
432
  let following = false;
433
+ let events;
150
434
  try {
151
- const events = ports.event.subscribe({ signal });
435
+ events = ports.event.subscribe({ signal })[Symbol.asyncIterator]();
152
436
  // Question forms shown while no instance followed the events were not seen: released now,
153
437
  // and again on the first event, by when the stream is surely connected.
154
438
  let missed = eventsFollowed();
155
439
  following = true;
156
440
  // Alongside the new subscription; a request both relays see is relayed once.
157
- void relayPending(ports, state).catch((error) => ports.log(`courier watch: could not relay pending requests: ${String(error)}`));
158
- for await (const event of events) {
441
+ relayAlongside(ports, state, relaying);
442
+ for await (const event of until(events, signal)) {
159
443
  if (missed)
160
444
  formsMayHaveBeenMissed();
161
445
  missed = false;
162
- await handle(ports, state, event).catch((error) => {
163
- const sessionID = event.data?.sessionID;
164
- ports.log(`courier watch: could not handle ${event.type} of ${sessionID}: ${String(error)}`);
165
- });
446
+ await handleLogged(ports, state, event);
166
447
  }
167
448
  }
168
449
  catch (error) {
@@ -172,8 +453,22 @@ export async function watchChildren(ports, state, signal, retryMs = RESUBSCRIBE_
172
453
  finally {
173
454
  if (following)
174
455
  eventsLeft();
456
+ // Closed without waiting: closing the adapter's stream waits on OpenCode, which may not answer
457
+ // while a location closes, and the leave waiting for this watcher must not wait for that too.
458
+ void events?.return?.().catch(() => { });
175
459
  }
176
460
  if (!signal.aborted)
177
461
  await pause(retryMs, signal);
178
462
  }
463
+ await Promise.allSettled(relaying);
464
+ }
465
+ /**
466
+ * Makes the hub start its subscriptions to OpenCode's events with this copy's watcher, the one loaded
467
+ * last; `join` starts them. The second is a standby, connected before the first's member leaves.
468
+ */
469
+ export function watchFromHub(hub, state, retryMs = RESUBSCRIBE_MS) {
470
+ hub.subscribe = (member) => {
471
+ const stop = new AbortController();
472
+ return { member, stop, done: watchChildren(member.watch, state, stop.signal, retryMs) };
473
+ };
179
474
  }