opencode-courier 0.2.3 → 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 (50) hide show
  1. package/README.md +124 -20
  2. package/dist/bounded.d.ts +2 -4
  3. package/dist/bounded.js +2 -4
  4. package/dist/cleanup.d.ts +8 -3
  5. package/dist/cleanup.js +24 -12
  6. package/dist/courier.d.ts +116 -11
  7. package/dist/courier.js +239 -55
  8. package/dist/group.d.ts +86 -0
  9. package/dist/group.js +247 -0
  10. package/dist/hub.d.ts +52 -77
  11. package/dist/hub.js +68 -65
  12. package/dist/index.d.ts +4 -8
  13. package/dist/index.js +27 -20
  14. package/dist/json.d.ts +4 -0
  15. package/dist/json.js +11 -0
  16. package/dist/later.d.ts +6 -6
  17. package/dist/later.js +6 -5
  18. package/dist/limits.d.ts +65 -0
  19. package/dist/limits.js +109 -0
  20. package/dist/notices.d.ts +125 -15
  21. package/dist/notices.js +304 -25
  22. package/dist/question/answer.js +6 -9
  23. package/dist/question/lifecycle.d.ts +10 -19
  24. package/dist/question/lifecycle.js +10 -19
  25. package/dist/question/pure.d.ts +3 -4
  26. package/dist/question/pure.js +2 -3
  27. package/dist/question/relay.js +13 -27
  28. package/dist/question/runtime.d.ts +2 -3
  29. package/dist/question/runtime.js +2 -3
  30. package/dist/relay.d.ts +2 -4
  31. package/dist/relay.js +2 -4
  32. package/dist/report.d.ts +48 -0
  33. package/dist/report.js +88 -0
  34. package/dist/roster.d.ts +29 -17
  35. package/dist/roster.js +57 -26
  36. package/dist/skill.d.ts +13 -0
  37. package/dist/skill.js +9 -0
  38. package/dist/stop.d.ts +41 -0
  39. package/dist/stop.js +120 -0
  40. package/dist/tools.d.ts +20 -0
  41. package/dist/tools.js +81 -12
  42. package/dist/tree.d.ts +102 -0
  43. package/dist/tree.js +58 -0
  44. package/dist/version.d.ts +4 -10
  45. package/dist/version.js +4 -10
  46. package/dist/watch.d.ts +55 -18
  47. package/dist/watch.js +219 -34
  48. package/dist/webhook.d.ts +10 -15
  49. package/dist/webhook.js +11 -20
  50. package/package.json +1 -1
package/dist/hub.js CHANGED
@@ -1,33 +1,14 @@
1
1
  import { randomInt } from "node:crypto";
2
2
  import { hostname } from "node:os";
3
+ import { deliverReleased } from "./group.js";
3
4
  import { num, obj, str } from "./json.js";
4
5
  import { deliverDue, TICK_MS } from "./later.js";
5
6
  import { processWide } from "./storage.js";
6
7
  /**
7
- * The plugin's process-wide state, in one place. OpenCode sets the plugin up once per location
8
- * (project or worktree), all in one process, and after an update loads the new copy of the package
9
- * next to the old one until the old one unloads; so the state every instance shares lives on
10
- * `globalThis`, outside the module graph, and this is the only module that puts it there.
11
- *
12
- * The hub is versioned by `HUB_VERSION`, not by the package's version: copies whose hub has the
13
- * same shape share one hub, however far apart their releases, and only a change to the hub's shape
14
- * (the ports a `Member` carries included) or to the meaning of one of its fields bumps it. An
15
- * optional field that a copy without it gets along without, such as `Member.location` or
16
- * `Scheduler.running`, is added without a bump while no release has the hub (the latest is 0.2.2).
17
- *
18
- * Version skew: a copy that finds a hub of another version under `opencode-courier.hub` logs that
19
- * once and runs its own hub under `opencode-courier.hub@<version>`. Whatever its version, a hub
20
- * takes the claim sets and the other shared objects of the copies before the hub from their own
21
- * keys (`opencode-courier.claimed`, `.watched`, `.forms`, `.questions`, `.receiver`, `.locations`),
22
- * making each when it is missing, so every copy in the process, with a hub or without, claims an
23
- * event or a delivery in the same set, and an old and a new copy never both act on it. Those keys'
24
- * shapes and meanings are therefore fixed: a hub version that needs other ones makes new keys, and
25
- * keeps claiming in the old ones too while a copy that uses them may still be loaded. `members` is
26
- * the one thing each version keeps to itself, so a job a hub runs once for its members runs once
27
- * per hub version, and still claims what it acts on in the shared sets, which keep it from acting
28
- * twice.
8
+ * The plugin's process-wide state on `globalThis`, shared by every loaded copy; only this module puts
9
+ * it there. `HUB_VERSION`, the fixed claim keys and version skew: docs/reference.md, § Several copies.
29
10
  */
