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.
Files changed (60) hide show
  1. package/README.md +247 -108
  2. package/dist/bounded.d.ts +8 -0
  3. package/dist/bounded.js +19 -0
  4. package/dist/cleanup.d.ts +21 -21
  5. package/dist/cleanup.js +61 -30
  6. package/dist/courier.d.ts +116 -15
  7. package/dist/courier.js +239 -74
  8. package/dist/group.d.ts +86 -0
  9. package/dist/group.js +247 -0
  10. package/dist/hub.d.ts +300 -0
  11. package/dist/hub.js +343 -0
  12. package/dist/index.d.ts +5 -13
  13. package/dist/index.js +82 -313
  14. package/dist/json.d.ts +8 -0
  15. package/dist/json.js +15 -0
  16. package/dist/later.d.ts +7 -7
  17. package/dist/later.js +8 -7
  18. package/dist/limits.d.ts +65 -0
  19. package/dist/limits.js +109 -0
  20. package/dist/notices.d.ts +356 -0
  21. package/dist/notices.js +678 -0
  22. package/dist/question/answer.d.ts +35 -0
  23. package/dist/question/answer.js +214 -0
  24. package/dist/question/index.d.ts +6 -0
  25. package/dist/question/index.js +7 -0
  26. package/dist/question/lifecycle.d.ts +43 -0
  27. package/dist/question/lifecycle.js +91 -0
  28. package/dist/question/pure.d.ts +48 -0
  29. package/dist/question/pure.js +74 -0
  30. package/dist/question/relay.d.ts +5 -0
  31. package/dist/question/relay.js +269 -0
  32. package/dist/question/runtime.d.ts +20 -0
  33. package/dist/question/runtime.js +74 -0
  34. package/dist/question/shared.d.ts +24 -0
  35. package/dist/question/shared.js +24 -0
  36. package/dist/relay.d.ts +32 -36
  37. package/dist/relay.js +6 -56
  38. package/dist/report.d.ts +48 -0
  39. package/dist/report.js +88 -0
  40. package/dist/roster.d.ts +48 -8
  41. package/dist/roster.js +140 -17
  42. package/dist/skill.d.ts +13 -0
  43. package/dist/skill.js +9 -0
  44. package/dist/stop.d.ts +41 -0
  45. package/dist/stop.js +120 -0
  46. package/dist/storage.d.ts +8 -1
  47. package/dist/storage.js +8 -5
  48. package/dist/tools.d.ts +94 -0
  49. package/dist/tools.js +297 -0
  50. package/dist/tree.d.ts +102 -0
  51. package/dist/tree.js +58 -0
  52. package/dist/version.d.ts +4 -10
  53. package/dist/version.js +4 -10
  54. package/dist/watch.d.ts +70 -42
  55. package/dist/watch.js +346 -51
  56. package/dist/webhook.d.ts +11 -28
  57. package/dist/webhook.js +46 -146
  58. package/package.json +16 -5
  59. package/dist/question.d.ts +0 -158
  60. package/dist/question.js +0 -768
package/dist/index.js CHANGED
@@ -1,79 +1,24 @@
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 { dirtyOf, headOf, inspectWorktree } from "./cleanup.js";
7
+ import { gate, hub, inbox, join, nudge, permissions, portsAt, track } from "./hub.js";
8
+ import { readLimits, shapeContext } from "./limits.js";
9
+ import { pendingOf } from "./relay.js";
10
+ import { joinRelay, noticeCutOff, pendingQuestions, relayQuestions } from "./question/index.js";
11
11
  import { pruneExpired } from "./roster.js";
12
- import { processWide } from "./storage.js";
13
- import { watchChildren } from "./watch.js";
12
+ import { addSkill } from "./skill.js";
13
+ import { addTools } from "./tools.js";
14
+ import { watchFromHub } from "./watch.js";
14
15
  import { builtVersions, versionNotice } from "./version.js";
