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.
Files changed (46) hide show
  1. package/README.md +139 -94
  2. package/dist/bounded.d.ts +10 -0
  3. package/dist/bounded.js +21 -0
  4. package/dist/cleanup.d.ts +13 -18
  5. package/dist/cleanup.js +38 -19
  6. package/dist/courier.d.ts +1 -5
  7. package/dist/courier.js +11 -23
  8. package/dist/hub.d.ts +325 -0
  9. package/dist/hub.js +340 -0
  10. package/dist/index.d.ts +4 -8
  11. package/dist/index.js +72 -310
  12. package/dist/json.d.ts +4 -0
  13. package/dist/json.js +4 -0
  14. package/dist/later.d.ts +1 -1
  15. package/dist/later.js +14 -5
  16. package/dist/notices.d.ts +246 -0
  17. package/dist/notices.js +399 -0
  18. package/dist/question/answer.d.ts +35 -0
  19. package/dist/question/answer.js +217 -0
  20. package/dist/question/index.d.ts +6 -0
  21. package/dist/question/index.js +7 -0
  22. package/dist/question/lifecycle.d.ts +52 -0
  23. package/dist/question/lifecycle.js +100 -0
  24. package/dist/question/pure.d.ts +49 -0
  25. package/dist/question/pure.js +75 -0
  26. package/dist/question/relay.d.ts +5 -0
  27. package/dist/question/relay.js +283 -0
  28. package/dist/question/runtime.d.ts +21 -0
  29. package/dist/question/runtime.js +75 -0
  30. package/dist/question/shared.d.ts +24 -0
  31. package/dist/question/shared.js +24 -0
  32. package/dist/relay.d.ts +31 -33
  33. package/dist/relay.js +5 -53
  34. package/dist/roster.d.ts +29 -1
  35. package/dist/roster.js +101 -9
  36. package/dist/storage.d.ts +12 -0
  37. package/dist/storage.js +16 -3
  38. package/dist/tools.d.ts +74 -0
  39. package/dist/tools.js +228 -0
  40. package/dist/watch.d.ts +28 -37
  41. package/dist/watch.js +158 -46
  42. package/dist/webhook.d.ts +1 -13
  43. package/dist/webhook.js +121 -177
  44. package/package.json +16 -5
  45. package/dist/question.d.ts +0 -158
  46. package/dist/question.js +0 -693
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, scheduler, webhook receiver and event watcher, as a promise plugin. `relay`
10
- * receives this instance's ports for the question relay while it is loaded.
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: (relay?: RelaySlot) => Plugin.Plugin;
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,78 +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, Schema } from "effect";
4
+ import { Effect } from "effect";
5
5
  import { randomUUID } from "node:crypto";
6
- import { describeFailure, END_TURN, listChildren, send, spawn, status } from "./courier.js";
7
- import { cleanup, headOf, inspectWorktree } from "./cleanup.js";
8
- import { cancel, deliverDue, schedule, TICK_MS } from "./later.js";
9
- import { answer, pendingOf } from "./relay.js";
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 { watchChildren } from "./watch.js";
11
+ import { addTools } from "./tools.js";
12
+ import { watchFromHub } from "./watch.js";
13
13
  import { builtVersions, versionNotice } from "./version.js";
