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/group.js ADDED
@@ -0,0 +1,247 @@
1
+ import { isNotFound, millis, num, obj, str } from "./json.js";
2
+ import { envelope, groupNotice, isStatus } from "./notices.js";
3
+ import { scanEntries } from "./storage.js";
4
+ /**
5
+ * Join groups: each member of a parent's group under `group/<parentID>/<name>/<sessionID>`, with its
6
+ * held report or how it left; released and dropped once none is out. docs/reference.md, § Join groups.
7
+ */
8
+ const PREFIX = "group/";
9
+ /** What a group may be called: short, and with no `/`, which separates the parts of its keys, or `"`, which closes an attribute. */
10
+ const NAME = /^[\w.-]{1,60}$/;
11
+ export const isGroupName = (value) => typeof value === "string" && NAME.test(value);
12
+ export const memberKey = (parentID, group, sessionID) => `${PREFIX}${parentID}/${group}/${sessionID}`;
13
+ /** The key prefix of a parent's groups, and of one group's members. */
14
+ export const groupsPrefix = (parentID) => `${PREFIX}${parentID}/`;
15
+ export const groupPrefix = (parentID, group) => `${groupsPrefix(parentID)}${group}/`;
16
+ /** A member's standing from its stored value, or `released` when there is none. */
17
+ export function standingOf(member) {
18
+ if (!member)
19
+ return "released";
20
+ if (member.report)
21
+ return "held";
22
+ return member.left?.by ?? "out";
23
+ }
24
+ function readMember(value) {
25
+ const given = obj(value);
26
+ const title = str(given.title);
27
+ const joinedAt = num(given.joinedAt);
28
+ if (title === undefined || joinedAt === undefined)
29
+ return undefined;
30
+ const report = readReport(given.report);
31
+ const left = obj(given.left);
32
+ const by = str(left.by);
33
+ return {
34
+ title,
35
+ joinedAt,
36
+ ...(report ? { report } : {}),
37
+ ...(by === "failed" || by === "interrupted" || by === "deleted" || by === "dropped" ? { left: { at: num(left.at) ?? 0, by } } : {}),
38
+ };
39
+ }
40
+ function readReport(value) {
41
+ const given = obj(value);
42
+ const status = given.status;
43
+ const message = str(given.message);
44
+ const at = num(given.at);
45
+ if (!isStatus(status) || message === undefined || at === undefined)
46
+ return undefined;
47
+ return { at, status, message, ...(given.artifacts !== undefined ? { artifacts: given.artifacts } : {}) };
48
+ }
49
+ /** A member as the storage takes it, which asks for an index signature the interface lacks. */
50
+ const stored = (member) => ({ ...member });
51
+ /** The members under `prefix`, a key prefix within `PREFIX`; a value that is not a member is left out. */
52
+ async function membersUnder(storage, prefix) {
53
+ const entries = await scanEntries(storage, prefix);
54
+ return entries.flatMap(({ key, value }) => {
55
+ const parts = `${prefix.slice(PREFIX.length)}${key}`.split("/");
56
+ const member = parts.length === 3 ? readMember(value) : undefined;
57
+ return member ? [{ ...member, parentID: parts[0], group: parts[1], sessionID: parts[2] }] : [];
58
+ });
59
+ }
60
+ /** The member a session is in a parent's group, if the group is open and it is a member. */
61
+ export async function memberOf(storage, parentID, group, sessionID) {
62
+ return readMember(await storage.get(memberKey(parentID, group, sessionID)));
63
+ }
64
+ /** Makes a spawned session a member of its parent's group, opening the group if it was not. */
65
+ export async function joinGroup(storage, parentID, group, sessionID, title, at) {
66
+ await storage.set(memberKey(parentID, group, sessionID), stored({ title, joinedAt: at }));
67
+ }
68
+ /** Whether every member of a group has a held report or has left: released once the parent's turn has ended. */
69
+ const complete = (members) => members.every((member) => member.report || member.left);
70
+ /** The members of each group among `members`, by the claim key of the group. */
71
+ function byGroup(members) {
72
+ const groups = new Map();
73
+ for (const member of members) {
74
+ const key = claimKey(member.parentID, member.group);
75
+ const group = groups.get(key);
76
+ if (group)
77
+ group.push(member);
78
+ else
79
+ groups.set(key, [member]);
80
+ }
81
+ return groups;
82
+ }
83
+ /** Whether a parent has a complete group with a report in it, which the scheduler delivers once the parent's turn has ended. */
84
+ export async function hasCompleteGroup(storage, parentID) {
85
+ return [...byGroup(await membersUnder(storage, groupsPrefix(parentID))).values()].some((members) => complete(members) && members.some((member) => member.report));
86
+ }
87
+ /** Holds a member's report with its group, in place of an earlier one, or of how it had left. */
88
+ export async function holdReport(storage, membership, report) {
89
+ const { parentID, group, sessionID, title, joinedAt } = membership;
90
+ await storage.set(memberKey(parentID, group, sessionID), stored({ title, joinedAt, report }));
91
+ }
92
+ /** How many members of a group have reported and how many have not left, and whether the group is complete. */
93
+ export async function groupStanding(storage, parentID, group) {
94
+ const members = await membersUnder(storage, groupPrefix(parentID, group));
95
+ return {
96
+ reported: members.filter((member) => member.report).length,
97
+ members: members.filter((member) => !member.left).length,
98
+ complete: complete(members),
99
+ };
100
+ }
101
+ /** A session's membership of a parent's group, if it is a member; `out` says whether the group still waits for its report. */
102
+ export async function membershipOf(storage, parentID, group, sessionID) {
103
+ const member = await memberOf(storage, parentID, group, sessionID);
104
+ return member && { ...member, parentID, group, sessionID, out: standingOf(member) === "out" };
105
+ }
106
+ /**
107
+ * Notes that a member left its group without a report: its turn failed or was interrupted, it was deleted or dropped. Read
108
+ * again first: one whose report is held meanwhile keeps it, one that left keeps why. True when that completes the group.
109
+ */
110
+ export async function markLeft(storage, membership, by, at) {
111
+ const { parentID, group, sessionID } = membership;
112
+ const current = await memberOf(storage, parentID, group, sessionID);
113
+ if (!current || standingOf(current) !== "out")
114
+ return false;
115
+ await storage.set(memberKey(parentID, group, sessionID), stored({ title: current.title, joinedAt: current.joinedAt, left: { at, by } }));
116
+ return (await groupStanding(storage, parentID, group)).complete;
117
+ }
118
+ /** `markLeft` for a member not read yet; undefined when it is in no open group. */
119
+ export async function leaveGroup(storage, parentID, group, sessionID, by, at) {
120
+ const membership = await membershipOf(storage, parentID, group, sessionID);
121
+ return membership && (await markLeft(storage, membership, by, at));
122
+ }
123
+ /**
124
+ * Puts a member whose report is held back among those out: its latest word to the parent was a blocked report. Read
125
+ * again first: a group released meanwhile is not opened again, and a report held since is kept.
126
+ */
127
+ export async function unholdForBlocked(storage, membership) {
128
+ const { parentID, group, sessionID, title, joinedAt } = membership;
129
+ if (!membership.report)
130
+ return;
131
+ const current = await memberOf(storage, parentID, group, sessionID);
132
+ if (JSON.stringify(current?.report) !== JSON.stringify(membership.report))
133
+ return;
134
+ await storage.set(memberKey(parentID, group, sessionID), stored({ title, joinedAt }));
135
+ }
136
+ /** Drops a member from its group as its roster entry goes, unless its report is held: that still goes to the parent. True when dropped. */
137
+ export async function dropMember(storage, parentID, group, sessionID) {
138
+ const member = await memberOf(storage, parentID, group, sessionID);
139
+ if (!member || member.report)
140
+ return false;
141
+ await storage.remove(memberKey(parentID, group, sessionID));
142
+ return true;
143
+ }
144
+ /** Drops a member from its group whatever it holds: its report could not be held after all, so it goes on its own. */
145
+ export async function unholdReport(storage, membership) {
146
+ await storage.remove(memberKey(membership.parentID, membership.group, membership.sessionID));
147
+ }
148
+ /** Drops every group of a parent, whose members' reports can no longer be delivered. */
149
+ export async function dropGroups(storage, parentID) {
150
+ const members = await membersUnder(storage, groupsPrefix(parentID));
151
+ await Promise.all(members.map((member) => storage.remove(memberKey(parentID, member.group, member.sessionID))));
152
+ }
153
+ /** The claim key of a group's release, in the hub's shared set of deliveries under way. */
154
+ const claimKey = (parentID, group) => `group:${parentID}/${group}`;
155
+ /**
156
+ * Delivers every released group once, from the scheduler's tick: claimed in the shared `claimed`, re-read,
157
+ * delivered and only then dropped, so a failed delivery is tried again at the next tick, and a crash in between delivers it again.
158
+ */
159
+ export async function deliverReleased(ports, claimed) {
160
+ const groups = [...byGroup(await membersUnder(ports.storage, PREFIX))].filter(([key, members]) => complete(members) && !claimed.has(key));
161
+ // Claimed all at once, before any delivery, so a tick alongside claims none of these.
162
+ for (const [key] of groups)
163
+ claimed.add(key);
164
+ await Promise.all(groups.map(([key, members]) => releaseClaimed(ports, claimed, key, members[0])));
165
+ }
166
+ async function releaseClaimed(ports, claimed, key, { parentID, group }) {
167
+ try {
168
+ await release(ports, parentID, group);
169
+ }
170
+ catch (error) {
171
+ ports.log(`courier group ${group} of ${parentID}: ${String(error)}`);
172
+ }
173
+ finally {
174
+ claimed.delete(key);
175
+ }
176
+ }
177
+ /**
178
+ * Whether the parent's turn that started a group's last member has ended, so no more join it; `gone` when OpenCode no
179
+ * longer knows the parent. A turn ending stamps the session's `time.idle`, which a shutdown does not.
180
+ */
181
+ async function sealed(ports, parentID, members) {
182
+ const joined = Math.max(...members.map((member) => member.joinedAt));
183
+ try {
184
+ const { time } = await ports.session.get({ sessionID: parentID });
185
+ // NaN, for a parent that has ended no turn yet, holds it.
186
+ return millis(time.idle) >= joined;
187
+ }
188
+ catch (error) {
189
+ if (isNotFound(error))
190
+ return "gone";
191
+ throw error;
192
+ }
193
+ }
194
+ async function release(ports, parentID, group) {
195
+ // Re-read: another server may have delivered and dropped it since the scan.
196
+ const members = (await membersUnder(ports.storage, groupPrefix(parentID, group))).sort((a, b) => a.joinedAt - b.joinedAt);
197
+ if (!members.length || !complete(members))
198
+ return;
199
+ const seal = await sealed(ports, parentID, members);
200
+ if (!seal)
201
+ return;
202
+ if (seal === "gone")
203
+ return drop(ports, parentID, group, members, "the session is gone");
204
+ const reports = members.flatMap((member) => (member.report ? [{ sessionID: member.sessionID, title: member.title, ...member.report }] : []));
205
+ const left = members.flatMap((member) => (member.left ? [{ sessionID: member.sessionID, title: member.title, by: member.left.by }] : []));
206
+ if (reports.length) {
207
+ try {
208
+ await ports.session.synthetic({
209
+ sessionID: parentID,
210
+ text: envelope(reports.map((report) => report.sessionID).join(","), groupNotice(group, reports, left), {
211
+ group,
212
+ reports: String(reports.length),
213
+ }),
214
+ description: `Reports of group ${group}`,
215
+ metadata: { source: "courier", from: reports.map((report) => report.sessionID).join(","), group, reports: reports.length },
216
+ delivery: "steer",
217
+ });
218
+ }
219
+ catch (error) {
220
+ // A parent OpenCode no longer knows will never take it: dropped, like the children of a deleted session.
221
+ if (!isNotFound(error)) {
222
+ ports.log(`courier group ${group} of ${parentID} not delivered, held for another try: ${String(error)}`);
223
+ return;
224
+ }
225
+ return drop(ports, parentID, group, members, `the session is gone (${String(error)})`);
226
+ }
227
+ }
228
+ await drop(ports, parentID, group, members);
229
+ }
230
+ /** Drops a group's members once delivered, or unsent, saying why. */
231
+ async function drop(ports, parentID, group, members, unsent) {
232
+ if (unsent)
233
+ ports.log(`courier group ${group} of ${parentID} dropped: ${unsent}`);
234
+ // A member left behind here is delivered again at the next tick, as after a crash: logged, so it can be seen.
235
+ const dropped = await Promise.allSettled(members.map((member) => dropDelivered(ports.storage, member)));
236
+ for (const [index, result] of dropped.entries())
237
+ if (result.status === "rejected")
238
+ ports.log(`courier group ${group} of ${parentID}: delivered, but ${members[index].sessionID} could not be dropped: ${String(result.reason)}`);
239
+ }
240
+ /** Drops a delivered member's key, unless it was written again since it was read: that report is still to deliver. */
241
+ async function dropDelivered(storage, delivered) {
242
+ const key = memberKey(delivered.parentID, delivered.group, delivered.sessionID);
243
+ const { parentID, group, sessionID, ...read } = delivered;
244
+ if (JSON.stringify(readMember(await storage.get(key))) !== JSON.stringify(read))
245
+ return;
246
+ await storage.remove(key);
247
+ }
package/dist/hub.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Plugin } from "@opencode/plugin";
2
2
  import type { Server } from "node:http";
