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/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.-]+$/;
|
|
@@ -22,8 +22,10 @@ export function parseTopic(input) {
|
|
|
22
22
|
const raw = input.trim();
|
|
23
23
|
const github = raw.replace(/^github:/i, "");
|
|
24
24
|
const [repo, number, ...rest] = github.split("#");
|
|
25
|
-
if (repo && GITHUB_REPO.test(repo) && rest.length === 0 && (number === undefined || /^\d+$/.test(number)))
|
|
26
|
-
|
|
25
|
+
if (repo && GITHUB_REPO.test(repo) && rest.length === 0 && (number === undefined || /^\d+$/.test(number))) {
|
|
26
|
+
const suffix = number === undefined ? "" : `#${Number(number)}`;
|
|
27
|
+
return `github:${repo.toLowerCase()}${suffix}`;
|
|
28
|
+
}
|
|
27
29
|
if (GENERIC_NAME.test(raw))
|
|
28
30
|
return raw;
|
|
29
31
|
throw new Error(`Not a topic: ${JSON.stringify(input)}. Use owner/repo, owner/repo#<number>, or a name of letters, digits, ".", "_" and "-".`);
|
|
@@ -48,8 +50,7 @@ export async function unsubscribe(ports, sessionID, topic) {
|
|
|
48
50
|
return [parseTopic(topic)];
|
|
49
51
|
}
|
|
50
52
|
const dropped = (await subscriptions(ports)).filter((item) => item.sessionID === sessionID);
|
|
51
|
-
|
|
52
|
-
await ports.storage.remove(keyOf(item.sessionID, item.topic));
|
|
53
|
+
await Promise.all(dropped.map((item) => ports.storage.remove(keyOf(item.sessionID, item.topic))));
|
|
53
54
|
return dropped.map((item) => item.topic);
|
|
54
55
|
}
|
|
55
56
|
/** Every subscription, or those to one topic. */
|
|
@@ -84,15 +85,29 @@ export function checkSignature(secret, body, header, name) {
|
|
|
84
85
|
export function sign(secret, body, name) {
|
|
85
86
|
return `sha256=${hmac(secret, body, name).toString("hex")}`;
|
|
86
87
|
}
|
|
87
|
-
const str = (value) => (typeof value === "string" ? value : undefined);
|
|
88
|
-
const num = (value) => (typeof value === "number" && Number.isInteger(value) ? value : undefined);
|
|
89
|
-
const obj = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
90
|
-
export function clip(text, max = MAX_TEXT) {
|
|
91
|
-
return text.length > max ? `${text.slice(0, max)}… [${text.length - max} more characters]` : text;
|
|
92
|
-
}
|
|
93
|
-
const CI_EVENTS = { check_run: "check run", check_suite: "check suite", workflow_run: "workflow run" };
|
|
94
88
|
/** Pull request and issue actions worth a wake-up; edits, labels, assignments and pushes to the branch are not. */
|
|
95
89
|
const ITEM_ACTIONS = new Set(["opened", "reopened", "closed", "ready_for_review"]);
|
|
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];
|
|
101
|
+
}
|
|
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];
|
|
110
|
+
}
|
|
96
111
|
/**
|
|
97
112
|
* Maps a GitHub delivery to its topics (`github:owner/repo` and, for pull requests and issues,
|
|
98
113
|
* `github:owner/repo#N`) and a summary. Undefined for deliveries nobody should be woken for: pings,
|
|
@@ -104,84 +119,18 @@ export function githubEvent(name, payload) {
|
|
|
104
119
|
if (name === "ping" || !repo)
|
|
105
120
|
return undefined;
|
|
106
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)))
|
|
125
|
+
return undefined;
|
|
107
126
|
const sender = str(obj(body.sender).login);
|
|
108
|
-
const numbers =
|
|
109
|
-
const lines = [];
|
|
110
|
-
const pr = obj(body.pull_request);
|
|
111
|
-
const issue = obj(body.issue);
|
|
112
|
-
const by = sender ? ` by ${sender}` : "";
|
|
113
|
-
if (Object.hasOwn(CI_EVENTS, name)) {
|
|
114
|
-
const run = obj(body[name]);
|
|
115
|
-
if (action !== "completed")
|
|
116
|
-
return undefined;
|
|
117
|
-
for (const item of Array.isArray(run.pull_requests) ? run.pull_requests : []) {
|
|
118
|
-
const n = num(obj(item).number);
|
|
119
|
-
if (n !== undefined)
|
|
120
|
-
numbers.add(n);
|
|
121
|
-
}
|
|
122
|
-
const label = str(run.name) ?? str(obj(run.app).name) ?? str(run.head_branch) ?? "";
|
|
123
|
-
const where = numbers.size ? [...numbers].map((n) => `${repo}#${n}`).join(", ") : `${repo} (${str(run.head_sha)?.slice(0, 7) ?? "?"})`;
|
|
124
|
-
lines.push(`${CI_EVENTS[name]}${label ? ` "${label}"` : ""} on ${where}: ${str(run.conclusion) ?? "completed"}`);
|
|
125
|
-
const url = str(run.html_url) ?? str(run.details_url);
|
|
126
|
-
if (url)
|
|
127
|
-
lines.push(url);
|
|
128
|
-
}
|
|
129
|
-
else if (name === "pull_request_review") {
|
|
130
|
-
const review = obj(body.review);
|
|
131
|
-
const n = num(pr.number);
|
|
132
|
-
if (n !== undefined)
|
|
133
|
-
numbers.add(n);
|
|
134
|
-
lines.push(`review ${action ?? ""} on ${repo}#${n ?? "?"} by ${str(obj(review.user).login) ?? sender ?? "?"}: ${str(review.state) ?? "?"}`);
|
|
135
|
-
if (str(pr.title))
|
|
136
|
-
lines.push(`"${str(pr.title)}"`);
|
|
137
|
-
if (str(review.html_url))
|
|
138
|
-
lines.push(str(review.html_url));
|
|
139
|
-
if (str(review.body))
|
|
140
|
-
lines.push("", clip(str(review.body)));
|
|
141
|
-
}
|
|
142
|
-
else if (name === "pull_request_review_comment" || name === "issue_comment") {
|
|
143
|
-
const comment = obj(body.comment);
|
|
144
|
-
const n = num(pr.number) ?? num(issue.number);
|
|
145
|
-
if (n !== undefined)
|
|
146
|
-
numbers.add(n);
|
|
147
|
-
const what = name === "issue_comment" && !issue.pull_request ? "issue comment" : "pull request comment";
|
|
148
|
-
lines.push(`${what} ${action ?? ""} on ${repo}#${n ?? "?"} by ${str(obj(comment.user).login) ?? sender ?? "?"}`);
|
|
149
|
-
if (str(comment.path))
|
|
150
|
-
lines.push(`on ${str(comment.path)}${num(comment.line) ? `:${comment.line}` : ""}`);
|
|
151
|
-
if (str(comment.html_url))
|
|
152
|
-
lines.push(str(comment.html_url));
|
|
153
|
-
if (str(comment.body))
|
|
154
|
-
lines.push("", clip(str(comment.body)));
|
|
155
|
-
}
|
|
156
|
-
else if (name === "pull_request" || name === "issues") {
|
|
157
|
-
if (!action || !ITEM_ACTIONS.has(action))
|
|
158
|
-
return undefined;
|
|
159
|
-
const item = name === "pull_request" ? pr : issue;
|
|
160
|
-
const n = num(item.number);
|
|
161
|
-
if (n !== undefined)
|
|
162
|
-
numbers.add(n);
|
|
163
|
-
const merged = name === "pull_request" && action === "closed" && pr.merged === true ? "merged" : action;
|
|
164
|
-
lines.push(`${name === "pull_request" ? "pull request" : "issue"} ${repo}#${n ?? "?"} ${merged ?? "updated"}${by}`);
|
|
165
|
-
if (str(item.title))
|
|
166
|
-
lines.push(`"${str(item.title)}"`);
|
|
167
|
-
if (str(item.html_url))
|
|
168
|
-
lines.push(str(item.html_url));
|
|
169
|
-
}
|
|
170
|
-
else if (name === "push") {
|
|
171
|
-
const commits = Array.isArray(body.commits) ? body.commits.length : 0;
|
|
172
|
-
lines.push(`push to ${repo} ${str(body.ref) ?? ""}${by}: ${commits} commit${commits === 1 ? "" : "s"}`);
|
|
173
|
-
if (str(body.compare))
|
|
174
|
-
lines.push(str(body.compare));
|
|
175
|
-
}
|
|
176
|
-
else {
|
|
177
|
-
lines.push(`${name}${action ? ` ${action}` : ""} on ${repo}${by}`);
|
|
178
|
-
}
|
|
127
|
+
const numbers = numbersOf(name, body);
|
|
179
128
|
const lower = repo.toLowerCase();
|
|
180
129
|
return {
|
|
181
130
|
source: "github",
|
|
182
131
|
name,
|
|
183
|
-
topics: [`github:${lower}`, ...
|
|
184
|
-
summary:
|
|
132
|
+
topics: [`github:${lower}`, ...numbers.map((n) => `github:${lower}#${n}`)],
|
|
133
|
+
summary: githubSummary({ name, body, repo, action, sender, by: sender ? ` by ${sender}` : "", numbers }),
|
|
185
134
|
};
|
|
186
135
|
}
|
|
187
136
|
/** A delivery to `/hook/<topic>`: a JSON body's `text`, `summary` or `message` field, else the body itself. */
|
|
@@ -192,13 +141,12 @@ export function genericEvent(topic, body) {
|
|
|
192
141
|
text = str(parsed.text) ?? str(parsed.summary) ?? str(parsed.message) ?? body;
|
|
193
142
|
}
|
|
194
143
|
catch { }
|
|
195
|
-
return { source: "hook", name: topic, topics: [topic], summary:
|
|
144
|
+
return { source: "hook", name: topic, topics: [topic], summary: hookSummary(text) };
|
|
196
145
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
return
|
|
200
|
-
}
|
|
201
|
-
const isNotFound = (error) => /NotFound/.test(String(error?._tag ?? ""));
|
|
146
|
+
const isNotFound = (error) => {
|
|
147
|
+
const tag = error?._tag;
|
|
148
|
+
return typeof tag === "string" && tag.includes("NotFound");
|
|
149
|
+
};
|
|
202
150
|
/**
|
|
203
151
|
* Delivers an event to every session subscribed to one of its topics, once per session. A session
|
|
204
152
|
* OpenCode no longer knows loses its subscriptions.
|
|
@@ -208,102 +156,74 @@ export async function dispatch(ports, event, delivery) {
|
|
|
208
156
|
const sessions = new Set(subscribed.map((item) => item.sessionID));
|
|
209
157
|
let delivered = 0;
|
|
210
158
|
let failed = 0;
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
}
|
|
159
|
+
// Each session gets its own message, so they go out at once rather than one after another.
|
|
160
|
+
const deliver = (sessionID) => ports.session
|
|
161
|
+
.synthetic({
|
|
162
|
+
sessionID,
|
|
163
|
+
text: envelope(event.source, webhookText(event.summary), {
|
|
164
|
+
event: event.name,
|
|
165
|
+
...(delivery ? { delivery } : {}),
|
|
166
|
+
}),
|
|
167
|
+
description: `${event.source} ${event.name}`,
|
|
168
|
+
metadata: { source: "courier", from: event.source, event: event.name, ...(delivery ? { delivery } : {}) },
|
|
169
|
+
delivery: "queue",
|
|
170
|
+
})
|
|
171
|
+
.then(() => delivered++)
|
|
172
|
+
.catch(async (error) => {
|
|
173
|
+
if (!isNotFound(error)) {
|
|
174
|
+
failed++;
|
|
175
|
+
return ports.log(`courier webhook: ${event.name} not delivered to ${sessionID}: ${String(error)}`);
|
|
176
|
+
}
|
|
177
|
+
const gone = subscribed.filter((item) => item.sessionID === sessionID);
|
|
178
|
+
await Promise.all(gone.map((item) => ports.storage.remove(keyOf(item.sessionID, item.topic))));
|
|
179
|
+
ports.log(`courier webhook: ${sessionID} no longer exists; dropped its subscriptions to ${gone.map((item) => item.topic).join(", ")}`);
|
|
180
|
+
});
|
|
181
|
+
await Promise.all([...sessions].map(deliver));
|
|
235
182
|
return { delivered, failed };
|
|
236
183
|
}
|
|
237
184
|
const header = (request, name) => {
|
|
238
185
|
const value = request.headers[name];
|
|
239
186
|
return Array.isArray(value) ? value[0] : value;
|
|
240
187
|
};
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
constructor(limit = REMEMBERED) {
|
|
246
|
-
this.limit = limit;
|
|
247
|
-
}
|
|
248
|
-
has(digest) {
|
|
249
|
-
return this.items.has(digest);
|
|
250
|
-
}
|
|
251
|
-
add(digest) {
|
|
252
|
-
this.items.add(digest);
|
|
253
|
-
if (this.items.size > this.limit)
|
|
254
|
-
this.items.delete(this.items.values().next().value);
|
|
188
|
+
const isResponse = (value) => "status" in value;
|
|
189
|
+
const decode = (raw) => {
|
|
190
|
+
try {
|
|
191
|
+
return decodeURIComponent(raw);
|
|
255
192
|
}
|
|
256
|
-
|
|
257
|
-
|
|
193
|
+
catch {
|
|
194
|
+
return undefined;
|
|
258
195
|
}
|
|
259
|
-
}
|
|
260
|
-
/**
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
* and a signature already accepted is ignored, so a captured delivery cannot be replayed.
|
|
264
|
-
*/
|
|
265
|
-
export async function receive(ports, secret, request, seen = new Seen()) {
|
|
266
|
-
const generic = request.path.match(/^\/hook\/([^/]+)$/);
|
|
196
|
+
};
|
|
197
|
+
/** The topic a request is for: none for `/github`, the name for `/hook/<name>`; else the refusal. */
|
|
198
|
+
function route(request) {
|
|
199
|
+
const generic = /^\/hook\/([^/]+)$/.exec(request.path);
|
|
267
200
|
if (request.path !== "/github" && !generic)
|
|
268
201
|
return { status: 404, body: "not found" };
|
|
269
202
|
if (request.method !== "POST")
|
|
270
203
|
return { status: 405, body: "use POST" };
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
const
|
|
281
|
-
if (!
|
|
282
|
-
return { status:
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
if (topic !== undefined) {
|
|
287
|
-
event = genericEvent(topic, request.body.toString("utf8"));
|
|
204
|
+
if (!generic)
|
|
205
|
+
return { topic: undefined };
|
|
206
|
+
const topic = decode(generic[1]);
|
|
207
|
+
return topic && GENERIC_NAME.test(topic) ? { topic } : { status: 400, body: "bad topic" };
|
|
208
|
+
}
|
|
209
|
+
/** The event in a signed request's body, or why there is none to deliver. */
|
|
210
|
+
function parseEvent(request, topic) {
|
|
211
|
+
if (topic !== undefined)
|
|
212
|
+
return genericEvent(topic, request.body.toString("utf8"));
|
|
213
|
+
const name = header(request, "x-github-event");
|
|
214
|
+
if (!name || !/^[\w.-]{1,64}$/.test(name))
|
|
215
|
+
return { status: 400, body: "missing or bad X-GitHub-Event" };
|
|
216
|
+
let payload;
|
|
217
|
+
try {
|
|
218
|
+
payload = JSON.parse(request.body.toString("utf8"));
|
|
288
219
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
if (!name || !/^[\w.-]{1,64}$/.test(name))
|
|
292
|
-
return { status: 400, body: "missing or bad X-GitHub-Event" };
|
|
293
|
-
let payload;
|
|
294
|
-
try {
|
|
295
|
-
payload = JSON.parse(request.body.toString("utf8"));
|
|
296
|
-
}
|
|
297
|
-
catch {
|
|
298
|
-
return { status: 400, body: "body is not JSON; set the webhook's content type to application/json" };
|
|
299
|
-
}
|
|
300
|
-
event = githubEvent(name, payload);
|
|
301
|
-
if (!event)
|
|
302
|
-
return { status: 200, body: `ignored ${name}` };
|
|
220
|
+
catch {
|
|
221
|
+
return { status: 400, body: "body is not JSON; set the webhook's content type to application/json" };
|
|
303
222
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
223
|
+
return githubEvent(name, payload) ?? { status: 200, body: `ignored ${name}` };
|
|
224
|
+
}
|
|
225
|
+
/** Dispatches a delivery `receive` has marked as seen, and unmarks it if it reached nobody. */
|
|
226
|
+
async function deliver(ports, request, event, digest, seen) {
|
|
307
227
|
const delivery = header(request, "x-github-delivery")?.replace(/[^\w-]/g, "").slice(0, 64) || undefined;
|
|
308
228
|
const { delivered, failed } = await dispatch(ports, event, delivery).catch((error) => {
|
|
309
229
|
seen.delete(digest);
|
|
@@ -311,9 +231,32 @@ export async function receive(ports, secret, request, seen = new Seen()) {
|
|
|
311
231
|
});
|
|
312
232
|
if (failed > 0 && delivered === 0)
|
|
313
233
|
seen.delete(digest);
|
|
314
|
-
|
|
234
|
+
const id = delivery ? ` ${delivery}` : "";
|
|
235
|
+
ports.log(`courier webhook: ${event.source} ${event.name}${id} delivered to ${delivered} session(s)`);
|
|
315
236
|
return { status: 202, body: `delivered to ${delivered} session(s)` };
|
|
316
237
|
}
|
|
238
|
+
/**
|
|
239
|
+
* Handles one request: `POST /github` with GitHub's headers, or `POST /hook/<name>` signed over
|
|
240
|
+
* the name and the body. Anything not signed with the secret is refused before its body is parsed,
|
|
241
|
+
* and a signature already accepted is ignored, so a captured delivery cannot be replayed.
|
|
242
|
+
*/
|
|
243
|
+
export async function receive(ports, secret, request, seen = new Set()) {
|
|
244
|
+
const routed = route(request);
|
|
245
|
+
if (isResponse(routed))
|
|
246
|
+
return routed;
|
|
247
|
+
const digest = checkSignature(secret, request.body, header(request, "x-hub-signature-256"), routed.topic);
|
|
248
|
+
if (!digest)
|
|
249
|
+
return { status: 401, body: "bad or missing X-Hub-Signature-256" };
|
|
250
|
+
if (seen.has(digest))
|
|
251
|
+
return { status: 200, body: "already delivered" };
|
|
252
|
+
const event = parseEvent(request, routed.topic);
|
|
253
|
+
if (isResponse(event))
|
|
254
|
+
return event;
|
|
255
|
+
// Marked with no await since the check above, so a concurrent replay is refused, and unmarked if
|
|
256
|
+
// the delivery reached nobody it was meant for, so a retry or a GitHub Redeliver can still get through.
|
|
257
|
+
addBounded(seen, digest, REMEMBERED);
|
|
258
|
+
return deliver(ports, request, event, digest, seen);
|
|
259
|
+
}
|
|
317
260
|
class TooLarge extends Error {
|
|
318
261
|
}
|
|
319
262
|
function readBody(request, maxBytes) {
|
|
@@ -339,7 +282,8 @@ function readBody(request, maxBytes) {
|
|
|
339
282
|
}
|
|
340
283
|
/** Starts the HTTP receiver; `ports()` picks a live plugin instance for each request. */
|
|
341
284
|
export function listen(config, ports) {
|
|
342
|
-
|
|
285
|
+
// Digests of accepted deliveries, newest last; shared by every request this receiver handles.
|
|
286
|
+
const seen = new Set();
|
|
343
287
|
const server = createServer((request, response) => {
|
|
344
288
|
const reply = ({ status, body }) => {
|
|
345
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"
|
package/dist/question.d.ts
DELETED
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
import type { Plugin } from "@opencode/plugin";
|
|
2
|
-
import type { Plugin as EffectPlugin } from "@opencode/plugin/effect";
|
|
3
|
-
import { Effect } from "effect";
|
|
4
|
-
import { type RosterStorage } from "./roster.js";
|
|
5
|
-
type Context = Plugin.Context;
|
|
6
|
-
/** OpenCode's own question tool, which the relay wraps. */
|
|
7
|
-
export declare const QUESTION_TOOL = "question";
|
|
8
|
-
/** At most this many questions are kept in storage; the oldest go first. */
|
|
9
|
-
export declare const MAX_STORED = 100;
|
|
10
|
-
/** One question of OpenCode's question tool, as the asking model wrote it. */
|
|
11
|
-
export interface Prompt {
|
|
12
|
-
readonly question: string;
|
|
13
|
-
readonly header: string;
|
|
14
|
-
readonly options: ReadonlyArray<{
|
|
15
|
-
readonly label: string;
|
|
16
|
-
readonly description: string;
|
|
17
|
-
}>;
|
|
18
|
-
readonly multiple?: boolean;
|
|
19
|
-
}
|
|
20
|
-
/** The answers to a question call, as the question tool returns them: one list per question. */
|
|
21
|
-
export type Answers = string[][];
|
|
22
|
-
/** A question a spawned session asked with its question tool, stored until it is answered. */
|
|
23
|
-
export interface Asked {
|
|
24
|
-
readonly requestID: string;
|
|
25
|
-
readonly sessionID: string;
|
|
26
|
-
/** The session told, at the top of the sessions started with courier_spawn; it alone answers. */
|
|
27
|
-
readonly top: string;
|
|
28
|
-
readonly title: string;
|
|
29
|
-
/** The session that started the asking one, when that is not the top session itself. */
|
|
30
|
-
readonly startedBy?: string;
|
|
31
|
-
readonly questions: ReadonlyArray<Prompt>;
|
|
32
|
-
readonly askedAt: number;
|
|
33
|
-
}
|
|
34
|
-
/** How a question was settled without the top session: in the asking session, or not at all. */
|
|
35
|
-
export type Elsewhere = {
|
|
36
|
-
readonly by: "child";
|
|
37
|
-
readonly answers: Answers;
|
|
38
|
-
} | {
|
|
39
|
-
readonly by: "dismissed";
|
|
40
|
-
} | {
|
|
41
|
-
readonly by: "failed";
|
|
42
|
-
readonly error: string;
|
|
43
|
-
};
|
|
44
|
-
/**
|
|
45
|
-
* Timings, changed by the tests: how long a question cut off by a closing location waits before an
|
|
46
|
-
* instance still loaded tells its top session, how long an answer waits for another one to it that
|
|
47
|
-
* is still being passed on, how long what follows a call's end waits for the notice of the
|
|
48
|
-
* question to go out, and how long a dismissal is held to see whether the location is shutting down.
|
|
49
|
-
*/
|
|
50
|
-
export declare const timing: {
|
|
51
|
-
closingGraceMs: number;
|
|
52
|
-
passingWaitMs: number;
|
|
53
|
-
relayWaitMs: number;
|
|
54
|
-
dismissalGraceMs: number;
|
|
55
|
-
};
|
|
56
|
-
/**
|
|
57
|
-
* For tests: when the location at `directory` was last reported shutting down, if at all; a shutdown
|
|
58
|
-
* reported without a location counts for every directory, and with none given, any location's counts.
|
|
59
|
-
*/
|
|
60
|
-
export declare const shutdownReportedAt: (directory?: string) => number | undefined;
|
|
61
|
-
/**
|
|
62
|
-
* Called for OpenCode's `location.shutdown`: the location at `directory` is closing, which
|
|
63
|
-
* withdraws its open forms as if the person had dismissed them (before unloading the plugin
|
|
64
|
-
* there, since OpenCode 2.0.22). The event's location is optional in the schema; without it,
|
|
65
|
-
* the shutdown counts for every location.
|
|
66
|
-
*/
|
|
67
|
-
export declare function locationClosing(directory?: string): void;
|
|
68
|
-
export interface QuestionPorts {
|
|
69
|
-
readonly storage: RosterStorage;
|
|
70
|
-
readonly session: Pick<Context["session"], "synthetic">;
|
|
71
|
-
/** The directory of the instance's location. */
|
|
72
|
-
readonly directory: string;
|
|
73
|
-
readonly now: () => number;
|
|
74
|
-
readonly newID: () => string;
|
|
75
|
-
readonly log: (message: string) => void;
|
|
76
|
-
}
|
|
77
|
-
/** Whether a request id is a question's rather than a permission request's. */
|
|
78
|
-
export declare const isQuestion: (requestID: string) => boolean;
|
|
79
|
-
/** What OpenCode's question tool tells the asking model once it has the answers. */
|
|
80
|
-
export declare function answeredText(questions: ReadonlyArray<Prompt>, answers: ReadonlyArray<ReadonlyArray<string>>): string;
|
|
81
|
-
declare const CUT_OFF: {
|
|
82
|
-
stopped: string;
|
|
83
|
-
restarted: string;
|
|
84
|
-
};
|
|
85
|
-
/** What the top session is told about a question, when it is asked or after its call was cut off. */
|
|
86
|
-
export declare function questionNotice(asked: Asked, cutOff?: keyof typeof CUT_OFF): string;
|
|
87
|
-
/** What the top session is told about a question settled without it. */
|
|
88
|
-
export declare function settledNotice(asked: Asked, how: Elsewhere): string;
|
|
89
|
-
export interface QuestionAnswerInput {
|
|
90
|
-
readonly sessionID: string;
|
|
91
|
-
readonly requestID: string;
|
|
92
|
-
/** One entry per question: a label or the text the person gave, or a list of them. */
|
|
93
|
-
readonly answers: ReadonlyArray<string | ReadonlyArray<string>>;
|
|
94
|
-
}
|
|
95
|
-
/**
|
|
96
|
-
* Answers a question of a session started, directly or through others, from the caller, which must
|
|
97
|
-
* be the session at the top, as for permission requests. `answered` is false when it no longer waits.
|
|
98
|
-
*/
|
|
99
|
-
export declare function answerQuestion(ports: QuestionPorts, callerID: string, input: QuestionAnswerInput): Promise<{
|
|
100
|
-
sessionID: string;
|
|
101
|
-
requestID: string;
|
|
102
|
-
answered: boolean;
|
|
103
|
-
by?: undefined;
|
|
104
|
-
answers?: undefined;
|
|
105
|
-
} | {
|
|
106
|
-
sessionID: string;
|
|
107
|
-
requestID: string;
|
|
108
|
-
answered: boolean;
|
|
109
|
-
by: "message" | "result";
|
|
110
|
-
answers: Answers;
|
|
111
|
-
}>;
|
|
112
|
-
/** The questions a session waits on, for courier_status; `stopped` when its call was cut off. */
|
|
113
|
-
export declare function pendingQuestions(storage: RosterStorage, sessionID: string): Promise<{
|
|
114
|
-
type: "question";
|
|
115
|
-
requestID: string;
|
|
116
|
-
questions: Prompt[];
|
|
117
|
-
stopped?: true;
|
|
118
|
-
}[]>;
|
|
119
|
-
/**
|
|
120
|
-
* On loading: drops stored questions that are too old, whose session is no longer on a roster, or
|
|
121
|
-
* beyond the newest `MAX_STORED`, and tells the top session about the rest, which a restart cut off.
|
|
122
|
-
*/
|
|
123
|
-
export declare function noticeCutOff(ports: QuestionPorts): Promise<void>;
|
|
124
|
-
/**
|
|
125
|
-
* A plugin instance whose ports the relay may use, until the returned function is called. A
|
|
126
|
-
* location loading again forgets the shutdown recorded for it, so a dismissal there is not
|
|
127
|
-
* mistaken for that shutdown. One recorded for every location is left alone, since a dismissal
|
|
128
|
-
* held in another location may still need it; it ages out with the grace.
|
|
129
|
-
*/
|
|
130
|
-
export declare function joinRelay(ports: QuestionPorts): () => void;
|
|
131
|
-
/** Called for OpenCode's `form.created`: a question call's form is shown, so its question can be relayed. */
|
|
132
|
-
export declare function formShown(event: {
|
|
133
|
-
readonly data: {
|
|
134
|
-
readonly form: {
|
|
135
|
-
readonly sessionID: string;
|
|
136
|
-
readonly metadata?: unknown;
|
|
137
|
-
};
|
|
138
|
-
};
|
|
139
|
-
}): void;
|
|
140
|
-
/**
|
|
141
|
-
* Called when an event stream may have missed forms being shown: every call still waiting for its
|
|
142
|
-
* form is taken as shown. One still waiting for its permission check is relayed early, which is the
|
|
143
|
-
* lesser harm.
|
|
144
|
-
*/
|
|
145
|
-
export declare function formsMayHaveBeenMissed(): void;
|
|
146
|
-
/**
|
|
147
|
-
* Called when an instance starts following OpenCode's events. Every instance is sent every event,
|
|
148
|
-
* so a form shown is missed only while none follows them; then the calls waiting are released, and
|
|
149
|
-
* true is returned, for the caller to release them again on its first event, once it is connected.
|
|
150
|
-
*/
|
|
151
|
-
export declare function eventsFollowed(): boolean;
|
|
152
|
-
/** Called when an instance's event stream ended or broke. */
|
|
153
|
-
export declare function eventsLeft(): void;
|
|
154
|
-
/** Wraps OpenCode's question tool for the relay. A tool by that id that is missing is left alone. */
|
|
155
|
-
export declare function relayQuestions(host: Pick<EffectPlugin.Context, "tool" | "location">, ports: () => QuestionPorts | undefined): Effect.Effect<import("@opencode/plugin/effect/registration").Registration, never, import("effect/Scope").Scope>;
|
|
156
|
-
/** For tests: forgets every question this process knows. */
|
|
157
|
-
export declare function forgetQuestions(): void;
|
|
158
|
-
export {};
|