30
- export const HUB_VERSION = 1;
11
+ export const HUB_VERSION = 2;
31
12
  /** The key of the hub; a copy that finds another version there keeps its own under `${HUB_KEY}@<version>`. */
32
13
  export const HUB_KEY = "opencode-courier.hub";
33
14
  const realTimers = {
@@ -36,10 +17,8 @@ const realTimers = {
36
17
  wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
37
18
  };
38
19
  /**
39
- * The key in the plugin's storage, shared by every OpenCode server on one data directory, naming
40
- * the server whose scheduler delivers the `courier_later` messages: `{ server, at }`, its id and
41
- * when it last wrote the key, in epoch milliseconds. Outside `later/`, which the schedulers scan
42
- * for messages, those of copies before the key included.
20
+ * The storage key, shared by every server on one data directory, naming the server whose scheduler
21
+ * delivers `courier_later` messages: `{ server, at }`, its id and when it last wrote (epoch ms).
43
22
  */
44
23
  export const OWNER_KEY = "scheduler/owner";
45
24
  /** How long the owner key holds without being renewed; the owner renews it every tick, four times as often. */
@@ -63,9 +42,8 @@ async function readOwner(ports) {
63
42
  return server !== undefined && at !== undefined ? { server, at } : undefined;
64
43
  }
65
44
  /**
66
- * How long an unloading instance waits, before it lets go, for the work it leaves behind: what it
67
- * started itself, a tick or an event's notice still under way through it, and on the last leave the
68
- * owner key's release.
45
+ * How long an unloading instance waits for the work it leaves behind: what it started itself, a tick
46
+ * or a notice under way through it, and on the last leave the owner key's release.
69
47
  */
70
48
  export const LEAVE_MS = 2_000;
71
49
  /** Whether a key written at `at` still holds at `now`; one from the future, after the clock went back, holds as long. */
@@ -73,9 +51,8 @@ const holds = (at, now, ms) => Math.abs(now - at) < ms;
73
51
  /** How many subscriptions to OpenCode's events a hub keeps, each through another member: one, and a standby. */
74
52
  export const WATCHERS = 2;
75
53
  /**
76
- * Finds or makes this copy's hub in `registry`: the one under `HUB_KEY`, or its own under a
77
- * versioned key when a hub of another version holds that. Either way, its claim sets and shared
78
- * objects are the ones under the keys of the copies before the hub, made when missing.
54
+ * Finds or makes this copy's hub in `registry`: the one under `HUB_KEY`, or its own under a versioned
55
+ * key when another version holds that; its claim sets are the pre-hub keys', made when missing.
79
56
  */
80
57
  export function open(registry, timers = realTimers, server = SERVER) {
81
58
  const shared = (name, create) => processWide(`opencode-courier.${name}`, create, registry);
@@ -112,9 +89,8 @@ export function open(registry, timers = realTimers, server = SERVER) {
112
89
  const skew = found != null && found.version !== HUB_VERSION ? { version: found.version } : undefined;
113
90
  const hub = processWide(skew ? `${HUB_KEY}@${HUB_VERSION}` : HUB_KEY, create, registry);
114
91
  /**
115
- * Whether this server holds the owner key, taking or renewing it through `ports`. Its own fresh
116
- * key is renewed. A free one, an expired one, or its own after a stall, is written and read back
117
- * after a random wait: of two servers taking it at once, the one that wrote last holds it.
92
+ * Whether this server holds the owner key, taking or renewing it through `ports`. A free, expired
93
+ * or stalled key is written and read back after a random wait: the last of two writers holds it.
118
94
  */
119
95
  const own = async (ports) => {
120
96
  const held = await readOwner(ports);
@@ -142,10 +118,8 @@ export function open(registry, timers = realTimers, server = SERVER) {
142
118
  return other !== hub && (name === HUB_KEY || name.startsWith(`${HUB_KEY}@`)) && Number(other?.members?.size) > 0;
143
119
  });
144
120
  /**
145
- * Removes the owner key if this server holds it, so another server takes over at its next tick:
146
- * once the loop has stopped and no tick is under way, whichever copy started it (a tick whose loop
147
- * stopped releases it as it ends), and while no hub of another version in the process still runs a
148
- * loop under the same id.
121
+ * Removes the owner key if this server holds it, so another takes over at its next tick: once the
122
+ * loop has stopped, no tick is under way and no other hub version here runs a loop under this id.
149
123
  */
150
124
  const release = async (ports) => {
151
125
  const scheduler = hub.scheduler;
@@ -154,30 +128,33 @@ export function open(registry, timers = realTimers, server = SERVER) {
154
128
  await ports.storage.remove(OWNER_KEY);
155
129
  };
156
130
  /**
157
- * Whether the tick under way, kept on the hub by whichever copy started it, still holds up the
158
- * next: within a minute of its start it does, and the key is renewed for it, through the first
159
- * member still loaded, since a delivery may run longer than the key holds; past the minute it is
160
- * taken to hang, and holds up the next only while its member is still loaded. A tick of a copy that
161
- * keeps only `ticking` holds it up while its member is loaded, without renewing the key.
131
+ * Whether the tick under way still holds the next up: within a minute of its start it does; past
132
+ * that it is taken to hang, holding up only while its member is loaded. `recent` says which.
162
133
  */
163
- const underWay = () => {
134
+ const busy = () => {
164
135
  const { running, ticking } = hub.scheduler;
165
136
  if (!running)
166
- return ticking !== undefined && hub.members.has(ticking);
137
+ return { busy: ticking !== undefined && hub.members.has(ticking), recent: false };
138
+ const first = hub.members.values().next().value;
139
+ if (first && holds(running.since, first.later.now(), OWNER_EXPIRY_MS))
140
+ return { busy: true, recent: true };
141
+ return { busy: hub.members.has(running.member), recent: false };
142
+ };
143
+ /** `busy`, renewing the key for a recent tick under way, as each tick falling due meanwhile does. */
144
+ const underWay = () => {
145
+ const state = busy();
167
146
  const first = hub.members.values().next().value;
168
- if (first && holds(running.since, first.later.now(), OWNER_EXPIRY_MS)) {
147
+ if (state.recent && first)
169
148
  void renew(first.later).catch((error) => first.log(`courier_later scheduler: ${String(error)}`));
170
- return true;
171
- }
172
- return hub.members.has(running.member);
149
+ return state.busy;
173
150
  };
174
- // One tick, through the first member still loaded, by the server holding the owner key. The claim
175
- // on each delivery stays, as the second line of defence against a copy before the hub, which runs
176
- // its own interval until it unloads, and has no owner key.
151
+ // One tick, through the first loaded member, by the server holding the owner key. The claim on each
152
+ // delivery stays, as a second line of defence against a pre-hub copy running its own interval.
177
153
  const tick = async () => {
178
154
  const scheduler = hub.scheduler;
179
155
  if (underWay())
180
156
  return;
157
+ scheduler.nudged = false;
181
158
  const owner = hub.members.values().next().value;
182
159
  if (!owner)
183
160
  return;
@@ -202,8 +179,10 @@ export function open(registry, timers = realTimers, server = SERVER) {
202
179
  catch { }
203
180
  };
204
181
  try {
205
- if ((await own(owner.later)) && scheduler.timer !== undefined && hub.members.has(owner))
182
+ if ((await own(owner.later)) && scheduler.timer !== undefined && hub.members.has(owner)) {
206
183
  await deliverDue(owner.later, hub.claimed);
184
+ await deliverReleased(owner.later, hub.claimed);
185
+ }
207
186
  }
208
187
  catch (error) {
209
188
  tell(`courier_later scheduler: ${String(error)}`);
@@ -219,6 +198,18 @@ export function open(registry, timers = realTimers, server = SERVER) {
219
198
  if (scheduler.timer === undefined)
220
199
  await release(owner.later).catch((error) => tell(`courier_later scheduler: owner key not released: ${String(error)}`));
221
200
  end();
201
+ if (scheduler.nudged && scheduler.timer !== undefined)
202
+ void scheduler.tick?.();
203
+ };
204
+ // Through the hub's tick, the copy loaded last's, like the loop's; this copy's only before one joined. A
205
+ // tick under way runs once more when it ends, so nothing is started here meanwhile.
206
+ const nudge = () => {
207
+ const scheduler = hub.scheduler;
208
+ if (scheduler.timer === undefined)
209
+ return;
210
+ scheduler.nudged = true;
211
+ if (!busy().busy)
212
+ void (scheduler.tick ?? tick)();
222
213
  };
223
214
  // Subscriptions through the earliest members without one, the longest loaded, until there are
224
215
  // `WATCHERS`; never through `leaving`.
@@ -262,9 +253,8 @@ export function open(registry, timers = realTimers, server = SERVER) {
262
253
  const behind = [...(work.get(member) ?? [])];
263
254
  if (scheduler.running?.member === member)
264
255
  behind.push(scheduler.running.done);
265
- // The other subscription, already connected, follows the events meanwhile; this one is
266
- // replaced before it ends, so the question relay's count of watchers never drops to none.
267
- // It ends once the event it is handling, whose notice may be going out, has been handled.
256
+ // The other subscription, already connected, follows the events meanwhile; this one is replaced
257
+ // before it ends, after its current event, so the relay's watcher count never drops to none.
268
258
  const index = hub.watchers.findIndex((watcher) => watcher.member === member);
269
259
  if (index >= 0) {
270
260
  const [leaving] = hub.watchers.splice(index, 1);
@@ -286,7 +276,14 @@ export function open(registry, timers = realTimers, server = SERVER) {
286
276
  return Promise.race([Promise.allSettled(behind).then(() => { }), timers.wait(LEAVE_MS)]);
287
277
  };
288
278
  };
289
- return { hub, join, track };
279
+ return {
280
+ hub,
281
+ join,
282
+ track,
283
+ gate: shared("spawning", () => ({ reserved: new Set(), turn: Promise.resolve() })),
284
+ inbox: shared("inbox", () => new Map()),
285
+ nudge,
286
+ };
290
287
  }
291
288
  const opened = open(globalThis);
292
289
  /** This copy's hub, in the process. */
@@ -295,6 +292,12 @@ export const hub = opened.hub;
295
292
  export const join = opened.join;
296
293
  /** Hands this copy's hub work a member started; see `Opened.track`. */
297
294
  export const track = opened.track;
295
+ /** The spawns under way in the process; see `Opened.gate`. */
296
+ export const gate = opened.gate;
297
+ /** The types of the items enqueued for spawned sessions; see `Opened.inbox`. */
298
+ export const inbox = opened.inbox;
299
+ /** Has this copy's scheduler deliver what is due now; see `Opened.nudge`. */
300
+ export const nudge = opened.nudge;
298
301
  /** The member of this copy's hub set up with `location` that joined last, while it is loaded. */
299
302
  export function memberAt(location) {
300
303
  let found;
@@ -304,9 +307,8 @@ export function memberAt(location) {
304
307
  return found;
305
308
  }
306
309
  /**
307
- * The question ports of the member that has just joined at `location`, the newest there, for as long
308
- * as it stays loaded: resolved once, so an instance joining later at the same location, while this
309
- * one is still loaded, does not take its place.
310
+ * The question ports of the member that just joined at `location`, while it is loaded: resolved once,
311
+ * so a later instance at the same location does not take its place while this one is still loaded.
310
312
  */
311
313
  export function portsAt(location) {
312
314
  const own = memberAt(location);
@@ -315,11 +317,12 @@ export function portsAt(location) {
315
317
  /** The permission domains of the loaded instances. */
316
318
  export const permissions = () => hub.locations.values();
317
319
  /**
318
- * For tests: forgets every remembered id and the question relay's state, the relay's loaded
319
- * instances and its count of watchers included. The hub's members, the locations and the webhook
320
- * receiver are left alone; leaving and unloading take them out.
320
+ * For tests: forgets every remembered id, the spawns under way and the question relay's state, loaded
321
+ * instances and watcher count included. Members, locations and the receiver are left to unloading.
321
322
  */
322
323
  export function resetHub() {
324
+ gate.reserved.clear();
325
+ inbox.clear();
323
326
  hub.claimed.clear();
324
327
  hub.watched.seen.clear();
325
328
  hub.watched.waiting.clear();
package/dist/index.d.ts CHANGED
@@ -1,17 +1,13 @@
1
1
  import { Plugin } from "@opencode/plugin";
2
2
  import { Plugin as EffectPlugin } from "@opencode/plugin/effect";
3
3
  /**
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.
4
+ * The courier tools and webhook receiver, as a promise plugin joining the hub that runs the scheduler
5
+ * and the watcher; its member carries the location by which the question relay finds its ports.
7
6
  */
8
7
  export declare const courier: () => Plugin.Plugin;
9
8
  /**
10
- * The plugin OpenCode loads. It is an Effect plugin, because the question relay wraps OpenCode's own
11
- * question tool, whose execute the promise API hands out only as a promise that cannot be
12
- * interrupted, so a child's question could not be withdrawn once its parent answered. Everything
13
- * else is the promise plugin above, run through the plugin package's own adapter, as OpenCode runs
14
- * a promise plugin.
9
+ * The plugin OpenCode loads: an Effect plugin, since the question relay wraps OpenCode's question tool
10
+ * and only the Effect API makes its call interruptible; the rest is the promise plugin, adapted.
15
11
  */
16
12
  declare const _default: EffectPlugin.Plugin<import("effect/Scope").Scope>;
17
13
  export default _default;
package/dist/index.js CHANGED
@@ -3,21 +3,20 @@ import { Plugin as EffectPlugin } from "@opencode/plugin/effect";
3
3
  import { fromPromise } from "@opencode/plugin/promise/adapter";
4
4
  import { Effect } from "effect";
5
5
  import { randomUUID } from "node:crypto";
6
- import { headOf, inspectWorktree } from "./cleanup.js";
7
- import { hub, join, permissions, portsAt, track } from "./hub.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";
8
9
  import { pendingOf } from "./relay.js";
9
10
  import { joinRelay, noticeCutOff, pendingQuestions, relayQuestions } from "./question/index.js";
10
11
  import { pruneExpired } from "./roster.js";
12
+ import { addSkill } from "./skill.js";
11
13
  import { addTools } from "./tools.js";
12
14
  import { watchFromHub } from "./watch.js";
13
15
  import { builtVersions, versionNotice } from "./version.js";
14
16
  import { listen, readConfig } from "./webhook.js";
15
17
  /**
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.
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.
21
20
  */
22
21
  const receivers = hub.receivers;
23
22
  const sameSettings = (a, b) => a.port === b.port && a.host === b.host && a.secret === b.secret && a.maxBytes === b.maxBytes;
@@ -64,16 +63,13 @@ function joinReceiver(config, ports) {
64
63
  receivers.closing = undefined;
65
64
  };
66
65
  }
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.
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.
71
68
  const { watched } = hub;
72
- const watchState = { ...watched, forms: hub.forms };
69
+ const watchState = { ...watched, forms: hub.forms, inbox };
73
70
  /**
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.
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.
77
73
  */
78
74
  export const courier = () => Plugin.define({
79
75
  id: "courier",
@@ -98,7 +94,16 @@ export const courier = () => Plugin.define({
98
94
  newID: () => `question_${randomUUID()}`,
99
95
  log,
100
96
  };
97
+ const { limits, problems } = readLimits(ctx.options);
98
+ for (const problem of problems)
99
+ log(`courier: ${problem}`);
100
+ const roles = new Map();
101
101
  const ports = {
102
+ limits,
103
+ gate,
104
+ roles,
105
+ nudge,
106
+ log,
102
107
  session: ctx.session,
103
108
  agent: ctx.agent,
104
109
  worktree: ctx.worktree,
@@ -107,6 +112,7 @@ export const courier = () => Plugin.define({
107
112
  projectID: ctx.location.project.id,
108
113
  now: Date.now,
109
114
  head: headOf,
115
+ dirty: dirtyOf,
110
116
  pending: async (sessionID) => {
111
117
  const [requests, questions] = await Promise.all([
112
118
  pendingOf(permissions(), sessionID),
@@ -135,6 +141,7 @@ export const courier = () => Plugin.define({
135
141
  const toolPorts = {
136
142
  courier: ports,
137
143
  cleanup: cleanupPorts,
144
+ stop: { courier: ports, later, cleanup: cleanupPorts },
138
145
  later,
139
146
  answer: answerPorts,
140
147
  questions: questionPorts,
@@ -143,6 +150,8 @@ export const courier = () => Plugin.define({
143
150
  receiving: async () => (await receivers.current?.server) !== undefined,
144
151
  };
145
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));
146
155
  const pruned = pruneExpired(ctx.storage, Date.now()).catch((error) => log(`courier roster prune: ${String(error)}`));
147
156
  const leaveRelay = joinRelay(questionPorts);
148
157
  const noticed = noticeCutOff(questionPorts).catch((error) => questionPorts.log(`courier question: stored questions: ${String(error)}`));
@@ -153,6 +162,7 @@ export const courier = () => Plugin.define({
153
162
  permissions,
154
163
  now: questionPorts.now,
155
164
  log: later.log,
165
+ nudge,
156
166
  };
157
167
  // The hub's subscriptions to OpenCode's events, one and a standby, start with this copy's watcher.
158
168
  watchFromHub(hub, watchState);
@@ -181,11 +191,8 @@ export const courier = () => Plugin.define({
181
191
  },
182
192
  });
183
193
  /**
184
- * The plugin OpenCode loads. It is an Effect plugin, because the question relay wraps OpenCode's own
185
- * question tool, whose execute the promise API hands out only as a promise that cannot be
186
- * interrupted, so a child's question could not be withdrawn once its parent answered. Everything
187
- * else is the promise plugin above, run through the plugin package's own adapter, as OpenCode runs
188
- * 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.
189
196
  */
190
197
  export default EffectPlugin.define({
191
198
  id: "courier",
package/dist/json.d.ts CHANGED
@@ -2,3 +2,7 @@
2
2
  export declare const str: (value: unknown) => string | undefined;
3
3
  export declare const num: (value: unknown) => number | undefined;
4
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 CHANGED
@@ -2,3 +2,14 @@
2
2
  export const str = (value) => (typeof value === "string" ? value : undefined);
3
3
  export const num = (value) => (typeof value === "number" && Number.isInteger(value) ? value : undefined);
4
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
@@ -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
@@ -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>;
package/dist/limits.js ADDED
@@ -0,0 +1,109 @@
1
+ import { setBounded } from "./bounded.js";
2
+ import { isNotFound, millis, obj } from "./json.js";
3
+ import { childrenRefusal, depthRefusal, ROLE_PREFIX, rolePart, totalRefusal } from "./notices.js";
4
+ import { reportOf } from "./report.js";
5
+ import { allEntries, bySession, children, indexedLineage, lineageBy } from "./roster.js";
6
+ export const DEFAULT_LIMITS = { maxDepth: 3, maxChildren: 5, maxTotal: 20 };
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 function readLimits(options) {
9
+ const given = obj(options);
10
+ const problems = [];
11
+ const read = (name) => {
12
+ const value = given[name];
13
+ if (value === undefined)
14
+ return DEFAULT_LIMITS[name];
15
+ if (typeof value === "number" && Number.isInteger(value) && value > 0)
16
+ return value;
17
+ problems.push(`${name} must be a positive integer, not ${JSON.stringify(value)}; using ${DEFAULT_LIMITS[name]}`);
18
+ return DEFAULT_LIMITS[name];
19
+ };
20
+ const limits = { maxDepth: read("maxDepth"), maxChildren: read("maxChildren"), maxTotal: read("maxTotal") };
21
+ return { limits, problems };
22
+ }
23
+ /** Whether a session with these times runs: it has never finished a turn, or something reached it after the last one ended. */
24
+ export const busy = (time) => time.idle === undefined || millis(time.updated) > millis(time.idle);
25
+ /**
26
+ * Whether a spawned session counts against the limits: it runs, unless its turn failed or was
27
+ * interrupted since its last prompt, or it owes its parent a report. One OpenCode no longer knows does not.
28
+ */
29
+ async function live(ports, sessionID) {
30
+ const [time, report] = await Promise.all([
31
+ ports.session.get({ sessionID }).then((info) => info.time, (error) => (isNotFound(error) ? null : undefined)),
32
+ reportOf(ports.storage, sessionID).catch(() => undefined),
33
+ ]);
34
+ if (time === null)
35
+ return false;
36
+ // One whose first prompt failed never ran, and has no idle time to tell so.
37
+ const runs = time !== undefined && busy(time) && !report?.ended;
38
+ return runs || (report?.owes ?? false);
39
+ }
40
+ /**
41
+ * Checks the limits for a spawn from `parentID`, then holds its place in `gate` until `release`;
42
+ * throws a refusal naming the limit. Checks run one at a time, so spawns made together count each other.
43
+ */
44
+ export async function admit(ports, parentID) {
45
+ const gate = ports.gate;
46
+ const checked = gate.turn.then(() => check(ports, parentID));
47
+ gate.turn = checked.catch(() => undefined);
48
+ const { depth, reservation } = await checked;
49
+ const release = () => {
50
+ gate.reserved.delete(reservation);
51
+ };
52
+ return { depth, reservation, release };
53
+ }
54
+ async function check(ports, parentID) {
55
+ const { maxDepth, maxChildren, maxTotal } = ports.limits;
56
+ const entries = await allEntries(ports.storage);
57
+ const parents = bySession(entries);
58
+ const chain = lineageBy(parents, parentID);
59
+ if (chain.length >= maxDepth)
60
+ throw new Error(depthRefusal(chain.length, maxDepth));
61
+ const top = chain.at(-1)?.parentID ?? parentID;
62
+ const chains = new Map(entries.map((entry) => [entry.sessionID, lineageBy(parents, entry.sessionID)]));
63
+ const tree = entries.filter((entry) => chains.get(entry.sessionID).at(-1)?.parentID === top);
64
+ const runs = await Promise.all(tree.map((entry) => live(ports, entry.sessionID)));
65
+ // A session waiting on a live descendant is live too: the brief tells it to end its turn meanwhile.
66
+ const active = new Set(tree.filter((_, i) => runs[i]).flatMap((entry) => [entry.sessionID, ...chains.get(entry.sessionID).map((above) => above.parentID)]));
67
+ const counted = tree.filter((entry) => active.has(entry.sessionID));
68
+ // A spawn whose child is on the roster already is counted there, if it is live, not by its reservation.
69
+ const recorded = new Set(tree.map((entry) => entry.sessionID));
70
+ const pending = [...ports.gate.reserved].filter((held) => held.top === top && !(held.sessionID && recorded.has(held.sessionID)));
71
+ const mine = counted.filter((entry) => entry.parentID === parentID).length + pending.filter((held) => held.parentID === parentID).length;
72
+ if (mine >= maxChildren)
73
+ throw new Error(childrenRefusal(mine, maxChildren));
74
+ if (counted.length + pending.length >= maxTotal)
75
+ throw new Error(totalRefusal(counted.length + pending.length, maxTotal));
76
+ const reservation = { parentID, top };
77
+ ports.gate.reserved.add(reservation);
78
+ return { depth: chain.length + 1, reservation };
79
+ }
80
+ /** How many sessions' depths an instance remembers. */
81
+ const ROLES_MAX = 1000;
82
+ /**
83
+ * The `context` hook: names a session's role in its tree in a system part, and hides courier_spawn
84
+ * from one at `maxDepth`. A session nobody spawned that has started none is pointed to the skill, if it could spawn.
85
+ */
86
+ export async function shapeContext(ports, event) {
87
+ try {
88
+ const depth = await depthOf(ports, event.sessionID);
89
+ if (depth === null && !event.tools.courier_spawn)
90
+ return;
91
+ if (depth !== null && depth >= ports.limits.maxDepth)
92
+ delete event.tools.courier_spawn;
93
+ if (!event.system.some((part) => part.text.startsWith(ROLE_PREFIX)))
94
+ event.system.push({ type: "text", text: rolePart(depth, ports.limits) });
95
+ }
96
+ catch (error) {
97
+ ports.log(`courier: cannot tell the role of session ${event.sessionID}: ${String(error)}`);
98
+ }
99
+ }
100
+ async function depthOf(ports, sessionID) {
101
+ const known = ports.roles.get(sessionID);
102
+ if (known !== undefined)
103
+ return known;
104
+ // The reverse index alone: a scan here would run for every session's first request, courier or not.
105
+ const chain = await indexedLineage(ports.storage, sessionID);
106
+ const depth = chain.length || ((await children(ports.storage, sessionID)).length ? 0 : null);
107
+ setBounded(ports.roles, sessionID, depth, ROLES_MAX);
108
+ return depth;
109
+ }