3
3
  import { type LaterPorts } from "./later.js";
4
+ import type { SpawnGate } from "./limits.js";
4
5
  import type { Asked, Outcome, Withdrawal } from "./notices.js";
5
6
  import type { Permissions, Waiting } from "./relay.js";
6
7
  import type { RosterStorage } from "./roster.js";
@@ -8,36 +9,15 @@ import { type Registry } from "./storage.js";
8
9
  import type { WebhookConfig, WebhookPorts } from "./webhook.js";
9
10
  type Context = Plugin.Context;
10
11
  /**
11
- * The plugin's process-wide state, in one place. OpenCode sets the plugin up once per location
12
- * (project or worktree), all in one process, and after an update loads the new copy of the package
13
- * next to the old one until the old one unloads; so the state every instance shares lives on
14
- * `globalThis`, outside the module graph, and this is the only module that puts it there.
15
- *
16
- * The hub is versioned by `HUB_VERSION`, not by the package's version: copies whose hub has the
17
- * same shape share one hub, however far apart their releases, and only a change to the hub's shape
18
- * (the ports a `Member` carries included) or to the meaning of one of its fields bumps it. An
19
- * optional field that a copy without it gets along without, such as `Member.location` or
20
- * `Scheduler.running`, is added without a bump while no release has the hub (the latest is 0.2.2).
21
- *
22
- * Version skew: a copy that finds a hub of another version under `opencode-courier.hub` logs that
23
- * once and runs its own hub under `opencode-courier.hub@<version>`. Whatever its version, a hub
24
- * takes the claim sets and the other shared objects of the copies before the hub from their own
25
- * keys (`opencode-courier.claimed`, `.watched`, `.forms`, `.questions`, `.receiver`, `.locations`),
26
- * making each when it is missing, so every copy in the process, with a hub or without, claims an
27
- * event or a delivery in the same set, and an old and a new copy never both act on it. Those keys'
28
- * shapes and meanings are therefore fixed: a hub version that needs other ones makes new keys, and
29
- * keeps claiming in the old ones too while a copy that uses them may still be loaded. `members` is
30
- * the one thing each version keeps to itself, so a job a hub runs once for its members runs once
31
- * per hub version, and still claims what it acts on in the shared sets, which keep it from acting
32
- * twice.
12
+ * The plugin's process-wide state on `globalThis`, shared by every loaded copy; only this module puts
13
+ * it there. `HUB_VERSION`, the fixed claim keys and version skew: docs/reference.md, § Several copies.
33
14
  */
