opencode-courier 0.2.1 → 0.2.3

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 (46) hide show
  1. package/README.md +139 -94
  2. package/dist/bounded.d.ts +10 -0
  3. package/dist/bounded.js +21 -0
  4. package/dist/cleanup.d.ts +13 -18
  5. package/dist/cleanup.js +38 -19
  6. package/dist/courier.d.ts +1 -5
  7. package/dist/courier.js +11 -23
  8. package/dist/hub.d.ts +325 -0
  9. package/dist/hub.js +340 -0
  10. package/dist/index.d.ts +4 -8
  11. package/dist/index.js +72 -310
  12. package/dist/json.d.ts +4 -0
  13. package/dist/json.js +4 -0
  14. package/dist/later.d.ts +1 -1
  15. package/dist/later.js +14 -5
  16. package/dist/notices.d.ts +246 -0
  17. package/dist/notices.js +399 -0
  18. package/dist/question/answer.d.ts +35 -0
  19. package/dist/question/answer.js +217 -0
  20. package/dist/question/index.d.ts +6 -0
  21. package/dist/question/index.js +7 -0
  22. package/dist/question/lifecycle.d.ts +52 -0
  23. package/dist/question/lifecycle.js +100 -0
  24. package/dist/question/pure.d.ts +49 -0
  25. package/dist/question/pure.js +75 -0
  26. package/dist/question/relay.d.ts +5 -0
  27. package/dist/question/relay.js +283 -0
  28. package/dist/question/runtime.d.ts +21 -0
  29. package/dist/question/runtime.js +75 -0
  30. package/dist/question/shared.d.ts +24 -0
  31. package/dist/question/shared.js +24 -0
  32. package/dist/relay.d.ts +31 -33
  33. package/dist/relay.js +5 -53
  34. package/dist/roster.d.ts +29 -1
  35. package/dist/roster.js +101 -9
  36. package/dist/storage.d.ts +12 -0
  37. package/dist/storage.js +16 -3
  38. package/dist/tools.d.ts +74 -0
  39. package/dist/tools.js +228 -0
  40. package/dist/watch.d.ts +28 -37
  41. package/dist/watch.js +158 -46
  42. package/dist/webhook.d.ts +1 -13
  43. package/dist/webhook.js +121 -177
  44. package/package.json +16 -5
  45. package/dist/question.d.ts +0 -158
  46. package/dist/question.js +0 -693
package/dist/watch.js CHANGED
@@ -1,28 +1,14 @@
1
- import { envelope } from "./courier.js";
2
- import { eventsFollowed, eventsLeft, formShown, formsMayHaveBeenMissed, locationClosing } from "./question.js";
3
- import { permissionNotice, settledNotice } from "./relay.js";
1
+ import { addBounded, setBounded } from "./bounded.js";
2
+ import { envelope, failureNotice, formNotice, formSettledNotice, kindOf, permissionNotice, permissionSettledNotice, } from "./notices.js";
3
+ import { eventsFollowed, eventsLeft, formShown, formsMayHaveBeenMissed, locationClosing } from "./question/index.js";
4
+ import { listEverywhere, QUESTION_FORM, } from "./relay.js";
4
5
  import { allEntries, entriesOf, lineage } from "./roster.js";
5
6
  /** How long to wait before subscribing again after the event stream ended or broke. */
6
7
  export const RESUBSCRIBE_MS = 5_000;
7
8
  /** How many handled event ids, and waiting or answered requests, are remembered. */
8
9
  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
- }
10
+ /** Claims a value in one of the shared sets; false when it was claimed already. */
11
+ const claim = (set, value) => addBounded(set, value, SEEN_MAX);
26
12
  /**
27
13
  * Tells the parent of a child whose turn failed, since a child that cannot reach its model cannot
28
14
  * call courier_send. Returns the parents told.
@@ -32,14 +18,13 @@ export async function reportFailure(ports, seen, event) {
32
18
  return [];
33
19
  const { sessionID, error } = event.data;
34
20
  const entries = await entriesOf(ports.storage, sessionID);
35
- for (const entry of entries)
36
- await ports.session.synthetic({
37
- sessionID: entry.parentID,
38
- text: envelope(sessionID, failureNotice(entry.title, error), { failed: error.type }),
39
- description: `Session ${sessionID} failed`,
40
- metadata: { source: "courier", from: sessionID, failed: true },
41
- delivery: "steer",
42
- });
21
+ await Promise.all(entries.map((entry) => ports.session.synthetic({
22
+ sessionID: entry.parentID,
23
+ text: envelope(sessionID, failureNotice(entry.title, error), { failed: error.type }),
24
+ description: `Session ${sessionID} failed`,
25
+ metadata: { source: "courier", from: sessionID, failed: true },
26
+ delivery: "steer",
27
+ })));
43
28
  return entries.map((entry) => entry.parentID);
44
29
  }
45
30
  /** The session at the top of a lineage: where its permission requests go, since that is where the person is. */
