opencode-courier 0.2.2 → 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 +130 -95
- 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 +1 -20
- package/dist/hub.d.ts +325 -0
- package/dist/hub.js +340 -0
- package/dist/index.d.ts +4 -8
- package/dist/index.js +70 -308
- package/dist/json.d.ts +4 -0
- package/dist/json.js +4 -0
- package/dist/later.d.ts +1 -1
- package/dist/later.js +2 -2
- 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 +8 -1
- package/dist/storage.js +8 -5
- package/dist/tools.d.ts +74 -0
- package/dist/tools.js +228 -0
- package/dist/watch.d.ts +28 -37
- package/dist/watch.js +148 -38
- package/dist/webhook.d.ts +1 -13
- package/dist/webhook.js +36 -127
- package/package.json +16 -5
- package/dist/question.d.ts +0 -158
- package/dist/question.js +0 -768
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.
|
|
@@ -87,7 +73,7 @@ export async function reportReplied(ports, state, event) {
|
|
|
87
73
|
return [];
|
|
88
74
|
await ports.session.synthetic({
|
|
89
75
|
sessionID: topOf(chain),
|
|
90
|
-
text: envelope(sessionID,
|
|
76
|
+
text: envelope(sessionID, permissionSettledNotice(chain[0].title, requestID, reply), { answered: reply, request: requestID }),
|
|
91
77
|
description: `Session ${sessionID} no longer asks for permission`,
|
|
92
78
|
metadata: { source: "courier", from: sessionID, answered: reply, requestID },
|
|
93
79
|
delivery: "steer",
|
|
@@ -95,15 +81,75 @@ export async function reportReplied(ports, state, event) {
|
|
|
95
81
|
return [topOf(chain)];
|
|
96
82
|
}
|
|
97
83
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
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.
|
|
100
144
|
*/
|
|
101
145
|
export async function relayPending(ports, state) {
|
|
102
146
|
const sessions = new Set((await allEntries(ports.storage)).map((entry) => entry.sessionID));
|
|
103
|
-
|
|
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.
|
|
104
150
|
const relay = async (sessionID) => {
|
|
105
|
-
const
|
|
106
|
-
await Promise.all(
|
|
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 })));
|
|
107
153
|
};
|
|
108
154
|
await Promise.all([...sessions].map(relay));
|
|
109
155
|
}
|
|
@@ -127,42 +173,87 @@ async function handle(ports, state, event) {
|
|
|
127
173
|
return reportAsked(ports, state, event);
|
|
128
174
|
if (event.type === "permission.replied")
|
|
129
175
|
return reportReplied(ports, state, event);
|
|
130
|
-
|
|
131
|
-
|
|
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.
|
|
132
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);
|
|
133
184
|
// Not claimed either: a location closing withdraws its forms, which must not pass for dismissals.
|
|
134
185
|
// The event's location is optional; without one, the shutdown counts for every location.
|
|
135
186
|
if (event.type === "location.shutdown") {
|
|
136
187
|
const directory = event.location?.directory;
|
|
137
|
-
locationClosing(typeof directory === "string" ? directory : undefined);
|
|
188
|
+
locationClosing(ports.now(), typeof directory === "string" ? directory : undefined);
|
|
138
189
|
}
|
|
139
190
|
return [];
|
|
140
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
|
+
}
|
|
141
231
|
/**
|
|
142
232
|
* 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
|
|
233
|
+
* fails, when it waits for a permission or on a form and when that is answered without them, and
|
|
144
234
|
* 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.
|
|
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.
|
|
146
238
|
*/
|
|
147
239
|
export async function watchChildren(ports, state, signal, retryMs = RESUBSCRIBE_MS) {
|
|
240
|
+
const relaying = new Set();
|
|
148
241
|
while (!signal.aborted) {
|
|
149
242
|
let following = false;
|
|
243
|
+
let events;
|
|
150
244
|
try {
|
|
151
|
-
|
|
245
|
+
events = ports.event.subscribe({ signal })[Symbol.asyncIterator]();
|
|
152
246
|
// Question forms shown while no instance followed the events were not seen: released now,
|
|
153
247
|
// and again on the first event, by when the stream is surely connected.
|
|
154
248
|
let missed = eventsFollowed();
|
|
155
249
|
following = true;
|
|
156
250
|
// Alongside the new subscription; a request both relays see is relayed once.
|
|
157
|
-
|
|
158
|
-
for await (const event of events) {
|
|
251
|
+
relayAlongside(ports, state, relaying);
|
|
252
|
+
for await (const event of until(events, signal)) {
|
|
159
253
|
if (missed)
|
|
160
254
|
formsMayHaveBeenMissed();
|
|
161
255
|
missed = false;
|
|
162
|
-
await
|
|
163
|
-
const sessionID = event.data?.sessionID;
|
|
164
|
-
ports.log(`courier watch: could not handle ${event.type} of ${sessionID}: ${String(error)}`);
|
|
165
|
-
});
|
|
256
|
+
await handleLogged(ports, state, event);
|
|
166
257
|
}
|
|
167
258
|
}
|
|
168
259
|
catch (error) {
|
|
@@ -172,8 +263,27 @@ export async function watchChildren(ports, state, signal, retryMs = RESUBSCRIBE_
|
|
|
172
263
|
finally {
|
|
173
264
|
if (following)
|
|
174
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(() => { });
|
|
175
270
|
}
|
|
176
271
|
if (!signal.aborted)
|
|
177
272
|
await pause(retryMs, signal);
|
|
178
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
|
+
};
|
|
179
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
|
/**
|
package/dist/webhook.js
CHANGED
|
@@ -2,13 +2,13 @@ import { createHmac, timingSafeEqual } from "node:crypto";
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { createServer } from "node:http";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
-
import {
|
|
5
|
+
import { addBounded } from "./bounded.js";
|
|
6
|
+
import { num, obj, str } from "./json.js";
|
|
7
|
+
import { CI_EVENTS, envelope, githubSummary, hookSummary, webhookText } from "./notices.js";
|
|
6
8
|
import { scanAll } from "./storage.js";
|
|
7
9
|
const PREFIX = "webhook/";
|
|
8
10
|
/** Bodies above this are refused; GitHub's own deliveries are capped at 25 MB, real ones are far smaller. */
|
|
9
11
|
export const DEFAULT_MAX_BYTES = 1024 * 1024;
|
|
10
|
-
/** Longest piece of free text (a review body, a generic payload) copied into a delivered message. */
|
|
11
|
-
const MAX_TEXT = 1500;
|
|
12
12
|
/** How many accepted signatures are remembered, so a captured delivery cannot be replayed. */
|
|
13
13
|
const REMEMBERED = 1000;
|
|
14
14
|
const GITHUB_REPO = /^[\w.-]+\/[\w.-]+$/;
|
|
@@ -85,99 +85,29 @@ export function checkSignature(secret, body, header, name) {
|
|
|
85
85
|
export function sign(secret, body, name) {
|
|
86
86
|
return `sha256=${hmac(secret, body, name).toString("hex")}`;
|
|
87
87
|
}
|
|
88
|
-
const str = (value) => (typeof value === "string" ? value : undefined);
|
|
89
|
-
const num = (value) => (typeof value === "number" && Number.isInteger(value) ? value : undefined);
|
|
90
|
-
const obj = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
91
|
-
export function clip(text, max = MAX_TEXT) {
|
|
92
|
-
return text.length > max ? `${text.slice(0, max)}… [${text.length - max} more characters]` : text;
|
|
93
|
-
}
|
|
94
|
-
const CI_EVENTS = { check_run: "check run", check_suite: "check suite", workflow_run: "workflow run" };
|
|
95
88
|
/** Pull request and issue actions worth a wake-up; edits, labels, assignments and pushes to the branch are not. */
|
|
96
89
|
const ITEM_ACTIONS = new Set(["opened", "reopened", "closed", "ready_for_review"]);
|
|
97
|
-
/** The
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const numbers = new Set();
|
|
109
|
-
for (const item of Array.isArray(run.pull_requests) ? run.pull_requests : []) {
|
|
110
|
-
const n = num(obj(item).number);
|
|
111
|
-
if (n !== undefined)
|
|
112
|
-
numbers.add(n);
|
|
90
|
+
/** The pull request and issue numbers a GitHub delivery concerns, each with a topic of its own. */
|
|
91
|
+
function numbersOf(name, body) {
|
|
92
|
+
if (Object.hasOwn(CI_EVENTS, name)) {
|
|
93
|
+
const run = obj(body[name]);
|
|
94
|
+
const numbers = new Set();
|
|
95
|
+
for (const item of Array.isArray(run.pull_requests) ? run.pull_requests : []) {
|
|
96
|
+
const n = num(obj(item).number);
|
|
97
|
+
if (n !== undefined)
|
|
98
|
+
numbers.add(n);
|
|
99
|
+
}
|
|
100
|
+
return [...numbers];
|
|
113
101
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
function reviewSummary({ body, repo, action, sender }) {
|
|
125
|
-
const review = obj(body.review);
|
|
126
|
-
const pr = obj(body.pull_request);
|
|
127
|
-
const n = num(pr.number);
|
|
128
|
-
const by = str(obj(review.user).login) ?? sender ?? "?";
|
|
129
|
-
return {
|
|
130
|
-
numbers: numbered(n),
|
|
131
|
-
lines: [
|
|
132
|
-
...present(`review ${action ?? ""} on ${ref(repo, n)} by ${by}: ${str(review.state) ?? "?"}`, inQuotes(str(pr.title)), str(review.html_url)),
|
|
133
|
-
...clippedBody(str(review.body)),
|
|
134
|
-
],
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
function commentSummary({ name, body, repo, action, sender }) {
|
|
138
|
-
const comment = obj(body.comment);
|
|
139
|
-
const issue = obj(body.issue);
|
|
140
|
-
const n = num(obj(body.pull_request).number) ?? num(issue.number);
|
|
141
|
-
const what = name === "issue_comment" && !issue.pull_request ? "issue comment" : "pull request comment";
|
|
142
|
-
const by = str(obj(comment.user).login) ?? sender ?? "?";
|
|
143
|
-
const path = str(comment.path);
|
|
144
|
-
const line = num(comment.line) ? `:${comment.line}` : "";
|
|
145
|
-
return {
|
|
146
|
-
numbers: numbered(n),
|
|
147
|
-
lines: [
|
|
148
|
-
...present(`${what} ${action ?? ""} on ${ref(repo, n)} by ${by}`, path && `on ${path}${line}`, str(comment.html_url)),
|
|
149
|
-
...clippedBody(str(comment.body)),
|
|
150
|
-
],
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
function itemSummary({ name, body, repo, action, by }) {
|
|
154
|
-
if (!action || !ITEM_ACTIONS.has(action))
|
|
155
|
-
return undefined;
|
|
156
|
-
const pullRequest = name === "pull_request";
|
|
157
|
-
const item = obj(pullRequest ? body.pull_request : body.issue);
|
|
158
|
-
const n = num(item.number);
|
|
159
|
-
const what = pullRequest ? "pull request" : "issue";
|
|
160
|
-
const done = pullRequest && action === "closed" && item.merged === true ? "merged" : action;
|
|
161
|
-
return { numbers: numbered(n), lines: present(`${what} ${ref(repo, n)} ${done}${by}`, inQuotes(str(item.title)), str(item.html_url)) };
|
|
102
|
+
let n;
|
|
103
|
+
if (name === "pull_request_review" || name === "pull_request")
|
|
104
|
+
n = num(obj(body.pull_request).number);
|
|
105
|
+
else if (name === "pull_request_review_comment" || name === "issue_comment")
|
|
106
|
+
n = num(obj(body.pull_request).number) ?? num(obj(body.issue).number);
|
|
107
|
+
else if (name === "issues")
|
|
108
|
+
n = num(obj(body.issue).number);
|
|
109
|
+
return n === undefined ? [] : [n];
|
|
162
110
|
}
|
|
163
|
-
function pushSummary({ body, repo, by }) {
|
|
164
|
-
const commits = Array.isArray(body.commits) ? body.commits.length : 0;
|
|
165
|
-
const plural = commits === 1 ? "" : "s";
|
|
166
|
-
return { numbers: [], lines: present(`push to ${repo} ${str(body.ref) ?? ""}${by}: ${commits} commit${plural}`, str(body.compare)) };
|
|
167
|
-
}
|
|
168
|
-
function otherSummary({ name, repo, action, by }) {
|
|
169
|
-
const acted = action ? ` ${action}` : "";
|
|
170
|
-
return { numbers: [], lines: [`${name}${acted} on ${repo}${by}`] };
|
|
171
|
-
}
|
|
172
|
-
const SUMMARIES = {
|
|
173
|
-
...Object.fromEntries(Object.keys(CI_EVENTS).map((name) => [name, ciSummary])),
|
|
174
|
-
pull_request_review: reviewSummary,
|
|
175
|
-
pull_request_review_comment: commentSummary,
|
|
176
|
-
issue_comment: commentSummary,
|
|
177
|
-
pull_request: itemSummary,
|
|
178
|
-
issues: itemSummary,
|
|
179
|
-
push: pushSummary,
|
|
180
|
-
};
|
|
181
111
|
/**
|
|
182
112
|
* Maps a GitHub delivery to its topics (`github:owner/repo` and, for pull requests and issues,
|
|
183
113
|
* `github:owner/repo#N`) and a summary. Undefined for deliveries nobody should be woken for: pings,
|
|
@@ -188,18 +118,19 @@ export function githubEvent(name, payload) {
|
|
|
188
118
|
const repo = str(obj(body.repository).full_name);
|
|
189
119
|
if (name === "ping" || !repo)
|
|
190
120
|
return undefined;
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if (!summary)
|
|
121
|
+
const action = str(body.action);
|
|
122
|
+
if (Object.hasOwn(CI_EVENTS, name) && action !== "completed")
|
|
123
|
+
return undefined;
|
|
124
|
+
if ((name === "pull_request" || name === "issues") && (!action || !ITEM_ACTIONS.has(action)))
|
|
196
125
|
return undefined;
|
|
126
|
+
const sender = str(obj(body.sender).login);
|
|
127
|
+
const numbers = numbersOf(name, body);
|
|
197
128
|
const lower = repo.toLowerCase();
|
|
198
129
|
return {
|
|
199
130
|
source: "github",
|
|
200
131
|
name,
|
|
201
|
-
topics: [`github:${lower}`, ...
|
|
202
|
-
summary:
|
|
132
|
+
topics: [`github:${lower}`, ...numbers.map((n) => `github:${lower}#${n}`)],
|
|
133
|
+
summary: githubSummary({ name, body, repo, action, sender, by: sender ? ` by ${sender}` : "", numbers }),
|
|
203
134
|
};
|
|
204
135
|
}
|
|
205
136
|
/** A delivery to `/hook/<topic>`: a JSON body's `text`, `summary` or `message` field, else the body itself. */
|
|
@@ -210,11 +141,7 @@ export function genericEvent(topic, body) {
|
|
|
210
141
|
text = str(parsed.text) ?? str(parsed.summary) ?? str(parsed.message) ?? body;
|
|
211
142
|
}
|
|
212
143
|
catch { }
|
|
213
|
-
return { source: "hook", name: topic, topics: [topic], summary:
|
|
214
|
-
}
|
|
215
|
-
/** Defuses `<courier` and `</courier>` in outside text, so it cannot close the envelope or forge another. */
|
|
216
|
-
export function defuse(text) {
|
|
217
|
-
return text.replace(/<(\/?)(courier)/gi, "<$1$2");
|
|
144
|
+
return { source: "hook", name: topic, topics: [topic], summary: hookSummary(text) };
|
|
218
145
|
}
|
|
219
146
|
const isNotFound = (error) => {
|
|
220
147
|
const tag = error?._tag;
|
|
@@ -233,7 +160,7 @@ export async function dispatch(ports, event, delivery) {
|
|
|
233
160
|
const deliver = (sessionID) => ports.session
|
|
234
161
|
.synthetic({
|
|
235
162
|
sessionID,
|
|
236
|
-
text: envelope(event.source,
|
|
163
|
+
text: envelope(event.source, webhookText(event.summary), {
|
|
237
164
|
event: event.name,
|
|
238
165
|
...(delivery ? { delivery } : {}),
|
|
239
166
|
}),
|
|
@@ -258,25 +185,6 @@ const header = (request, name) => {
|
|
|
258
185
|
const value = request.headers[name];
|
|
259
186
|
return Array.isArray(value) ? value[0] : value;
|
|
260
187
|
};
|
|
261
|
-
/** Digests of accepted deliveries, newest last; shared by every receiver in the process. */
|
|
262
|
-
export class Seen {
|
|
263
|
-
limit;
|
|
264
|
-
items = new Set();
|
|
265
|
-
constructor(limit = REMEMBERED) {
|
|
266
|
-
this.limit = limit;
|
|
267
|
-
}
|
|
268
|
-
has(digest) {
|
|
269
|
-
return this.items.has(digest);
|
|
270
|
-
}
|
|
271
|
-
add(digest) {
|
|
272
|
-
this.items.add(digest);
|
|
273
|
-
if (this.items.size > this.limit)
|
|
274
|
-
this.items.delete(this.items.values().next().value);
|
|
275
|
-
}
|
|
276
|
-
delete(digest) {
|
|
277
|
-
this.items.delete(digest);
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
188
|
const isResponse = (value) => "status" in value;
|
|
281
189
|
const decode = (raw) => {
|
|
282
190
|
try {
|
|
@@ -332,7 +240,7 @@ async function deliver(ports, request, event, digest, seen) {
|
|
|
332
240
|
* the name and the body. Anything not signed with the secret is refused before its body is parsed,
|
|
333
241
|
* and a signature already accepted is ignored, so a captured delivery cannot be replayed.
|
|
334
242
|
*/
|
|
335
|
-
export async function receive(ports, secret, request, seen = new
|
|
243
|
+
export async function receive(ports, secret, request, seen = new Set()) {
|
|
336
244
|
const routed = route(request);
|
|
337
245
|
if (isResponse(routed))
|
|
338
246
|
return routed;
|
|
@@ -346,7 +254,7 @@ export async function receive(ports, secret, request, seen = new Seen()) {
|
|
|
346
254
|
return event;
|
|
347
255
|
// Marked with no await since the check above, so a concurrent replay is refused, and unmarked if
|
|
348
256
|
// the delivery reached nobody it was meant for, so a retry or a GitHub Redeliver can still get through.
|
|
349
|
-
seen
|
|
257
|
+
addBounded(seen, digest, REMEMBERED);
|
|
350
258
|
return deliver(ports, request, event, digest, seen);
|
|
351
259
|
}
|
|
352
260
|
class TooLarge extends Error {
|
|
@@ -374,7 +282,8 @@ function readBody(request, maxBytes) {
|
|
|
374
282
|
}
|
|
375
283
|
/** Starts the HTTP receiver; `ports()` picks a live plugin instance for each request. */
|
|
376
284
|
export function listen(config, ports) {
|
|
377
|
-
|
|
285
|
+
// Digests of accepted deliveries, newest last; shared by every request this receiver handles.
|
|
286
|
+
const seen = new Set();
|
|
378
287
|
const server = createServer((request, response) => {
|
|
379
288
|
const reply = ({ status, body }) => {
|
|
380
289
|
response.writeHead(status, { "content-type": "text/plain; charset=utf-8", connection: "close" });
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-courier",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.3",
|
|
4
|
+
"description": "Multi-session orchestration for OpenCode V2: spawn child sessions, be woken by them. No polling.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ivo Pogace <71701666+ivopogace@users.noreply.github.com>",
|
|
7
7
|
"homepage": "https://github.com/ivopogace/opencode-courier#readme",
|
|
@@ -28,7 +28,18 @@
|
|
|
28
28
|
"opencode",
|
|
29
29
|
"opencode-plugin",
|
|
30
30
|
"agents",
|
|
31
|
-
"orchestration"
|
|
31
|
+
"orchestration",
|
|
32
|
+
"opencode2",
|
|
33
|
+
"opencode-v2",
|
|
34
|
+
"multi-agent",
|
|
35
|
+
"subagent",
|
|
36
|
+
"subagents",
|
|
37
|
+
"background-agents",
|
|
38
|
+
"parallel",
|
|
39
|
+
"worktree",
|
|
40
|
+
"webhook",
|
|
41
|
+
"scheduler",
|
|
42
|
+
"ai-agents"
|
|
32
43
|
],
|
|
33
44
|
"scripts": {
|
|
34
45
|
"build": "tsc -p tsconfig.build.json",
|
|
@@ -40,10 +51,10 @@
|
|
|
40
51
|
"effect": "4.0.0-rc.112"
|
|
41
52
|
},
|
|
42
53
|
"peerDependencies": {
|
|
43
|
-
"@opencode/plugin": "2.0.
|
|
54
|
+
"@opencode/plugin": "2.0.26"
|
|
44
55
|
},
|
|
45
56
|
"devDependencies": {
|
|
46
|
-
"@opencode/plugin": "2.0.
|
|
57
|
+
"@opencode/plugin": "2.0.26",
|
|
47
58
|
"@types/bun": "1.3.14",
|
|
48
59
|
"@types/node": "26.6.4",
|
|
49
60
|
"typescript": "5.9.3"
|