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/index.d.ts
CHANGED
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
import { Plugin } from "@opencode/plugin";
|
|
2
2
|
import { Plugin as EffectPlugin } from "@opencode/plugin/effect";
|
|
3
|
-
import { type QuestionPorts } from "./question.js";
|
|
4
|
-
/** What a question relay needs from the plugin instance that wraps the question tool. */
|
|
5
|
-
export interface RelaySlot {
|
|
6
|
-
ports?: QuestionPorts;
|
|
7
|
-
}
|
|
8
3
|
/**
|
|
9
|
-
* The courier tools
|
|
10
|
-
*
|
|
4
|
+
* The courier tools and webhook receiver, as a promise plugin, which joins the hub that runs the
|
|
5
|
+
* scheduler and the event watcher. Its member of the hub carries the instance's location, by which
|
|
6
|
+
* the question relay, the Effect half of the same instance, finds its ports.
|
|
11
7
|
*/
|
|
12
|
-
export declare const courier: (
|
|
8
|
+
export declare const courier: () => Plugin.Plugin;
|
|
13
9
|
/**
|
|
14
10
|
* The plugin OpenCode loads. It is an Effect plugin, because the question relay wraps OpenCode's own
|
|
15
11
|
* question tool, whose execute the promise API hands out only as a promise that cannot be
|
package/dist/index.js
CHANGED
|
@@ -1,79 +1,25 @@
|
|
|
1
1
|
import { Plugin } from "@opencode/plugin";
|
|
2
2
|
import { Plugin as EffectPlugin } from "@opencode/plugin/effect";
|
|
3
3
|
import { fromPromise } from "@opencode/plugin/promise/adapter";
|
|
4
|
-
import { Effect
|
|
4
|
+
import { Effect } from "effect";
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import { answerQuestion, isQuestion, joinRelay, noticeCutOff, pendingQuestions, relayQuestions } from "./question.js";
|
|
6
|
+
import { headOf, inspectWorktree } from "./cleanup.js";
|
|
7
|
+
import { hub, join, permissions, portsAt, track } from "./hub.js";
|
|
8
|
+
import { pendingOf } from "./relay.js";
|
|
9
|
+
import { joinRelay, noticeCutOff, pendingQuestions, relayQuestions } from "./question/index.js";
|
|
11
10
|
import { pruneExpired } from "./roster.js";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
11
|
+
import { addTools } from "./tools.js";
|
|
12
|
+
import { watchFromHub } from "./watch.js";
|
|
14
13
|
import { builtVersions, versionNotice } from "./version.js";
|
|
15
|
-
import { listen, readConfig
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
message: Schema.String.annotate({ description: "The message text." }),
|
|
25
|
-
queue: Schema.optional(Schema.Boolean.annotate({ description: "Wait until the target's current turn ends instead of steering it now." })),
|
|
26
|
-
});
|
|
27
|
-
const StatusInput = Schema.Struct({
|
|
28
|
-
sessionID: Schema.String.annotate({ description: "The session to look at." }),
|
|
29
|
-
});
|
|
30
|
-
const ChildrenInput = Schema.Struct({
|
|
31
|
-
sessionID: Schema.optional(Schema.String.annotate({ description: "The session whose children to list; defaults to this one." })),
|
|
32
|
-
});
|
|
33
|
-
const CleanupInput = Schema.Struct({
|
|
34
|
-
sessionID: Schema.String.annotate({ description: "The isolated child whose worktree to remove." }),
|
|
35
|
-
force: Schema.optional(Schema.Boolean.annotate({
|
|
36
|
-
description: "Remove it even with uncommitted changes or commits on no branch; that work is lost.",
|
|
37
|
-
})),
|
|
38
|
-
});
|
|
39
|
-
const LaterInput = Schema.Struct({
|
|
40
|
-
message: Schema.String.annotate({ description: "The message to deliver." }),
|
|
41
|
-
delayMinutes: Schema.optional(
|
|
42
|
-
// Some models send the number as a string ("3", "3.0") whatever the schema says, and resend it
|
|
43
|
-
// unchanged after the error, so a string is accepted and schedule reads it as a number.
|
|
44
|
-
Schema.Union([Schema.Number, Schema.String]).annotate({
|
|
45
|
-
description: "Deliver this many minutes from now. Give this or at.",
|
|
46
|
-
})),
|
|
47
|
-
at: Schema.optional(Schema.String.annotate({ description: "Deliver at this ISO 8601 time. Give this or delayMinutes." })),
|
|
48
|
-
sessionID: Schema.optional(Schema.String.annotate({ description: "The session to deliver to; defaults to this one." })),
|
|
49
|
-
});
|
|
50
|
-
const AnswerInput = Schema.Struct({
|
|
51
|
-
sessionID: Schema.String.annotate({ description: "The session you started that is waiting, as its notice names it." }),
|
|
52
|
-
requestID: Schema.String.annotate({ description: "The request id from the notice." }),
|
|
53
|
-
reply: Schema.optional(Schema.Literals(["once", "always", "reject"]).annotate({
|
|
54
|
-
description: "For a permission request: the choice the person made, once, always (only when the notice offers it) or reject.",
|
|
55
|
-
})),
|
|
56
|
-
message: Schema.optional(Schema.String.annotate({ description: "For a permission request, with reject: the person's reason, passed on with the refusal." })),
|
|
57
|
-
answers: Schema.optional(Schema.Array(Schema.Union([Schema.String, Schema.Array(Schema.String)])).annotate({
|
|
58
|
-
description: "For a question: one entry per question, in order, each the label the person chose or the text they gave, " +
|
|
59
|
-
"or a list of labels where the question allows several.",
|
|
60
|
-
})),
|
|
61
|
-
});
|
|
62
|
-
const CancelInput = Schema.Struct({
|
|
63
|
-
id: Schema.String.annotate({ description: "The id courier_later returned." }),
|
|
64
|
-
});
|
|
65
|
-
const SubscribeInput = Schema.Struct({
|
|
66
|
-
topic: Schema.String.annotate({
|
|
67
|
-
description: "owner/repo for every event of a GitHub repository, owner/repo#<number> for one pull request or issue " +
|
|
68
|
-
"(reviews, comments, completed CI runs), or a plain name for deliveries posted to /hook/<name>.",
|
|
69
|
-
}),
|
|
70
|
-
sessionID: Schema.optional(Schema.String.annotate({ description: "The session to subscribe; defaults to this one." })),
|
|
71
|
-
});
|
|
72
|
-
const UnsubscribeInput = Schema.Struct({
|
|
73
|
-
topic: Schema.optional(Schema.String.annotate({ description: "The topic to drop; all of this session's when omitted." })),
|
|
74
|
-
sessionID: Schema.optional(Schema.String.annotate({ description: "The session to unsubscribe; defaults to this one." })),
|
|
75
|
-
});
|
|
76
|
-
const receivers = processWide("opencode-courier.receiver", () => ({}));
|
|
14
|
+
import { listen, readConfig } from "./webhook.js";
|
|
15
|
+
/**
|
|
16
|
+
* The webhook receiver, one per process like the claim set: every instance configured with a
|
|
17
|
+
* `webhook` option joins `instances`, the first one starts the server, and the last one to unload
|
|
18
|
+
* stops it. Requests are served with any live instance's ports; sessions and storage are shared.
|
|
19
|
+
* A receiver that could not listen is dropped, so the next instance to load tries again, and a new
|
|
20
|
+
* one waits for the previous one to finish closing, as on a plugin reload.
|
|
21
|
+
*/
|
|
22
|
+
const receivers = hub.receivers;
|
|
77
23
|
const sameSettings = (a, b) => a.port === b.port && a.host === b.host && a.secret === b.secret && a.maxBytes === b.maxBytes;
|
|
78
24
|
function startReceiver(config, log) {
|
|
79
25
|
const instances = new Set();
|
|
@@ -118,46 +64,18 @@ function joinReceiver(config, ports) {
|
|
|
118
64
|
receivers.closing = undefined;
|
|
119
65
|
};
|
|
120
66
|
}
|
|
121
|
-
// One
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
//
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
seen: new Set(),
|
|
128
|
-
waiting: new Set(),
|
|
129
|
-
answered: new Set(),
|
|
130
|
-
}));
|
|
131
|
-
// The permission domain of every loaded instance, under a key of its own. OpenCode keeps a request
|
|
132
|
-
// where its session runs, so a request of an isolated child is answered through the instance loaded
|
|
133
|
-
// in its worktree.
|
|
134
|
-
const locations = processWide("opencode-courier.locations", () => new Map());
|
|
135
|
-
const permissions = () => locations.values();
|
|
136
|
-
const rethrow = (tool) => (error) => {
|
|
137
|
-
throw describeFailure(tool, error);
|
|
138
|
-
};
|
|
139
|
-
function describeCleanup(result) {
|
|
140
|
-
if (result.outcome === "removed")
|
|
141
|
-
return `Removed the worktree ${result.directory} of ${result.sessionID}.`;
|
|
142
|
-
if (result.outcome === "gone")
|
|
143
|
-
return `The worktree ${result.directory} of ${result.sessionID} was already gone; dropped it from courier_children.`;
|
|
144
|
-
return `Kept the worktree ${result.directory} of ${result.sessionID}: it has ${result.reason}. Commit or branch what you want to keep, or call courier_cleanup again with force: true to discard it.`;
|
|
145
|
-
}
|
|
146
|
-
function describeAnswer(result) {
|
|
147
|
-
const kind = isQuestion(result.requestID) ? "question" : "request";
|
|
148
|
-
if (!result.answered)
|
|
149
|
-
return (`${result.sessionID} no longer waits on ${kind} ${result.requestID}: it was answered some other way, or the ` +
|
|
150
|
-
"session stopped waiting. Nothing was passed on; tell the person their answer is not needed.");
|
|
151
|
-
if ("reply" in result)
|
|
152
|
-
return `Passed on ${result.reply} for request ${result.requestID} of ${result.sessionID}, which carries on and reports back with courier_send. ${END_TURN}`;
|
|
153
|
-
const how = "by" in result && result.by === "message" ? " as a message, since its question had been cut off" : "";
|
|
154
|
-
return `Passed the answers to question ${result.requestID} on to ${result.sessionID}${how}; it carries on and reports back with courier_send. ${END_TURN}`;
|
|
155
|
-
}
|
|
67
|
+
// One set of handled events for every instance in the process, since the hub's two subscriptions,
|
|
68
|
+
// and a copy of another hub version or one from before the hub, are sent the same events, and of
|
|
69
|
+
// the permission requests sessions were told about. The scheduler's claim set is the hub's
|
|
70
|
+
// own business: the hub runs the scheduler once for its members.
|
|
71
|
+
const { watched } = hub;
|
|
72
|
+
const watchState = { ...watched, forms: hub.forms };
|
|
156
73
|
/**
|
|
157
|
-
* The courier tools
|
|
158
|
-
*
|
|
74
|
+
* The courier tools and webhook receiver, as a promise plugin, which joins the hub that runs the
|
|
75
|
+
* scheduler and the event watcher. Its member of the hub carries the instance's location, by which
|
|
76
|
+
* the question relay, the Effect half of the same instance, finds its ports.
|
|
159
77
|
*/
|
|
160
|
-
export const courier = (
|
|
78
|
+
export const courier = () => Plugin.define({
|
|
161
79
|
id: "courier",
|
|
162
80
|
setup: async (ctx) => {
|
|
163
81
|
const log = (message) => console.error(message);
|
|
@@ -176,6 +94,7 @@ export const courier = (relay = {}) => Plugin.define({
|
|
|
176
94
|
session: ctx.session,
|
|
177
95
|
directory: ctx.location.directory,
|
|
178
96
|
now: Date.now,
|
|
97
|
+
timing: { closingGraceMs: 30_000, passingWaitMs: 30_000, relayWaitMs: 30_000, dismissalGraceMs: 2_000 },
|
|
179
98
|
newID: () => `question_${randomUUID()}`,
|
|
180
99
|
log,
|
|
181
100
|
};
|
|
@@ -198,27 +117,6 @@ export const courier = (relay = {}) => Plugin.define({
|
|
|
198
117
|
};
|
|
199
118
|
const cleanupPorts = { ...ports, inspect: inspectWorktree };
|
|
200
119
|
const answerPorts = { storage: ctx.storage, permissions };
|
|
201
|
-
// One tool answers both: a question's id is the plugin's own, a permission request's OpenCode's.
|
|
202
|
-
const answerRequest = async (callerID, input) => {
|
|
203
|
-
const { sessionID, requestID } = input;
|
|
204
|
-
if (isQuestion(requestID)) {
|
|
205
|
-
if (input.reply !== undefined || input.message !== undefined)
|
|
206
|
-
throw new Error(`${requestID} is a question: pass the person's answers in answers, not reply or message.`);
|
|
207
|
-
if (input.answers === undefined)
|
|
208
|
-
throw new Error(`${requestID} is a question: answers is required, one entry per question.`);
|
|
209
|
-
return answerQuestion(questionPorts, callerID, { sessionID, requestID, answers: input.answers });
|
|
210
|
-
}
|
|
211
|
-
if (input.answers !== undefined)
|
|
212
|
-
throw new Error(`${requestID} is a permission request: pass the person's choice in reply (once, always or reject), not answers.`);
|
|
213
|
-
if (input.reply === undefined)
|
|
214
|
-
throw new Error(`${requestID} is a permission request: reply is required, once, always or reject.`);
|
|
215
|
-
return answer(answerPorts, watched.waiting, callerID, {
|
|
216
|
-
sessionID,
|
|
217
|
-
requestID,
|
|
218
|
-
reply: input.reply,
|
|
219
|
-
...(input.message !== undefined ? { message: input.message } : {}),
|
|
220
|
-
});
|
|
221
|
-
};
|
|
222
120
|
const later = {
|
|
223
121
|
storage: ctx.storage,
|
|
224
122
|
session: ctx.session,
|
|
@@ -234,188 +132,51 @@ export const courier = (relay = {}) => Plugin.define({
|
|
|
234
132
|
catch (error) {
|
|
235
133
|
later.log(`courier webhook: not started: ${error instanceof Error ? error.message : String(error)}`);
|
|
236
134
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
const child = await spawn(ports, context.sessionID, input).catch(rethrow("courier_spawn"));
|
|
250
|
-
const warning = child.rosterError ? ` It is not on your courier_children list: ${child.rosterError}` : "";
|
|
251
|
-
return {
|
|
252
|
-
content: `Started session ${child.sessionID} in ${child.directory}. It will report back with courier_send, which ` +
|
|
253
|
-
"starts a new turn for you. Once you have started every session you need, end your turn: reply without " +
|
|
254
|
-
`calling more tools. That does not drop the task; you carry on with it when the reports arrive.${warning}`,
|
|
255
|
-
metadata: child,
|
|
256
|
-
};
|
|
257
|
-
},
|
|
258
|
-
});
|
|
259
|
-
tools.add({
|
|
260
|
-
name: "courier_send",
|
|
261
|
-
options: { codemode: false },
|
|
262
|
-
description: "Deliver a message to another OpenCode session. If that session is idle, OpenCode starts a new turn for it. " +
|
|
263
|
-
"Use it to report back to the session that started you, or to steer a session you started.",
|
|
264
|
-
input: SendInput,
|
|
265
|
-
execute: async (input, context) => {
|
|
266
|
-
const delivered = await send(ports, context.sessionID, input).catch(rethrow("courier_send"));
|
|
267
|
-
return { content: `Delivered to ${input.sessionID}.`, metadata: delivered };
|
|
268
|
-
},
|
|
269
|
-
});
|
|
270
|
-
tools.add({
|
|
271
|
-
name: "courier_status",
|
|
272
|
-
options: { codemode: false },
|
|
273
|
-
description: "Look once at a session's state and its last reply, e.g. on a scheduled check-in. Not for waiting: " +
|
|
274
|
-
"sessions you started report back on their own.",
|
|
275
|
-
input: StatusInput,
|
|
276
|
-
execute: async (input) => {
|
|
277
|
-
const result = await status(ports, input).catch(rethrow("courier_status"));
|
|
278
|
-
return { content: JSON.stringify(result, null, 2), metadata: result };
|
|
279
|
-
},
|
|
280
|
-
});
|
|
281
|
-
tools.add({
|
|
282
|
-
name: "courier_children",
|
|
283
|
-
options: { codemode: false },
|
|
284
|
-
description: "List the sessions this one started with courier_spawn, with each one's state and last reply, e.g. after a " +
|
|
285
|
-
"compaction or restart. Like courier_status, for a one-off look, not for waiting.",
|
|
286
|
-
input: ChildrenInput,
|
|
287
|
-
execute: async (input, context) => {
|
|
288
|
-
const listed = await listChildren(ports, input.sessionID || context.sessionID).catch(rethrow("courier_children"));
|
|
289
|
-
return {
|
|
290
|
-
content: listed.length ? JSON.stringify(listed, null, 2) : "No sessions started with courier_spawn.",
|
|
291
|
-
metadata: { children: listed },
|
|
292
|
-
};
|
|
293
|
-
},
|
|
294
|
-
});
|
|
295
|
-
tools.add({
|
|
296
|
-
name: "courier_cleanup",
|
|
297
|
-
options: { codemode: false },
|
|
298
|
-
description: "Remove the git worktree of a session you started with isolate: true, once you have what you need from it, " +
|
|
299
|
-
"and drop it from courier_children. A worktree with uncommitted changes or commits on no branch is kept and " +
|
|
300
|
-
"the result lists them; commit or branch what you want, or pass force: true to discard it.",
|
|
301
|
-
input: CleanupInput,
|
|
302
|
-
execute: async (input, context) => {
|
|
303
|
-
const result = await cleanup(cleanupPorts, context.sessionID, input).catch(rethrow("courier_cleanup"));
|
|
304
|
-
return { content: describeCleanup(result), metadata: result };
|
|
305
|
-
},
|
|
306
|
-
});
|
|
307
|
-
tools.add({
|
|
308
|
-
name: "courier_answer",
|
|
309
|
-
options: { codemode: false },
|
|
310
|
-
description: "Pass on the answer to a permission request or a question that a session you started with courier_spawn (or " +
|
|
311
|
-
"one started from it) waits on, after a notice from it named the request. The answer is the person's, not yours: " +
|
|
312
|
-
"first ask the person you are working with, offering the choices the notice lists, then call this with what " +
|
|
313
|
-
"they chose: reply for a permission request, answers for a question. Never choose for them. The session " +
|
|
314
|
-
"carries on once it has the answer.",
|
|
315
|
-
input: AnswerInput,
|
|
316
|
-
execute: async (input, context) => {
|
|
317
|
-
const result = await answerRequest(context.sessionID, input).catch(rethrow("courier_answer"));
|
|
318
|
-
return { content: describeAnswer(result), metadata: result };
|
|
319
|
-
},
|
|
320
|
-
});
|
|
321
|
-
tools.add({
|
|
322
|
-
name: "courier_later",
|
|
323
|
-
options: { codemode: false },
|
|
324
|
-
description: "Schedule a message for a session (this one by default), delivered when due and waking it if idle. " +
|
|
325
|
-
"Use it as a safety net when you start sessions: schedule one check-in, end your turn by replying without " +
|
|
326
|
-
"calling more tools, and cancel it with courier_cancel if the child reports first. Survives server restarts; " +
|
|
327
|
-
"may arrive up to ~15 seconds late.",
|
|
328
|
-
input: LaterInput,
|
|
329
|
-
execute: async (input, context) => {
|
|
330
|
-
const entry = await schedule(later, context.sessionID, input).catch(rethrow("courier_later"));
|
|
331
|
-
const fireAt = new Date(entry.fireAt).toISOString();
|
|
332
|
-
const next = entry.sessionID === context.sessionID
|
|
333
|
-
? "It arrives when due, after your current turn if one is running, so do not wait for it: once nothing " +
|
|
334
|
-
"else is left to do now, end your turn by replying without calling more tools. If what it checks on " +
|
|
335
|
-
"reports first, cancel it then with courier_cancel."
|
|
336
|
-
: "Cancel it with courier_cancel if it is no longer needed.";
|
|
337
|
-
return {
|
|
338
|
-
content: `Scheduled ${entry.id} for ${fireAt}, to ${entry.sessionID}. ${next}`,
|
|
339
|
-
metadata: { id: entry.id, fireAt, sessionID: entry.sessionID },
|
|
340
|
-
};
|
|
341
|
-
},
|
|
342
|
-
});
|
|
343
|
-
tools.add({
|
|
344
|
-
name: "courier_cancel",
|
|
345
|
-
options: { codemode: false },
|
|
346
|
-
description: "Cancel a message scheduled with courier_later, e.g. because the child it was waiting for reported.",
|
|
347
|
-
input: CancelInput,
|
|
348
|
-
execute: async (input) => {
|
|
349
|
-
const cancelled = await cancel(later, input.id).catch(rethrow("courier_cancel"));
|
|
350
|
-
return {
|
|
351
|
-
content: cancelled ? `Cancelled ${input.id}.` : `Nothing pending under ${input.id}; it may have been delivered.`,
|
|
352
|
-
metadata: { id: input.id, cancelled },
|
|
353
|
-
};
|
|
354
|
-
},
|
|
355
|
-
});
|
|
356
|
-
tools.add({
|
|
357
|
-
name: "courier_subscribe",
|
|
358
|
-
options: { codemode: false },
|
|
359
|
-
description: "Wake a session (this one by default) when a webhook arrives for a topic: a GitHub repository, one of its " +
|
|
360
|
-
"pull requests or issues (reviews, comments, completed CI runs), or a named generic hook. Each matching " +
|
|
361
|
-
"delivery arrives as a message, queued behind any running turn. End your turn and wait; do not poll.",
|
|
362
|
-
input: SubscribeInput,
|
|
363
|
-
execute: async (input, context) => {
|
|
364
|
-
const sessionID = input.sessionID ?? context.sessionID;
|
|
365
|
-
const subscription = await subscribe(hooks, sessionID, input.topic).catch(rethrow("courier_subscribe"));
|
|
366
|
-
const receiving = (await receivers.current?.server) !== undefined;
|
|
367
|
-
const note = receiving
|
|
368
|
-
? ""
|
|
369
|
-
: " Note: no webhook receiver runs in this OpenCode server (see the plugin's webhook option), so nothing will arrive yet.";
|
|
370
|
-
return {
|
|
371
|
-
content: `Subscribed ${sessionID} to ${subscription.topic}.${note}`,
|
|
372
|
-
metadata: { sessionID, topic: subscription.topic, receiver: receiving },
|
|
373
|
-
};
|
|
374
|
-
},
|
|
375
|
-
});
|
|
376
|
-
tools.add({
|
|
377
|
-
name: "courier_unsubscribe",
|
|
378
|
-
options: { codemode: false },
|
|
379
|
-
description: "Stop webhook deliveries for a topic, or all of a session's topics, e.g. once its pull request is merged.",
|
|
380
|
-
input: UnsubscribeInput,
|
|
381
|
-
execute: async (input, context) => {
|
|
382
|
-
const sessionID = input.sessionID ?? context.sessionID;
|
|
383
|
-
const dropped = await unsubscribe(hooks, sessionID, input.topic).catch(rethrow("courier_unsubscribe"));
|
|
384
|
-
return {
|
|
385
|
-
content: dropped.length ? `Unsubscribed ${sessionID} from ${dropped.join(", ")}.` : `${sessionID} had no matching subscription.`,
|
|
386
|
-
metadata: { sessionID, dropped },
|
|
387
|
-
};
|
|
388
|
-
},
|
|
389
|
-
});
|
|
390
|
-
});
|
|
391
|
-
void pruneExpired(ctx.storage, Date.now()).catch((error) => log(`courier roster prune: ${String(error)}`));
|
|
392
|
-
relay.ports = questionPorts;
|
|
135
|
+
const toolPorts = {
|
|
136
|
+
courier: ports,
|
|
137
|
+
cleanup: cleanupPorts,
|
|
138
|
+
later,
|
|
139
|
+
answer: answerPorts,
|
|
140
|
+
questions: questionPorts,
|
|
141
|
+
hooks,
|
|
142
|
+
waiting: watched.waiting,
|
|
143
|
+
receiving: async () => (await receivers.current?.server) !== undefined,
|
|
144
|
+
};
|
|
145
|
+
await ctx.tool.transform((tools) => addTools(tools, toolPorts));
|
|
146
|
+
const pruned = pruneExpired(ctx.storage, Date.now()).catch((error) => log(`courier roster prune: ${String(error)}`));
|
|
393
147
|
const leaveRelay = joinRelay(questionPorts);
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
148
|
+
const noticed = noticeCutOff(questionPorts).catch((error) => questionPorts.log(`courier question: stored questions: ${String(error)}`));
|
|
149
|
+
const watchPorts = {
|
|
150
|
+
storage: ctx.storage,
|
|
151
|
+
session: ctx.session,
|
|
152
|
+
event: ctx.event,
|
|
153
|
+
permissions,
|
|
154
|
+
now: questionPorts.now,
|
|
155
|
+
log: later.log,
|
|
156
|
+
};
|
|
157
|
+
// The hub's subscriptions to OpenCode's events, one and a standby, start with this copy's watcher.
|
|
158
|
+
watchFromHub(hub, watchState);
|
|
159
|
+
const member = {
|
|
160
|
+
directory: ctx.location.directory,
|
|
161
|
+
location: ctx.location,
|
|
162
|
+
permission: ctx.permission,
|
|
163
|
+
later,
|
|
164
|
+
watch: watchPorts,
|
|
165
|
+
questions: questionPorts,
|
|
166
|
+
log,
|
|
403
167
|
};
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
locations.set(location, ctx.permission);
|
|
409
|
-
void watchChildren({ storage: ctx.storage, session: ctx.session, event: ctx.event, permission: ctx.permission, log: later.log }, watched, watching.signal);
|
|
168
|
+
const leaveHub = join(member);
|
|
169
|
+
// The leave waits for them, so they do not run on against a location that is closing.
|
|
170
|
+
track(member, pruned);
|
|
171
|
+
track(member, noticed);
|
|
410
172
|
const leave = webhook ? joinReceiver(webhook, hooks) : undefined;
|
|
411
173
|
return async () => {
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
if (relay.ports === questionPorts)
|
|
416
|
-
relay.ports = undefined;
|
|
174
|
+
// Waits, for a moment at most, for the work this instance leaves behind, and on the last
|
|
175
|
+
// leave for the scheduler's owner key to be released, so another server takes over at once.
|
|
176
|
+
const leftHub = leaveHub();
|
|
417
177
|
leaveRelay();
|
|
418
178
|
await leave?.();
|
|
179
|
+
await leftHub;
|
|
419
180
|
};
|
|
420
181
|
},
|
|
421
182
|
});
|
|
@@ -429,8 +190,9 @@ export const courier = (relay = {}) => Plugin.define({
|
|
|
429
190
|
export default EffectPlugin.define({
|
|
430
191
|
id: "courier",
|
|
431
192
|
effect: (host) => Effect.gen(function* () {
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
193
|
+
yield* fromPromise(courier()).effect(host);
|
|
194
|
+
// The promise plugin's instance is handed this same location and has just joined with it: its
|
|
195
|
+
// member, bound now, while it stays loaded.
|
|
196
|
+
yield* relayQuestions(host, portsAt(host.location));
|
|
435
197
|
}),
|
|
436
198
|
});
|
package/dist/json.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Readers for untyped JSON, such as a webhook payload or a plugin option: the value when it has the type, else nothing. */
|
|
2
|
+
export declare const str: (value: unknown) => string | undefined;
|
|
3
|
+
export declare const num: (value: unknown) => number | undefined;
|
|
4
|
+
export declare const obj: (value: unknown) => Record<string, any>;
|
package/dist/json.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Readers for untyped JSON, such as a webhook payload or a plugin option: the value when it has the type, else nothing. */
|
|
2
|
+
export const str = (value) => (typeof value === "string" ? value : undefined);
|
|
3
|
+
export const num = (value) => (typeof value === "number" && Number.isInteger(value) ? value : undefined);
|
|
4
|
+
export const obj = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
package/dist/later.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Plugin } from "@opencode/plugin";
|
|
2
2
|
import { type Storage } from "./storage.js";
|
|
3
3
|
type Context = Plugin.Context;
|
|
4
|
-
/** How often
|
|
4
|
+
/** How often the hub's scheduler looks for due messages; a delivery can be this late. */
|
|
5
5
|
export declare const TICK_MS = 15000;
|
|
6
6
|
export interface LaterEntry {
|
|
7
7
|
readonly id: string;
|
package/dist/later.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { envelope } from "./
|
|
1
|
+
import { envelope } from "./notices.js";
|
|
2
2
|
import { scanAll } from "./storage.js";
|
|
3
|
-
/** How often
|
|
3
|
+
/** How often the hub's scheduler looks for due messages; a delivery can be this late. */
|
|
4
4
|
export const TICK_MS = 15_000;
|
|
5
5
|
const PREFIX = "later/";
|
|
6
6
|
/** The latest time a Date can hold; a message due after it could never be shown or delivered. */
|