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.
- package/README.md +139 -94
- package/dist/bounded.d.ts +10 -0
- package/dist/bounded.js +21 -0
- package/dist/cleanup.d.ts +13 -18
- package/dist/cleanup.js +38 -19
- package/dist/courier.d.ts +1 -5
- package/dist/courier.js +11 -23
- package/dist/hub.d.ts +325 -0
- package/dist/hub.js +340 -0
- package/dist/index.d.ts +4 -8
- package/dist/index.js +72 -310
- package/dist/json.d.ts +4 -0
- package/dist/json.js +4 -0
- package/dist/later.d.ts +1 -1
- package/dist/later.js +14 -5
- package/dist/notices.d.ts +246 -0
- package/dist/notices.js +399 -0
- package/dist/question/answer.d.ts +35 -0
- package/dist/question/answer.js +217 -0
- package/dist/question/index.d.ts +6 -0
- package/dist/question/index.js +7 -0
- package/dist/question/lifecycle.d.ts +52 -0
- package/dist/question/lifecycle.js +100 -0
- package/dist/question/pure.d.ts +49 -0
- package/dist/question/pure.js +75 -0
- package/dist/question/relay.d.ts +5 -0
- package/dist/question/relay.js +283 -0
- package/dist/question/runtime.d.ts +21 -0
- package/dist/question/runtime.js +75 -0
- package/dist/question/shared.d.ts +24 -0
- package/dist/question/shared.js +24 -0
- package/dist/relay.d.ts +31 -33
- package/dist/relay.js +5 -53
- package/dist/roster.d.ts +29 -1
- package/dist/roster.js +101 -9
- package/dist/storage.d.ts +12 -0
- package/dist/storage.js +16 -3
- package/dist/tools.d.ts +74 -0
- package/dist/tools.js +228 -0
- package/dist/watch.d.ts +28 -37
- package/dist/watch.js +158 -46
- package/dist/webhook.d.ts +1 -13
- package/dist/webhook.js +121 -177
- package/package.json +16 -5
- package/dist/question.d.ts +0 -158
- package/dist/question.js +0 -693
package/dist/watch.js
CHANGED
|
@@ -1,28 +1,14 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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,
|
|
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
|
-
*
|
|
100
|
-
*
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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?:
|
|
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
|
/**
|