34
- export declare const HUB_VERSION = 1;
15
+ export declare const HUB_VERSION = 2;
35
16
  /** The key of the hub; a copy that finds another version there keeps its own under `${HUB_KEY}@<version>`. */
36
17
  export declare const HUB_KEY = "opencode-courier.hub";
37
18
  /**
38
- * A question this process knows: one whose call waits (`call`), or one whose call was cut off,
39
- * which is answered by message. `link` withdraws the top session's question call that passes its
40
- * answer on, once the question is settled without that call.
19
+ * A question this process knows: its call waits (`call`), or was cut off and is answered by message.
20
+ * `link` withdraws the top session's call passing the answer on, once settled without that call.
41
21
  */
42
22
  export interface Question extends Asked {
43
23
  /** The directory of the location the call runs in, whose shutdown withdraws its form. */
@@ -51,11 +31,8 @@ export interface Question extends Asked {
51
31
  settling?: Promise<void>;
52
32
  }
53
33
  /**
54
- * The relay's timings, carried by its ports: how long a question cut off by a closing location
55
- * waits before an instance still loaded tells its top session, how long an answer waits for another
56
- * one to it that is still being passed on, how long what follows a call's end waits for the notice
57
- * of the question to go out, and how long a dismissal is held to see whether the location is
58
- * shutting down.
34
+ * The relay's timings, carried by its ports: the closing grace before a cut-off question is told, the
35
+ * wait for an answer still being passed on, the wait for a notice to go out, and the dismissal hold.
59
36
  */
60
37
  export interface QuestionTiming {
61
38
  readonly closingGraceMs: number;
@@ -76,7 +53,8 @@ export interface QuestionPorts {
76
53
  }
77
54
  export interface WatchPorts {
78
55
  readonly storage: RosterStorage;
79
- readonly session: Pick<Context["session"], "synthetic">;
56
+ /** `context` reads the last reply of a session that ended its turn without reporting. */
57
+ readonly session: Pick<Context["session"], "synthetic" | "context">;
80
58
  readonly event: Pick<Context["event"], "subscribe">;
81
59
  /**
82
60
  * The permission domains of every loaded location, whose pending requests are relayed when the
@@ -86,20 +64,20 @@ export interface WatchPorts {
86
64
  /** The clock a location shutdown is recorded by: the question relay's, which judges it. */
87
65
  readonly now: () => number;
88
66
  readonly log: (message: string) => void;
67
+ /** Has the scheduler deliver what is due now rather than at its next tick: a group completed, or its parent's turn ended. */
68
+ readonly nudge: () => void;
89
69
  }
90
70
  /**
91
- * What every plugin instance in the process shares. A hub follows OpenCode's events through two of
92
- * its members, one a standby, and a copy of another hub version, or one from before the hub, follows
93
- * them too, all sent the same events; so an event id is claimed synchronously and handled once.
94
- * `waiting` holds the permission requests a session was told about and has not answered;
95
- * `answered`, requests answered before anyone was told, so a notice whose roster lookup was
96
- * overtaken by the answer is not sent. `forms` does the same for the forms of spawned sessions.
71
+ * What every plugin instance in the process shares. Several subscriptions and copies see the same
72
+ * events, so each is claimed synchronously and handled once; `answered` keeps stale notices unsent.
97
73
  */
98
74
  export interface WatchState {
99
75
  readonly seen: Set<string>;
100
76
  readonly waiting: Waiting;
101
77
  readonly answered: Set<string>;
102
78
  readonly forms: FormsTold;
79
+ /** The type of each item enqueued for a spawned session, by inbox id, for its delivery to look up. */
80
+ readonly inbox: Map<string, string>;
103
81
  }
104
82
  /** The forms sessions were told about, kept apart from the permission requests. */
105
83
  export interface FormsTold {
@@ -123,9 +101,8 @@ export interface Receivers {
123
101
  closing?: Promise<void>;
124
102
  }
125
103
  /**
126
- * The question relay's state, shared by every instance, since a child and the session it asks can
127
- * be in different locations: the questions told to a top session and not yet settled, the cut-off
128
- * ones whose top session has been told so, and the question calls waiting for their form to be shown.
104
+ * The question relay's state, shared by every instance, as a child and the session it asks can be in
105
+ * different locations: open questions, cut-off ones already told, and calls waiting for their form.
129
106
  */
130
107
  export interface QuestionState {
131
108
  readonly questions: Map<string, Question>;
@@ -135,9 +112,8 @@ export interface QuestionState {
135
112
  readonly passing: Map<string, Promise<unknown>>;
136
113
  readonly answered: Set<string>;
137
114
  /**
138
- * The ports of the loaded instances, of every copy of the plugin. A release before the hub picks
139
- * one here to tell a cut-off question through, so every copy keeps it filled; this copy reads its
140
- * hub's members, and this only for an instance of another copy, when it has none.
115
+ * The ports of every loaded instance of every copy. A release before the hub picks one here to tell
116
+ * a cut-off question through, so every copy fills it; this copy reads it only for another copy.
141
117
  */
142
118
  readonly loaded: Set<QuestionPorts>;
143
119
  /** How many instances follow OpenCode's events now, and whether any has since the process started. */
@@ -186,11 +162,8 @@ export interface Scheduler {
186
162
  /** The loop's interval, while it runs. */
187
163
  timer?: unknown;
188
164
  /**
189
- * The tick under way, if any, whichever copy of the plugin started it and whether or not its
190
- * member is still loaded. A tick that falls due meanwhile renews the owner key for it, for up to a
191
- * minute from its start, and is skipped; past that minute one whose member has left holds up
192
- * nothing, and the next tick starts. Optional, since a copy of the same hub version made before it
193
- * existed keeps only `ticking`.
165
+ * The tick under way, whichever copy started it: for a minute the key is renewed for it and due
166
+ * ticks skip; past it, one whose member left holds nothing up. Older copies have only `ticking`.
194
167
  */
195
168
  running?: Tick;
196
169
  /**
@@ -200,6 +173,8 @@ export interface Scheduler {
200
173
  ticking?: Member;
201
174
  /** One tick, by the copy of the plugin that joined last, so a copy loaded after an update runs its own code. */
202
175
  tick?: () => Promise<void>;
176
+ /** Whether a nudge came since the tick under way started, whichever copy's, so it ticks once more when done. */
177
+ nudged?: boolean;
203
178
  }
204
179
  /** How the hub starts and stops the scheduler's loop, and waits before re-reading the owner key; tests pass their own. */
205
180
  export interface Timers {
@@ -208,10 +183,8 @@ export interface Timers {
208
183
  readonly wait: (ms: number) => Promise<void>;
209
184
  }
210
185
  /**
211
- * The key in the plugin's storage, shared by every OpenCode server on one data directory, naming
212
- * the server whose scheduler delivers the `courier_later` messages: `{ server, at }`, its id and
213
- * when it last wrote the key, in epoch milliseconds. Outside `later/`, which the schedulers scan
214
- * for messages, those of copies before the key included.
186
+ * The storage key, shared by every server on one data directory, naming the server whose scheduler
187
+ * delivers `courier_later` messages: `{ server, at }`, its id and when it last wrote (epoch ms).
215
188
  */
216
189
  export declare const OWNER_KEY = "scheduler/owner";
217
190
  /** How long the owner key holds without being renewed; the owner renews it every tick, four times as often. */
@@ -229,9 +202,8 @@ export declare const OWNER_WAIT_MS = 500;
229
202
  */
230
203
  export declare const SERVER: string;
231
204
  /**
232
- * How long an unloading instance waits, before it lets go, for the work it leaves behind: what it
233
- * started itself, a tick or an event's notice still under way through it, and on the last leave the
234
- * owner key's release.
205
+ * How long an unloading instance waits for the work it leaves behind: what it started itself, a tick
206
+ * or a notice under way through it, and on the last leave the owner key's release.
235
207
  */
236
208
  export declare const LEAVE_MS = 2000;
237
209
  /** How many subscriptions to OpenCode's events a hub keeps, each through another member: one, and a standby. */
@@ -261,7 +233,7 @@ export interface Hub {
261
233
  /** The ids of the `courier_later` messages being delivered. */
262
234
  readonly claimed: Set<string>;
263
235
  /** The events handled, and the permission requests a session was told about or that were answered first. */
264
- readonly watched: Omit<WatchState, "forms">;
236
+ readonly watched: Omit<WatchState, "forms" | "inbox">;
265
237
  /** The forms sessions were told about, and those settled first. */
266
238
  readonly forms: FormsTold;
267
239
  /**
@@ -278,26 +250,25 @@ export interface Hub {
278
250
  export interface Opened {
279
251
  readonly hub: Hub;
280
252
  /**
281
- * Adds a loaded instance to the hub and its permission domain to `locations`; returns its leave,
282
- * which takes both out again. The first instance to join logs a hub of another version found
283
- * under `HUB_KEY`. An instance joining a hub without members starts the scheduler's loop, with a
284
- * tick at once; the last one to leave stops it. An instance joining while the hub has fewer than
285
- * `WATCHERS` subscriptions to OpenCode's events starts one through itself; when one leaves that
286
- * has one, another member without one starts its replacement before it ends. The last leave
287
- * settles once the scheduler's owner key is released, after the tick under way, whichever copy
288
- * started it. Every leave first waits for the work its member leaves behind: what was handed to
289
- * `track`, a tick under way through it, and the subscription it ran, which ends after the event
290
- * it is handling; all of it, the release included, for up to `LEAVE_MS`. The question relay's
291
- * own work is not waited for: it outlives the instance on purpose.
253
+ * Adds a loaded instance to the hub and `locations`; returns its leave. The first join starts the
254
+ * scheduler and watchers, the last leave stops them; a leave waits `LEAVE_MS` for the member's work.
292
255
  */
293
256
  readonly join: (member: Member) => () => Promise<void>;
294
257
  /** Hands the hub work a member started, which its leave waits for; the member's own background work. */
295
258
  readonly track: (member: Member, work: Promise<unknown>) => void;
259
+ /** The spawns under way, under a fixed key of its own rather than in the hub, so every hub version shares it. */
260
+ readonly gate: SpawnGate;
261
+ /** `WatchState.inbox`, under a fixed key of its own like the gate, so every hub version shares it. */
262
+ readonly inbox: Map<string, string>;
263
+ /**
264
+ * Runs a tick now, or once the tick under way has ended, so what has just fallen due (a released
265
+ * group) is delivered at once rather than at the next tick; nothing while no instance is loaded.
266
+ */
267
+ readonly nudge: () => void;
296
268
  }
297
269
  /**
298
- * Finds or makes this copy's hub in `registry`: the one under `HUB_KEY`, or its own under a
299
- * versioned key when a hub of another version holds that. Either way, its claim sets and shared
300
- * objects are the ones under the keys of the copies before the hub, made when missing.
270
+ * Finds or makes this copy's hub in `registry`: the one under `HUB_KEY`, or its own under a versioned
271
+ * key when another version holds that; its claim sets are the pre-hub keys', made when missing.
301
272
  */
302
273
  export declare function open(registry: Registry, timers?: Timers, server?: string): Opened;
303
274
  /** This copy's hub, in the process. */
@@ -306,20 +277,24 @@ export declare const hub: Hub;
306
277
  export declare const join: (member: Member) => () => Promise<void>;
307
278
  /** Hands this copy's hub work a member started; see `Opened.track`. */
308
279
  export declare const track: (member: Member, work: Promise<unknown>) => void;
280
+ /** The spawns under way in the process; see `Opened.gate`. */
281
+ export declare const gate: SpawnGate;
282
+ /** The types of the items enqueued for spawned sessions; see `Opened.inbox`. */
283
+ export declare const inbox: Map<string, string>;
284
+ /** Has this copy's scheduler deliver what is due now; see `Opened.nudge`. */
285
+ export declare const nudge: () => void;
309
286
  /** The member of this copy's hub set up with `location` that joined last, while it is loaded. */
310
287
  export declare function memberAt(location: object): Member | undefined;
311
288
  /**
312
- * The question ports of the member that has just joined at `location`, the newest there, for as long
313
- * as it stays loaded: resolved once, so an instance joining later at the same location, while this
314
- * one is still loaded, does not take its place.
289
+ * The question ports of the member that just joined at `location`, while it is loaded: resolved once,
290
+ * so a later instance at the same location does not take its place while this one is still loaded.
315
291
  */
316
292
  export declare function portsAt(location: object): () => QuestionPorts | undefined;
317
293
  /** The permission domains of the loaded instances. */
318
294
  export declare const permissions: () => MapIterator<Permissions>;
319
295
  /**
320
- * For tests: forgets every remembered id and the question relay's state, the relay's loaded
321
- * instances and its count of watchers included. The hub's members, the locations and the webhook
322
- * receiver are left alone; leaving and unloading take them out.
296
+ * For tests: forgets every remembered id, the spawns under way and the question relay's state, loaded
297
+ * instances and watcher count included. Members, locations and the receiver are left to unloading.
323
298
  */
324
299
  export declare function resetHub(): void;
325
300
  export {};