14
- import { listen, readConfig, subscribe, unsubscribe } from "./webhook.js";
15
- const SpawnInput = Schema.Struct({
16
- task: Schema.String.annotate({ description: "What the new session should do. It is told who started it and how to report back." }),
17
- title: Schema.optional(Schema.String.annotate({ description: "Session title; defaults to the task's first line." })),
18
- agent: Schema.optional(Schema.String.annotate({ description: "Agent to run the session with; defaults to the default agent." })),
19
- isolate: Schema.optional(Schema.Boolean.annotate({ description: "Run the session in its own git worktree so parallel sessions don't share files." })),
20
- });
21
- const SendInput = Schema.Struct({
22
- sessionID: Schema.String.annotate({ description: "The session to deliver to." }),
23
- message: Schema.String.annotate({ description: "The message text." }),
24
- queue: Schema.optional(Schema.Boolean.annotate({ description: "Wait until the target's current turn ends instead of steering it now." })),
25
- });
26
- const StatusInput = Schema.Struct({
27
- sessionID: Schema.String.annotate({ description: "The session to look at." }),
28
- });
29
- const ChildrenInput = Schema.Struct({
30
- sessionID: Schema.optional(Schema.String.annotate({ description: "The session whose children to list; defaults to this one." })),
31
- });
32
- const CleanupInput = Schema.Struct({
33
- sessionID: Schema.String.annotate({ description: "The isolated child whose worktree to remove." }),
34
- force: Schema.optional(Schema.Boolean.annotate({
35
- description: "Remove it even with uncommitted changes or commits on no branch; that work is lost.",
36
- })),
37
- });
38
- const LaterInput = Schema.Struct({
39
- message: Schema.String.annotate({ description: "The message to deliver." }),
40
- delayMinutes: Schema.optional(
41
- // Some models send the number as a string ("3", "3.0") whatever the schema says, and resend it
42
- // unchanged after the error, so a string is accepted and schedule reads it as a number.
43
- Schema.Union([Schema.Number, Schema.String]).annotate({
44
- description: "Deliver this many minutes from now. Give this or at.",
45
- })),
46
- at: Schema.optional(Schema.String.annotate({ description: "Deliver at this ISO 8601 time. Give this or delayMinutes." })),
47
- sessionID: Schema.optional(Schema.String.annotate({ description: "The session to deliver to; defaults to this one." })),
48
- });
49
- const AnswerInput = Schema.Struct({
50
- sessionID: Schema.String.annotate({ description: "The session you started that is waiting, as its notice names it." }),
51
- requestID: Schema.String.annotate({ description: "The request id from the notice." }),
52
- reply: Schema.optional(Schema.Literals(["once", "always", "reject"]).annotate({
53
- description: "For a permission request: the choice the person made, once, always (only when the notice offers it) or reject.",
54
- })),
55
- message: Schema.optional(Schema.String.annotate({ description: "For a permission request, with reject: the person's reason, passed on with the refusal." })),
56
- answers: Schema.optional(Schema.Array(Schema.Union([Schema.String, Schema.Array(Schema.String)])).annotate({
57
- description: "For a question: one entry per question, in order, each the label the person chose or the text they gave, " +
58
- "or a list of labels where the question allows several.",
59
- })),
60
- });
61
- const CancelInput = Schema.Struct({
62
- id: Schema.String.annotate({ description: "The id courier_later returned." }),
63
- });
64
- const SubscribeInput = Schema.Struct({
65
- topic: Schema.String.annotate({
66
- description: "owner/repo for every event of a GitHub repository, owner/repo#<number> for one pull request or issue " +
67
- "(reviews, comments, completed CI runs), or a plain name for deliveries posted to /hook/<name>.",
68
- }),
69
- sessionID: Schema.optional(Schema.String.annotate({ description: "The session to subscribe; defaults to this one." })),
70
- });
71
- const UnsubscribeInput = Schema.Struct({
72
- topic: Schema.optional(Schema.String.annotate({ description: "The topic to drop; all of this session's when omitted." })),
73
- sessionID: Schema.optional(Schema.String.annotate({ description: "The session to unsubscribe; defaults to this one." })),
74
- });
75
- const receivers = (globalThis[Symbol.for("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;
76
23
  const sameSettings = (a, b) => a.port === b.port && a.host === b.host && a.secret === b.secret && a.maxBytes === b.maxBytes;
77
24
  function startReceiver(config, log) {
78
25
  const instances = new Set();
@@ -91,7 +38,8 @@ function startReceiver(config, log) {
91
38
  return undefined;
92
39
  }),
93
40
  };
94
- return (receivers.current = receiver);
41
+ receivers.current = receiver;
42
+ return receiver;
95
43
  }
96
44
  function joinReceiver(config, ports) {
97
45
  const receiver = receivers.current ?? startReceiver(config, ports.log);
@@ -116,48 +64,18 @@ function joinReceiver(config, ports) {
116
64
  receivers.closing = undefined;
117
65
  };
118
66
  }
119
- // One claim set for every instance in the process: OpenCode sets the plugin up once per project
120
- // location, and those instances share one storage.
121
- const claimed = (globalThis[Symbol.for("opencode-courier.claimed")] ??=
122
- new Set());
123
- // Likewise one set of handled events, since every instance may be sent the same event, and the
124
- // permission requests sessions were told about and have not answered.
125
- const watched = (globalThis[Symbol.for("opencode-courier.watched")] ??= {
126
- seen: new Set(),
127
- waiting: new Set(),
128
- answered: new Set(),
129
- });
130
- // The permission domain of every loaded instance, under a key of its own. OpenCode keeps a request
131
- // where its session runs, so a request of an isolated child is answered through the instance loaded
132
- // in its worktree.
133
- const locations = (globalThis[Symbol.for("opencode-courier.locations")] ??=
134
- 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, scheduler, webhook receiver and event watcher, as a promise plugin. `relay`
158
- * receives this instance's ports for the question relay while it is loaded.
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 = (relay = {}) => Plugin.define({
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
- await ctx.tool.transform((tools) => {
238
- tools.add({
239
- name: "courier_spawn",
240
- options: { codemode: false },
241
- description: "Start a new OpenCode session on a task and return immediately. It runs on your model. The session reports " +
242
- "back with courier_send, which wakes this session, and you are told if its turn fails instead, it waits for a " +
243
- "permission or it asks a question. DO NOT poll it or call courier_status in a loop: once you have started the sessions " +
244
- "you need, end your turn by replying without calling more tools; each report starts a new turn in which you " +
245
- "carry on. For long tasks, also courier_later a check-in for yourself in case it never reports, and " +
246
- "courier_cancel it when it does.",
247
- input: SpawnInput,
248
- execute: async (input, context) => {
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
- void noticeCutOff(questionPorts).catch((error) => questionPorts.log(`courier question: stored questions: ${String(error)}`));
395
- let ticking = false;
396
- const tick = async () => {
397
- if (ticking)
398
- return;
399
- ticking = true;
400
- await deliverDue(later, claimed)
401
- .catch((error) => later.log(`courier_later scheduler: ${String(error)}`))
402
- .finally(() => (ticking = false));
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
- void tick();
405
- const timer = setInterval(tick, TICK_MS);
406
- const watching = new AbortController();
407
- const location = {};
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
- clearInterval(timer);
413
- watching.abort();
414
- locations.delete(location);
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
- const relay = {};
433
- yield* fromPromise(courier(relay)).effect(host);
434
- yield* relayQuestions(host, () => relay.ports);
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 each plugin instance looks for due messages; a delivery can be this late. */
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,18 +1,27 @@
1
- import { envelope } from "./courier.js";
1
+ import { envelope } from "./notices.js";
2
2
  import { scanAll } from "./storage.js";
3
- /** How often each plugin instance looks for due messages; a delivery can be this late. */
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. */
7
7
  const LATEST = 8.64e15;
8
- /** A plain decimal number of minutes, the only string form of delayMinutes taken ("0x10" is not). */
9
- const DECIMAL = /^\s*(\d+\.?\d*|\.\d+)\s*$/;
8
+ /**
9
+ * A plain decimal number of minutes, the only string form of delayMinutes taken ("0x10" is not),
10
+ * once trimmed of the whitespace `\s` matches.
11
+ */
12
+ const DECIMAL = /^(?:\d+(?:\.\d*)?|\.\d+)$/;
13
+ function minutesOf(given) {
14
+ if (typeof given !== "string")
15
+ return given;
16
+ const trimmed = given.trim();
17
+ return DECIMAL.test(trimmed) ? Number(trimmed) : Number.NaN;
18
+ }
10
19
  function fireTime(now, input) {
11
20
  if ((input.delayMinutes === undefined) === (input.at === undefined))
12
21
  throw new Error("Give exactly one of delayMinutes or at.");
13
22
  if (input.delayMinutes !== undefined) {
14
23
  const given = input.delayMinutes;
15
- const minutes = typeof given === "string" ? (DECIMAL.test(given) ? Number(given) : Number.NaN) : given;
24
+ const minutes = minutesOf(given);
16
25
  if (!Number.isFinite(minutes) || minutes < 0)
17
26
  throw new Error("delayMinutes must be a number of minutes, zero or more.");
18
27
  const fireAt = now + Math.round(minutes * 60_000);