15
- import { listen, readConfig, subscribe, unsubscribe } from "./webhook.js";
16
- const SpawnInput = Schema.Struct({
17
- task: Schema.String.annotate({ description: "What the new session should do. It is told who started it and how to report back." }),
18
- title: Schema.optional(Schema.String.annotate({ description: "Session title; defaults to the task's first line." })),
19
- agent: Schema.optional(Schema.String.annotate({ description: "Agent to run the session with; defaults to the default agent." })),
20
- isolate: Schema.optional(Schema.Boolean.annotate({ description: "Run the session in its own git worktree so parallel sessions don't share files." })),
21
- });
22
- const SendInput = Schema.Struct({
23
- sessionID: Schema.String.annotate({ description: "The session to deliver to." }),
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", () => ({}));
16
+ import { listen, readConfig } from "./webhook.js";
17
+ /**
18
+ * The webhook receiver, one per process: instances with a `webhook` option join `instances`, the first
19
+ * starts the server and the last to unload stops it; one that could not listen is dropped for a retry.
20
+ */
21
+ const receivers = hub.receivers;
77
22
  const sameSettings = (a, b) => a.port === b.port && a.host === b.host && a.secret === b.secret && a.maxBytes === b.maxBytes;
78
23
  function startReceiver(config, log) {
79
24
  const instances = new Set();
@@ -118,46 +63,15 @@ function joinReceiver(config, ports) {
118
63
  receivers.closing = undefined;
119
64
  };
120
65
  }
121
- // One claim set for every instance in the process: OpenCode sets the plugin up once per project
122
- // location, and those instances share one storage.
123
- const claimed = processWide("opencode-courier.claimed", () => new Set());
124
- // Likewise one set of handled events, since every instance may be sent the same event, and the
125
- // permission requests sessions were told about and have not answered.
126
- const watched = processWide("opencode-courier.watched", () => ({
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
- }
66
+ // One set of handled events and of told permission requests for every instance in the process, since
67
+ // several subscriptions and copies see the same events. The scheduler's claim set is the hub's own.
68
+ const { watched } = hub;
69
+ const watchState = { ...watched, forms: hub.forms, inbox };
156
70
  /**
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.
71
+ * The courier tools and webhook receiver, as a promise plugin joining the hub that runs the scheduler
72
+ * and the watcher; its member carries the location by which the question relay finds its ports.
159
73
  */
160
- export const courier = (relay = {}) => Plugin.define({
74
+ export const courier = () => Plugin.define({
161
75
  id: "courier",
162
76
  setup: async (ctx) => {
163
77
  const log = (message) => console.error(message);
@@ -176,10 +90,20 @@ export const courier = (relay = {}) => Plugin.define({
176
90
  session: ctx.session,
177
91
  directory: ctx.location.directory,
178
92
  now: Date.now,
93
+ timing: { closingGraceMs: 30_000, passingWaitMs: 30_000, relayWaitMs: 30_000, dismissalGraceMs: 2_000 },
179
94
  newID: () => `question_${randomUUID()}`,
180
95
  log,
181
96
  };
97
+ const { limits, problems } = readLimits(ctx.options);
98
+ for (const problem of problems)
99
+ log(`courier: ${problem}`);
100
+ const roles = new Map();
182
101
  const ports = {
102
+ limits,
103
+ gate,
104
+ roles,
105
+ nudge,
106
+ log,
183
107
  session: ctx.session,
184
108
  agent: ctx.agent,
185
109
  worktree: ctx.worktree,
@@ -188,6 +112,7 @@ export const courier = (relay = {}) => Plugin.define({
188
112
  projectID: ctx.location.project.id,
189
113
  now: Date.now,
190
114
  head: headOf,
115
+ dirty: dirtyOf,
191
116
  pending: async (sessionID) => {
192
117
  const [requests, questions] = await Promise.all([
193
118
  pendingOf(permissions(), sessionID),
@@ -198,27 +123,6 @@ export const courier = (relay = {}) => Plugin.define({
198
123
  };
199
124
  const cleanupPorts = { ...ports, inspect: inspectWorktree };
200
125
  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
126
  const later = {
223
127
  storage: ctx.storage,
224
128
  session: ctx.session,
@@ -234,203 +138,68 @@ export const courier = (relay = {}) => Plugin.define({
234
138
  catch (error) {
235
139
  later.log(`courier webhook: not started: ${error instanceof Error ? error.message : String(error)}`);
236
140
  }
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;
141
+ const toolPorts = {
142
+ courier: ports,
143
+ cleanup: cleanupPorts,
144
+ stop: { courier: ports, later, cleanup: cleanupPorts },
145
+ later,
146
+ answer: answerPorts,
147
+ questions: questionPorts,
148
+ hooks,
149
+ waiting: watched.waiting,
150
+ receiving: async () => (await receivers.current?.server) !== undefined,
151
+ };
152
+ await ctx.tool.transform((tools) => addTools(tools, toolPorts));
153
+ await ctx.skill.transform(addSkill);
154
+ await ctx.session.hook("context", (event) => shapeContext({ storage: ctx.storage, limits, roles, log }, event));
155
+ const pruned = pruneExpired(ctx.storage, Date.now()).catch((error) => log(`courier roster prune: ${String(error)}`));
393
156
  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));
157
+ const noticed = noticeCutOff(questionPorts).catch((error) => questionPorts.log(`courier question: stored questions: ${String(error)}`));
158
+ const watchPorts = {
159
+ storage: ctx.storage,
160
+ session: ctx.session,
161
+ event: ctx.event,
162
+ permissions,
163
+ now: questionPorts.now,
164
+ log: later.log,
165
+ nudge,
166
+ };
167
+ // The hub's subscriptions to OpenCode's events, one and a standby, start with this copy's watcher.
168
+ watchFromHub(hub, watchState);
169
+ const member = {
170
+ directory: ctx.location.directory,
171
+ location: ctx.location,
172
+ permission: ctx.permission,
173
+ later,
174
+ watch: watchPorts,
175
+ questions: questionPorts,
176
+ log,
403
177
  };
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);
178
+ const leaveHub = join(member);
179
+ // The leave waits for them, so they do not run on against a location that is closing.
180
+ track(member, pruned);
181
+ track(member, noticed);
410
182
  const leave = webhook ? joinReceiver(webhook, hooks) : undefined;
411
183
  return async () => {
412
- clearInterval(timer);
413
- watching.abort();
414
- locations.delete(location);
415
- if (relay.ports === questionPorts)
416
- relay.ports = undefined;
184
+ // Waits, for a moment at most, for the work this instance leaves behind, and on the last
185
+ // leave for the scheduler's owner key to be released, so another server takes over at once.
186
+ const leftHub = leaveHub();
417
187
  leaveRelay();
418
188
  await leave?.();
189
+ await leftHub;
419
190
  };
420
191
  },
421
192
  });
422
193
  /**
423
- * The plugin OpenCode loads. It is an Effect plugin, because the question relay wraps OpenCode's own
424
- * question tool, whose execute the promise API hands out only as a promise that cannot be
425
- * interrupted, so a child's question could not be withdrawn once its parent answered. Everything
426
- * else is the promise plugin above, run through the plugin package's own adapter, as OpenCode runs
427
- * a promise plugin.
194
+ * The plugin OpenCode loads: an Effect plugin, since the question relay wraps OpenCode's question tool
195
+ * and only the Effect API makes its call interruptible; the rest is the promise plugin, adapted.
428
196
  */
429
197
  export default EffectPlugin.define({
430
198
  id: "courier",
431
199
  effect: (host) => Effect.gen(function* () {
432
- const relay = {};
433
- yield* fromPromise(courier(relay)).effect(host);
434
- yield* relayQuestions(host, () => relay.ports);
200
+ yield* fromPromise(courier()).effect(host);
201
+ // The promise plugin's instance is handed this same location and has just joined with it: its
202
+ // member, bound now, while it stays loaded.
203
+ yield* relayQuestions(host, portsAt(host.location));
435
204
  }),
436
205
  });
package/dist/json.d.ts ADDED
@@ -0,0 +1,8 @@
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>;
5
+ /** Epoch milliseconds from a session time, a `DateTime` in OpenCode's API and a number in tests; NaN for none. */
6
+ export declare function millis(value: unknown): number;
7
+ /** Whether an error of OpenCode's says that what it names, such as a session, does not exist (`Session.NotFoundError`). */
8
+ export declare const isNotFound: (error: unknown) => boolean;
package/dist/json.js ADDED
@@ -0,0 +1,15 @@
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 : {};
5
+ /** Epoch milliseconds from a session time, a `DateTime` in OpenCode's API and a number in tests; NaN for none. */
6
+ export function millis(value) {
7
+ if (typeof value === "number")
8
+ return value;
9
+ if (typeof value === "string")
10
+ return Date.parse(value);
11
+ const epoch = obj(value).epochMilliseconds;
12
+ return typeof epoch === "number" ? epoch : Number.NaN;
13
+ }
14
+ /** Whether an error of OpenCode's says that what it names, such as a session, does not exist (`Session.NotFoundError`). */
15
+ export const isNotFound = (error) => str(obj(error)._tag)?.includes("NotFound") ?? false;
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;
@@ -13,7 +13,8 @@ export interface LaterEntry {
13
13
  }
14
14
  export interface LaterPorts {
15
15
  readonly storage: Storage;
16
- readonly session: Pick<Context["session"], "synthetic">;
16
+ /** `get` for the release of a join group, which waits for its parent's turn to end. */
17
+ readonly session: Pick<Context["session"], "synthetic" | "get">;
17
18
  readonly now: () => number;
18
19
  readonly newID: () => string;
19
20
  readonly log: (message: string) => void;
@@ -29,12 +30,11 @@ export interface LaterInput {
29
30
  export declare function schedule(ports: LaterPorts, from: string, input: LaterInput): Promise<LaterEntry>;
30
31
  /** Drops a pending message; false when there was none, e.g. it was already delivered. */
31
32
  export declare function cancel(ports: LaterPorts, id: string): Promise<boolean>;
33
+ /** The messages scheduled for a session that have not been delivered yet. */
34
+ export declare function scheduledFor(storage: Pick<Storage, "scan">, sessionID: string): Promise<LaterEntry[]>;
32
35
  /**
33
- * Delivers every due message once. OpenCode sets the plugin up once per project location, all in
34
- * one process and over one storage, so the instances share `claimed`: an id is claimed
35
- * synchronously, re-read after the claim (another instance may have just delivered it), delivered,
36
- * and only then removed. A crash between delivery and removal delivers it again after a restart,
37
- * which a check-in survives better than being lost.
36
+ * Delivers every due message once: an id is claimed synchronously in the shared `claimed`, re-read,
37
+ * delivered and only then removed; a crash in between delivers it again after a restart, not never.
38
38
  */
39
39
  export declare function deliverDue(ports: LaterPorts, claimed: Set<string>): Promise<void>;
40
40
  export {};
package/dist/later.js CHANGED
@@ -1,6 +1,6 @@
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. */
@@ -60,12 +60,13 @@ export async function cancel(ports, id) {
60
60
  function pending(ports) {
61
61
  return scanAll(ports.storage, PREFIX);
62
62
  }
63
+ /** The messages scheduled for a session that have not been delivered yet. */
64
+ export async function scheduledFor(storage, sessionID) {
65
+ return (await pending({ storage: storage })).filter((entry) => entry.sessionID === sessionID);
66
+ }
63
67
  /**
64
- * Delivers every due message once. OpenCode sets the plugin up once per project location, all in
65
- * one process and over one storage, so the instances share `claimed`: an id is claimed
66
- * synchronously, re-read after the claim (another instance may have just delivered it), delivered,
67
- * and only then removed. A crash between delivery and removal delivers it again after a restart,
68
- * which a check-in survives better than being lost.
68
+ * Delivers every due message once: an id is claimed synchronously in the shared `claimed`, re-read,
69
+ * delivered and only then removed; a crash in between delivers it again after a restart, not never.
69
70
  */
70
71
  export async function deliverDue(ports, claimed) {
71
72
  const now = ports.now();
@@ -0,0 +1,65 @@
1
+ import type { Plugin } from "@opencode/plugin";
2
+ import { type Limits } from "./notices.js";
3
+ import { type RosterStorage } from "./roster.js";
4
+ export type { Limits } from "./notices.js";
5
+ type Context = Plugin.Context;
6
+ export declare const DEFAULT_LIMITS: Limits;
7
+ /** The limits in the plugin's options; one that is not a positive integer keeps its default and is named in `problems`. */
8
+ export declare function readLimits(options: unknown): {
9
+ limits: Limits;
10
+ problems: string[];
11
+ };
12
+ /** A spawn past its limit check: its parent, the top of its tree, and its child once created. */
13
+ export interface Reservation {
14
+ readonly parentID: string;
15
+ readonly top: string;
16
+ sessionID?: string;
17
+ }
18
+ /**
19
+ * The spawns under way in the process, kept by the hub under a fixed key, so spawns in other
20
+ * locations and copies count them; `turn` runs the limit checks one at a time.
21
+ */
22
+ export interface SpawnGate {
23
+ readonly reserved: Set<Reservation>;
24
+ turn: Promise<unknown>;
25
+ }
26
+ export interface LimitPorts {
27
+ readonly storage: RosterStorage;
28
+ readonly session: Pick<Context["session"], "get">;
29
+ readonly limits: Limits;
30
+ readonly gate: SpawnGate;
31
+ }
32
+ /** A session's times, as `session.get` gives them. */
33
+ type Times = Awaited<ReturnType<Context["session"]["get"]>>["time"];
34
+ /** Whether a session with these times runs: it has never finished a turn, or something reached it after the last one ended. */
35
+ export declare const busy: (time: Pick<Times, "updated" | "idle">) => boolean;
36
+ /**
37
+ * Checks the limits for a spawn from `parentID`, then holds its place in `gate` until `release`;
38
+ * throws a refusal naming the limit. Checks run one at a time, so spawns made together count each other.
39
+ */
40
+ export declare function admit(ports: LimitPorts, parentID: string): Promise<{
41
+ depth: number;
42
+ reservation: Reservation;
43
+ release: () => void;
44
+ }>;
45
+ export interface ContextPorts {
46
+ readonly storage: RosterStorage;
47
+ readonly limits: Limits;
48
+ /** Each session's depth once looked up: null for one nobody spawned that has started none, until it starts one. */
49
+ readonly roles: Map<string, number | null>;
50
+ readonly log: (message: string) => void;
51
+ }
52
+ /** The part of a session context this hook changes. */
53
+ export interface Shaped {
54
+ readonly sessionID: string;
55
+ tools: Record<string, unknown>;
56
+ system: Array<{
57
+ type: "text";
58
+ text: string;
59
+ }>;
60
+ }
61
+ /**
62
+ * The `context` hook: names a session's role in its tree in a system part, and hides courier_spawn
63
+ * from one at `maxDepth`. A session nobody spawned that has started none is pointed to the skill, if it could spawn.
64
+ */
65
+ export declare function shapeContext(ports: ContextPorts, event: Shaped): Promise<void>;