opencode-courier 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +247 -108
- package/dist/bounded.d.ts +8 -0
- package/dist/bounded.js +19 -0
- package/dist/cleanup.d.ts +21 -21
- package/dist/cleanup.js +61 -30
- package/dist/courier.d.ts +116 -15
- package/dist/courier.js +239 -74
- package/dist/group.d.ts +86 -0
- package/dist/group.js +247 -0
- package/dist/hub.d.ts +300 -0
- package/dist/hub.js +343 -0
- package/dist/index.d.ts +5 -13
- package/dist/index.js +82 -313
- package/dist/json.d.ts +8 -0
- package/dist/json.js +15 -0
- package/dist/later.d.ts +7 -7
- package/dist/later.js +8 -7
- package/dist/limits.d.ts +65 -0
- package/dist/limits.js +109 -0
- package/dist/notices.d.ts +356 -0
- package/dist/notices.js +678 -0
- package/dist/question/answer.d.ts +35 -0
- package/dist/question/answer.js +214 -0
- package/dist/question/index.d.ts +6 -0
- package/dist/question/index.js +7 -0
- package/dist/question/lifecycle.d.ts +43 -0
- package/dist/question/lifecycle.js +91 -0
- package/dist/question/pure.d.ts +48 -0
- package/dist/question/pure.js +74 -0
- package/dist/question/relay.d.ts +5 -0
- package/dist/question/relay.js +269 -0
- package/dist/question/runtime.d.ts +20 -0
- package/dist/question/runtime.js +74 -0
- package/dist/question/shared.d.ts +24 -0
- package/dist/question/shared.js +24 -0
- package/dist/relay.d.ts +32 -36
- package/dist/relay.js +6 -56
- package/dist/report.d.ts +48 -0
- package/dist/report.js +88 -0
- package/dist/roster.d.ts +48 -8
- package/dist/roster.js +140 -17
- package/dist/skill.d.ts +13 -0
- package/dist/skill.js +9 -0
- package/dist/stop.d.ts +41 -0
- package/dist/stop.js +120 -0
- package/dist/storage.d.ts +8 -1
- package/dist/storage.js +8 -5
- package/dist/tools.d.ts +94 -0
- package/dist/tools.js +297 -0
- package/dist/tree.d.ts +102 -0
- package/dist/tree.js +58 -0
- package/dist/version.d.ts +4 -10
- package/dist/version.js +4 -10
- package/dist/watch.d.ts +70 -42
- package/dist/watch.js +346 -51
- package/dist/webhook.d.ts +11 -28
- package/dist/webhook.js +46 -146
- package/package.json +16 -5
- package/dist/question.d.ts +0 -158
- package/dist/question.js +0 -768
package/dist/webhook.d.ts
CHANGED
|
@@ -22,9 +22,8 @@ export interface WebhookConfig {
|
|
|
22
22
|
readonly maxBytes: number;
|
|
23
23
|
}
|
|
24
24
|
/**
|
|
25
|
-
* Normalises a topic: `owner/repo` or `owner/repo#12` (optionally
|
|
26
|
-
*
|
|
27
|
-
* `-` is a generic topic, posted to `/hook/<name>`.
|
|
25
|
+
* Normalises a topic: `owner/repo` or `owner/repo#12` (optionally `github:`-prefixed) is a GitHub
|
|
26
|
+
* repo, PR or issue; any other name of letters, digits, `.`, `_` and `-` is posted to `/hook/<name>`.
|
|
28
27
|
*/
|
|
29
28
|
export declare function parseTopic(input: string): string;
|
|
30
29
|
/** Subscribes an existing session to a topic; throws for a session OpenCode does not know. */
|
|
@@ -34,9 +33,8 @@ export declare function unsubscribe(ports: WebhookPorts, sessionID: string, topi
|
|
|
34
33
|
/** Every subscription, or those to one topic. */
|
|
35
34
|
export declare function subscriptions(ports: Pick<WebhookPorts, "storage">, topic?: string): Promise<Subscription[]>;
|
|
36
35
|
/**
|
|
37
|
-
* Checks an `X-Hub-Signature-256` header (`sha256=`
|
|
38
|
-
*
|
|
39
|
-
* keyed by that digest, never by the header text, which could be re-cased or padded.
|
|
36
|
+
* Checks an `X-Hub-Signature-256` header (`sha256=` + 64 hex digits) in constant time, returning the
|
|
37
|
+
* digest in lowercase hex, or undefined; replays are keyed by that digest, never by the header text.
|
|
40
38
|
*/
|
|
41
39
|
export declare function checkSignature(secret: string, body: Uint8Array, header: string | undefined, name?: string): string | undefined;
|
|
42
40
|
/** The `X-Hub-Signature-256` value for a body; give the topic name for `/hook/<name>`. */
|
|
@@ -48,17 +46,13 @@ export interface Event {
|
|
|
48
46
|
readonly topics: readonly string[];
|
|
49
47
|
readonly summary: string;
|
|
50
48
|
}
|
|
51
|
-
export declare function clip(text: string, max?: number): string;
|
|
52
49
|
/**
|
|
53
|
-
* Maps a GitHub delivery to its topics (`github:owner/repo`
|
|
54
|
-
*
|
|
55
|
-
* CI events that have not completed, and pull request or issue actions outside `ITEM_ACTIONS`.
|
|
50
|
+
* Maps a GitHub delivery to its topics (`github:owner/repo`, plus `github:owner/repo#N` for PRs and
|
|
51
|
+
* issues) and a summary; undefined for pings, unfinished CI events and actions outside `ITEM_ACTIONS`.
|
|
56
52
|
*/
|
|
57
53
|
export declare function githubEvent(name: string, payload: unknown): Event | undefined;
|
|
58
54
|
/** A delivery to `/hook/<topic>`: a JSON body's `text`, `summary` or `message` field, else the body itself. */
|
|
59
55
|
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
56
|
/**
|
|
63
57
|
* Delivers an event to every session subscribed to one of its topics, once per session. A session
|
|
64
58
|
* OpenCode no longer knows loses its subscriptions.
|
|
@@ -77,27 +71,16 @@ export interface Response {
|
|
|
77
71
|
readonly status: number;
|
|
78
72
|
readonly body: string;
|
|
79
73
|
}
|
|
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
74
|
/**
|
|
90
|
-
* Handles one request: `POST /github` with GitHub's headers, or `POST /hook/<name>` signed over
|
|
91
|
-
*
|
|
92
|
-
* and a signature already accepted is ignored, so a captured delivery cannot be replayed.
|
|
75
|
+
* Handles one request: `POST /github` with GitHub's headers, or `POST /hook/<name>` signed over name
|
|
76
|
+
* and body; anything unsigned is refused before parsing, and a signature already accepted is ignored.
|
|
93
77
|
*/
|
|
94
|
-
export declare function receive(ports: WebhookPorts, secret: string, request: Request, seen?:
|
|
78
|
+
export declare function receive(ports: WebhookPorts, secret: string, request: Request, seen?: Set<string>): Promise<Response>;
|
|
95
79
|
/** Starts the HTTP receiver; `ports()` picks a live plugin instance for each request. */
|
|
96
80
|
export declare function listen(config: WebhookConfig, ports: () => WebhookPorts | undefined): Promise<Server<typeof IncomingMessage, typeof import("node:http").ServerResponse>>;
|
|
97
81
|
/**
|
|
98
|
-
* Reads the
|
|
99
|
-
*
|
|
100
|
-
* the option itself, so it stays out of opencode.json. Undefined when the receiver is not configured.
|
|
82
|
+
* Reads the `webhook` option, undefined when the receiver is not configured; the secret comes from
|
|
83
|
+
* `secretFile` or the env var `secretEnv` (default `COURIER_WEBHOOK_SECRET`), never from the option.
|
|
101
84
|
*/
|
|
102
85
|
export declare function readConfig(options: unknown, env?: Record<string, string | undefined>): WebhookConfig | undefined;
|
|
103
86
|
export {};
|
package/dist/webhook.js
CHANGED
|
@@ -2,21 +2,20 @@ 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 { isNotFound, 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.-]+$/;
|
|
15
15
|
const GENERIC_NAME = /^[\w.-]{1,64}$/;
|
|
16
16
|
/**
|
|
17
|
-
* Normalises a topic: `owner/repo` or `owner/repo#12` (optionally
|
|
18
|
-
*
|
|
19
|
-
* `-` is a generic topic, posted to `/hook/<name>`.
|
|
17
|
+
* Normalises a topic: `owner/repo` or `owner/repo#12` (optionally `github:`-prefixed) is a GitHub
|
|
18
|
+
* repo, PR or issue; any other name of letters, digits, `.`, `_` and `-` is posted to `/hook/<name>`.
|
|
20
19
|
*/
|
|
21
20
|
export function parseTopic(input) {
|
|
22
21
|
const raw = input.trim();
|
|
@@ -69,9 +68,8 @@ function hmac(secret, body, name) {
|
|
|
69
68
|
}
|
|
70
69
|
const SIGNATURE = /^sha256=([0-9a-fA-F]{64})$/;
|
|
71
70
|
/**
|
|
72
|
-
* Checks an `X-Hub-Signature-256` header (`sha256=`
|
|
73
|
-
*
|
|
74
|
-
* keyed by that digest, never by the header text, which could be re-cased or padded.
|
|
71
|
+
* Checks an `X-Hub-Signature-256` header (`sha256=` + 64 hex digits) in constant time, returning the
|
|
72
|
+
* digest in lowercase hex, or undefined; replays are keyed by that digest, never by the header text.
|
|
75
73
|
*/
|
|
76
74
|
export function checkSignature(secret, body, header, name) {
|
|
77
75
|
const hex = header?.match(SIGNATURE)?.[1];
|
|
@@ -85,121 +83,51 @@ export function checkSignature(secret, body, header, name) {
|
|
|
85
83
|
export function sign(secret, body, name) {
|
|
86
84
|
return `sha256=${hmac(secret, body, name).toString("hex")}`;
|
|
87
85
|
}
|
|
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
86
|
/** Pull request and issue actions worth a wake-up; edits, labels, assignments and pushes to the branch are not. */
|
|
96
87
|
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);
|
|
88
|
+
/** The pull request and issue numbers a GitHub delivery concerns, each with a topic of its own. */
|
|
89
|
+
function numbersOf(name, body) {
|
|
90
|
+
if (Object.hasOwn(CI_EVENTS, name)) {
|
|
91
|
+
const run = obj(body[name]);
|
|
92
|
+
const numbers = new Set();
|
|
93
|
+
for (const item of Array.isArray(run.pull_requests) ? run.pull_requests : []) {
|
|
94
|
+
const n = num(obj(item).number);
|
|
95
|
+
if (n !== undefined)
|
|
96
|
+
numbers.add(n);
|
|
97
|
+
}
|
|
98
|
+
return [...numbers];
|
|
113
99
|
}
|
|
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)) };
|
|
100
|
+
let n;
|
|
101
|
+
if (name === "pull_request_review" || name === "pull_request")
|
|
102
|
+
n = num(obj(body.pull_request).number);
|
|
103
|
+
else if (name === "pull_request_review_comment" || name === "issue_comment")
|
|
104
|
+
n = num(obj(body.pull_request).number) ?? num(obj(body.issue).number);
|
|
105
|
+
else if (name === "issues")
|
|
106
|
+
n = num(obj(body.issue).number);
|
|
107
|
+
return n === undefined ? [] : [n];
|
|
162
108
|
}
|
|
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
109
|
/**
|
|
182
|
-
* Maps a GitHub delivery to its topics (`github:owner/repo`
|
|
183
|
-
*
|
|
184
|
-
* CI events that have not completed, and pull request or issue actions outside `ITEM_ACTIONS`.
|
|
110
|
+
* Maps a GitHub delivery to its topics (`github:owner/repo`, plus `github:owner/repo#N` for PRs and
|
|
111
|
+
* issues) and a summary; undefined for pings, unfinished CI events and actions outside `ITEM_ACTIONS`.
|
|
185
112
|
*/
|
|
186
113
|
export function githubEvent(name, payload) {
|
|
187
114
|
const body = obj(payload);
|
|
188
115
|
const repo = str(obj(body.repository).full_name);
|
|
189
116
|
if (name === "ping" || !repo)
|
|
190
117
|
return undefined;
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
const summarise = Object.hasOwn(SUMMARIES, name) ? SUMMARIES[name] : otherSummary;
|
|
194
|
-
const summary = summarise({ name, body, repo, action: str(body.action), sender, by: sender ? ` by ${sender}` : "" });
|
|
195
|
-
if (!summary)
|
|
118
|
+
const action = str(body.action);
|
|
119
|
+
if (Object.hasOwn(CI_EVENTS, name) && action !== "completed")
|
|
196
120
|
return undefined;
|
|
121
|
+
if ((name === "pull_request" || name === "issues") && (!action || !ITEM_ACTIONS.has(action)))
|
|
122
|
+
return undefined;
|
|
123
|
+
const sender = str(obj(body.sender).login);
|
|
124
|
+
const numbers = numbersOf(name, body);
|
|
197
125
|
const lower = repo.toLowerCase();
|
|
198
126
|
return {
|
|
199
127
|
source: "github",
|
|
200
128
|
name,
|
|
201
|
-
topics: [`github:${lower}`, ...
|
|
202
|
-
summary:
|
|
129
|
+
topics: [`github:${lower}`, ...numbers.map((n) => `github:${lower}#${n}`)],
|
|
130
|
+
summary: githubSummary({ name, body, repo, action, sender, by: sender ? ` by ${sender}` : "", numbers }),
|
|
203
131
|
};
|
|
204
132
|
}
|
|
205
133
|
/** A delivery to `/hook/<topic>`: a JSON body's `text`, `summary` or `message` field, else the body itself. */
|
|
@@ -210,16 +138,8 @@ export function genericEvent(topic, body) {
|
|
|
210
138
|
text = str(parsed.text) ?? str(parsed.summary) ?? str(parsed.message) ?? body;
|
|
211
139
|
}
|
|
212
140
|
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");
|
|
141
|
+
return { source: "hook", name: topic, topics: [topic], summary: hookSummary(text) };
|
|
218
142
|
}
|
|
219
|
-
const isNotFound = (error) => {
|
|
220
|
-
const tag = error?._tag;
|
|
221
|
-
return typeof tag === "string" && tag.includes("NotFound");
|
|
222
|
-
};
|
|
223
143
|
/**
|
|
224
144
|
* Delivers an event to every session subscribed to one of its topics, once per session. A session
|
|
225
145
|
* OpenCode no longer knows loses its subscriptions.
|
|
@@ -233,7 +153,7 @@ export async function dispatch(ports, event, delivery) {
|
|
|
233
153
|
const deliver = (sessionID) => ports.session
|
|
234
154
|
.synthetic({
|
|
235
155
|
sessionID,
|
|
236
|
-
text: envelope(event.source,
|
|
156
|
+
text: envelope(event.source, webhookText(event.summary), {
|
|
237
157
|
event: event.name,
|
|
238
158
|
...(delivery ? { delivery } : {}),
|
|
239
159
|
}),
|
|
@@ -258,25 +178,6 @@ const header = (request, name) => {
|
|
|
258
178
|
const value = request.headers[name];
|
|
259
179
|
return Array.isArray(value) ? value[0] : value;
|
|
260
180
|
};
|
|
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
181
|
const isResponse = (value) => "status" in value;
|
|
281
182
|
const decode = (raw) => {
|
|
282
183
|
try {
|
|
@@ -328,11 +229,10 @@ async function deliver(ports, request, event, digest, seen) {
|
|
|
328
229
|
return { status: 202, body: `delivered to ${delivered} session(s)` };
|
|
329
230
|
}
|
|
330
231
|
/**
|
|
331
|
-
* Handles one request: `POST /github` with GitHub's headers, or `POST /hook/<name>` signed over
|
|
332
|
-
*
|
|
333
|
-
* and a signature already accepted is ignored, so a captured delivery cannot be replayed.
|
|
232
|
+
* Handles one request: `POST /github` with GitHub's headers, or `POST /hook/<name>` signed over name
|
|
233
|
+
* and body; anything unsigned is refused before parsing, and a signature already accepted is ignored.
|
|
334
234
|
*/
|
|
335
|
-
export async function receive(ports, secret, request, seen = new
|
|
235
|
+
export async function receive(ports, secret, request, seen = new Set()) {
|
|
336
236
|
const routed = route(request);
|
|
337
237
|
if (isResponse(routed))
|
|
338
238
|
return routed;
|
|
@@ -346,7 +246,7 @@ export async function receive(ports, secret, request, seen = new Seen()) {
|
|
|
346
246
|
return event;
|
|
347
247
|
// Marked with no await since the check above, so a concurrent replay is refused, and unmarked if
|
|
348
248
|
// the delivery reached nobody it was meant for, so a retry or a GitHub Redeliver can still get through.
|
|
349
|
-
seen
|
|
249
|
+
addBounded(seen, digest, REMEMBERED);
|
|
350
250
|
return deliver(ports, request, event, digest, seen);
|
|
351
251
|
}
|
|
352
252
|
class TooLarge extends Error {
|
|
@@ -374,7 +274,8 @@ function readBody(request, maxBytes) {
|
|
|
374
274
|
}
|
|
375
275
|
/** Starts the HTTP receiver; `ports()` picks a live plugin instance for each request. */
|
|
376
276
|
export function listen(config, ports) {
|
|
377
|
-
|
|
277
|
+
// Digests of accepted deliveries, newest last; shared by every request this receiver handles.
|
|
278
|
+
const seen = new Set();
|
|
378
279
|
const server = createServer((request, response) => {
|
|
379
280
|
const reply = ({ status, body }) => {
|
|
380
281
|
response.writeHead(status, { "content-type": "text/plain; charset=utf-8", connection: "close" });
|
|
@@ -404,9 +305,8 @@ export function listen(config, ports) {
|
|
|
404
305
|
});
|
|
405
306
|
}
|
|
406
307
|
/**
|
|
407
|
-
* Reads the
|
|
408
|
-
*
|
|
409
|
-
* the option itself, so it stays out of opencode.json. Undefined when the receiver is not configured.
|
|
308
|
+
* Reads the `webhook` option, undefined when the receiver is not configured; the secret comes from
|
|
309
|
+
* `secretFile` or the env var `secretEnv` (default `COURIER_WEBHOOK_SECRET`), never from the option.
|
|
410
310
|
*/
|
|
411
311
|
export function readConfig(options, env = process.env) {
|
|
412
312
|
const option = obj(options).webhook;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-courier",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.0",
|
|
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 {};
|