@@ -88,7 +73,7 @@ export async function reportReplied(ports, state, event) {
88
73
  return [];
89
74
  await ports.session.synthetic({
90
75
  sessionID: topOf(chain),
91
- text: envelope(sessionID, settledNotice(chain[0].title, requestID, reply), { answered: reply, request: requestID }),
76
+ text: envelope(sessionID, permissionSettledNotice(chain[0].title, requestID, reply), { answered: reply, request: requestID }),
92
77
  description: `Session ${sessionID} no longer asks for permission`,
93
78
  metadata: { source: "courier", from: sessionID, answered: reply, requestID },
94
79
  delivery: "steer",
@@ -96,14 +81,77 @@ export async function reportReplied(ports, state, event) {
96
81
  return [topOf(chain)];
97
82
  }
98
83
  /**
99
- * Relays the requests that spawned sessions in this location already wait on, which the event
100
- * stream does not repeat: those asked while it was down, before the watcher (re)subscribed.
84
+ * Tells the session at the top about a form OpenCode shows in a spawned session, such as web
85
+ * search asking for its provider, which only the person can answer there. Question forms are
86
+ * left to the question relay, which passes every question of a spawned session on.
87
+ */
88
+ export async function reportForm(ports, state, event) {
89
+ if (!claim(state.seen, event.id))
90
+ return [];
91
+ const form = event.data.form;
92
+ if (kindOf(form) === QUESTION_FORM)
93
+ return [];
94
+ const chain = await lineage(ports.storage, form.sessionID);
95
+ // As with a permission request, claimed after the lookup, so a form settled meanwhile is not told.
96
+ if (!chain.length || state.forms.settled.has(form.id) || state.forms.told.has(form.id))
97
+ return [];
98
+ const startedBy = chain.length > 1 ? chain[0].parentID : undefined;
99
+ const kind = kindOf(form);
100
+ const telling = ports.session.synthetic({
101
+ sessionID: topOf(chain),
102
+ text: envelope(form.sessionID, formNotice(chain[0].title, form, startedBy), {
103
+ asks: "form",
104
+ form: form.id,
105
+ ...(kind ? { kind } : {}),
106
+ }),
107
+ description: `Session ${form.sessionID} shows a form`,
108
+ metadata: { source: "courier", from: form.sessionID, asks: "form", formID: form.id, ...(kind ? { kind } : {}) },
109
+ delivery: "steer",
110
+ });
111
+ setBounded(state.forms.told, form.id, telling, SEEN_MAX);
112
+ await telling;
113
+ return [topOf(chain)];
114
+ }
115
+ /** 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. */
116
+ export async function reportFormSettled(ports, state, event) {
117
+ if (!claim(state.seen, event.id))
118
+ return [];
119
+ const { id, sessionID } = event.data;
120
+ const telling = state.forms.told.get(id);
121
+ if (!telling) {
122
+ claim(state.forms.settled, id);
123
+ return [];
124
+ }
125
+ state.forms.told.delete(id);
126
+ const settled = event.type === "form.replied" ? "answered" : "cancelled";
127
+ // After the notice that the form is shown, which another instance may still be sending, so the
128
+ // settling never arrives first; and not at all when that notice did not go out.
129
+ const [chain, told] = await Promise.all([lineage(ports.storage, sessionID), telling.then(() => true, () => false)]);
130
+ if (!chain.length || !told)
131
+ return [];
132
+ await ports.session.synthetic({
133
+ sessionID: topOf(chain),
134
+ text: envelope(sessionID, formSettledNotice(chain[0].title, id, settled), { settled, form: id }),
135
+ description: `Session ${sessionID} no longer shows a form`,
136
+ metadata: { source: "courier", from: sessionID, settled, formID: id },
137
+ delivery: "steer",
138
+ });
139
+ return [topOf(chain)];
140
+ }
141
+ /**
142
+ * Relays the requests that spawned sessions already wait on, in every loaded location, which the
143
+ * event stream does not repeat: those asked while it was down, before the watcher (re)subscribed.
101
144
  */
102
145
  export async function relayPending(ports, state) {
103
146
  const sessions = new Set((await allEntries(ports.storage)).map((entry) => entry.sessionID));
104
- for (const sessionID of sessions)
105
- for (const request of await ports.permission.list({ sessionID }).catch(() => []))
106
- await reportAsked(ports, state, { id: `pending:${request.id}`, data: request });
147
+ const domains = [...ports.permissions()];
148
+ // Each request is claimed before its notice goes out, so relaying them all at once tells each
149
+ // once, however many locations list it; a location that cannot be read lists none.
150
+ const relay = async (sessionID) => {
151
+ const listed = (await listEverywhere(domains, sessionID)).flatMap((found) => found.requests);
152
+ await Promise.all(listed.map((request) => reportAsked(ports, state, { id: `pending:${request.id}`, data: request })));
153
+ };
154
+ await Promise.all([...sessions].map(relay));
107
155
  }
108
156
  const pause = (ms, signal) => new Promise((resolve) => {
109
157
  const timer = setTimeout(done, ms);
@@ -125,42 +173,87 @@ async function handle(ports, state, event) {
125
173
  return reportAsked(ports, state, event);
126
174
  if (event.type === "permission.replied")
127
175
  return reportReplied(ports, state, event);
128
- // Not claimed: every instance may resolve the same waiting call, which is harmless.
129
- if (event.type === "form.created")
176
+ if (event.type === "form.created") {
177
+ // Not claimed: the hub's other subscription, or another copy's watcher, may resolve the same
178
+ // waiting call, which is harmless.
130
179
  formShown(event);
180
+ return reportForm(ports, state, event);
181
+ }
182
+ if (event.type === "form.replied" || event.type === "form.cancelled")
183
+ return reportFormSettled(ports, state, event);
131
184
  // Not claimed either: a location closing withdraws its forms, which must not pass for dismissals.
132
185
  // The event's location is optional; without one, the shutdown counts for every location.
133
186
  if (event.type === "location.shutdown") {
134
187
  const directory = event.location?.directory;
135
- locationClosing(typeof directory === "string" ? directory : undefined);
188
+ locationClosing(ports.now(), typeof directory === "string" ? directory : undefined);
136
189
  }
137
190
  return [];
138
191
  }
192
+ const ENDED = { done: true, value: undefined };
193
+ /**
194
+ * The events of `events` until it ends or `signal` aborts, whichever comes first; the stream is not
195
+ * closed. Each wait for the next event listens for the abort only while it waits, so a watcher that
196
+ * follows events for the life of the process holds nothing per event it has seen.
197
+ */
198
+ const until = (events, signal) => ({
199
+ [Symbol.asyncIterator]: () => ({
200
+ next: () => new Promise((resolve, reject) => {
201
+ if (signal.aborted)
202
+ return resolve(ENDED);
203
+ const stop = () => resolve(ENDED);
204
+ signal.addEventListener("abort", stop, { once: true });
205
+ // Once the signal has aborted, whatever the stream ends this wait with is not wanted.
206
+ events.next().then((next) => {
207
+ signal.removeEventListener("abort", stop);
208
+ resolve(next);
209
+ }, (error) => {
210
+ signal.removeEventListener("abort", stop);
211
+ reject(error);
212
+ });
213
+ }),
214
+ }),
215
+ });
216
+ /** Relays the requests already pending, kept in `relaying` until done, for the watcher's end to wait for. */
217
+ function relayAlongside(ports, state, relaying) {
218
+ const relayed = relayPending(ports, state)
219
+ .catch((error) => ports.log(`courier watch: could not relay pending requests: ${String(error)}`))
220
+ .finally(() => relaying.delete(relayed));
221
+ relaying.add(relayed);
222
+ }
223
+ /** Handles one event, logging a failure rather than ending the watch. */
224
+ function handleLogged(ports, state, event) {
225
+ return handle(ports, state, event).catch((error) => {
226
+ const data = event.data;
227
+ const sessionID = data?.sessionID ?? data?.form?.sessionID;
228
+ ports.log(`courier watch: could not handle ${event.type} of ${sessionID}: ${String(error)}`);
229
+ });
230
+ }
139
231
  /**
140
232
  * Follows OpenCode's events until `signal` aborts, telling parents when a spawned child's turn
141
- * fails, when it waits for a permission and when that request is answered without them, and
233
+ * fails, when it waits for a permission or on a form and when that is answered without them, and
142
234
  * noting for the question relay the question forms shown, which it waits for, and the locations
143
- * shutting down, whose withdrawn forms must not pass for dismissals.
235
+ * shutting down, whose withdrawn forms must not pass for dismissals. Once `signal` aborts it ends
236
+ * as soon as the event it is handling, and the relay of the requests already pending, are done,
237
+ * without waiting for the stream to close: an unloading instance's leave waits for that.
144
238
  */
145
239
  export async function watchChildren(ports, state, signal, retryMs = RESUBSCRIBE_MS) {
240
+ const relaying = new Set();
146
241
  while (!signal.aborted) {
147
242
  let following = false;
243
+ let events;
148
244
  try {
149
- const events = ports.event.subscribe({ signal });
245
+ events = ports.event.subscribe({ signal })[Symbol.asyncIterator]();
150
246
  // Question forms shown while no instance followed the events were not seen: released now,
151
247
  // and again on the first event, by when the stream is surely connected.
152
248
  let missed = eventsFollowed();
153
249
  following = true;
154
250
  // Alongside the new subscription; a request both relays see is relayed once.
155
- void relayPending(ports, state).catch((error) => ports.log(`courier watch: could not relay pending requests: ${String(error)}`));
156
- for await (const event of events) {
251
+ relayAlongside(ports, state, relaying);
252
+ for await (const event of until(events, signal)) {
157
253
  if (missed)
158
254
  formsMayHaveBeenMissed();
159
255
  missed = false;
160
- await handle(ports, state, event).catch((error) => {
161
- const sessionID = event.data?.sessionID;
162
- ports.log(`courier watch: could not handle ${event.type} of ${sessionID}: ${String(error)}`);
163
- });
256
+ await handleLogged(ports, state, event);
164
257
  }
165
258
  }
166
259
  catch (error) {
@@ -170,8 +263,27 @@ export async function watchChildren(ports, state, signal, retryMs = RESUBSCRIBE_
170
263
  finally {
171
264
  if (following)
172
265
  eventsLeft();
266
+ // Closed without waiting: the subscription's own signal has aborted, or the stream ended, and
267
+ // closing the adapter's stream waits on OpenCode's side, which may not answer while a location
268
+ // closes; the leave that waits for this watcher must not wait for that too.
269
+ void events?.return?.().catch(() => { });
173
270
  }
174
271
  if (!signal.aborted)
175
272
  await pause(retryMs, signal);
176
273
  }
274
+ await Promise.allSettled(relaying);
275
+ }
276
+ /**
277
+ * Makes the hub start its subscriptions to OpenCode's events with this copy's watcher, the copy loaded
278
+ * last's, as after an update; `join` starts them. Every instance in the process is sent every event,
279
+ * so one subscription would serve them all; the second is a standby, already connected when the
280
+ * first one's member leaves, so no event goes unseen then. Both handle every event, and the claim
281
+ * sets tell each once. Like any (re)subscription, a new one relays the requests already pending,
282
+ * which the claim sets tell once too.
283
+ */
284
+ export function watchFromHub(hub, state, retryMs = RESUBSCRIBE_MS) {
285
+ hub.subscribe = (member) => {
286
+ const stop = new AbortController();
287
+ return { member, stop, done: watchChildren(member.watch, state, stop.signal, retryMs) };
288
+ };
177
289
  }
package/dist/webhook.d.ts CHANGED
@@ -48,7 +48,6 @@ export interface Event {
48
48
  readonly topics: readonly string[];
49
49
  readonly summary: string;
50
50
  }
51
- export declare function clip(text: string, max?: number): string;
52
51
  /**
53
52
  * Maps a GitHub delivery to its topics (`github:owner/repo` and, for pull requests and issues,
54
53
  * `github:owner/repo#N`) and a summary. Undefined for deliveries nobody should be woken for: pings,
@@ -57,8 +56,6 @@ export declare function clip(text: string, max?: number): string;
57
56
  export declare function githubEvent(name: string, payload: unknown): Event | undefined;
58
57
  /** A delivery to `/hook/<topic>`: a JSON body's `text`, `summary` or `message` field, else the body itself. */
59
58
  export declare function genericEvent(topic: string, body: string): Event;
60
- /** Defuses `<courier` and `</courier>` in outside text, so it cannot close the envelope or forge another. */
61
- export declare function defuse(text: string): string;
62
59
  /**
63
60
  * Delivers an event to every session subscribed to one of its topics, once per session. A session
64
61
  * OpenCode no longer knows loses its subscriptions.
@@ -77,21 +74,12 @@ export interface Response {
77
74
  readonly status: number;
78
75
  readonly body: string;
79
76
  }
80
- /** Digests of accepted deliveries, newest last; shared by every receiver in the process. */
81
- export declare class Seen {
82
- private readonly limit;
83
- private readonly items;
84
- constructor(limit?: number);
85
- has(digest: string): boolean;
86
- add(digest: string): void;
87
- delete(digest: string): void;
88
- }
89
77
  /**
90
78
  * Handles one request: `POST /github` with GitHub's headers, or `POST /hook/<name>` signed over
91
79
  * the name and the body. Anything not signed with the secret is refused before its body is parsed,
92
80
  * and a signature already accepted is ignored, so a captured delivery cannot be replayed.
93
81
  */
94
- export declare function receive(ports: WebhookPorts, secret: string, request: Request, seen?: Seen): Promise<Response>;
82
+ export declare function receive(ports: WebhookPorts, secret: string, request: Request, seen?: Set<string>): Promise<Response>;
95
83
  /** Starts the HTTP receiver; `ports()` picks a live plugin instance for each request. */
96
84
  export declare function listen(config: WebhookConfig, ports: () => WebhookPorts | undefined): Promise<Server<typeof IncomingMessage, typeof import("node:http").ServerResponse>>;
97
85
  /**