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/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 ADDED
@@ -0,0 +1,300 @@
1
+ import type { Plugin } from "@opencode/plugin";
2
+ import type { Server } from "node:http";
3
+ import { type LaterPorts } from "./later.js";
4
+ import type { SpawnGate } from "./limits.js";
5
+ import type { Asked, Outcome, Withdrawal } from "./notices.js";
6
+ import type { Permissions, Waiting } from "./relay.js";
7
+ import type { RosterStorage } from "./roster.js";
8
+ import { type Registry } from "./storage.js";
9
+ import type { WebhookConfig, WebhookPorts } from "./webhook.js";
10
+ type Context = Plugin.Context;
11
+ /**
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.
14
+ */
15
+ export declare const HUB_VERSION = 2;
16
+ /** The key of the hub; a copy that finds another version there keeps its own under `${HUB_KEY}@<version>`. */
17
+ export declare const HUB_KEY = "opencode-courier.hub";
18
+ /**
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.
21
+ */
22
+ export interface Question extends Asked {
23
+ /** The directory of the location the call runs in, whose shutdown withdraws its form. */
24
+ directory?: string;
25
+ /** Hands the waiting call the top session's outcome; true once the call has ended with it. */
26
+ call?: (outcome: Outcome) => Promise<boolean>;
27
+ link?: (how: Withdrawal) => void;
28
+ /** Storing the question and telling the top session, while that is under way. */
29
+ relaying?: Promise<boolean>;
30
+ /** Working out what became of it once its call ended without the top session's answer. */
31
+ settling?: Promise<void>;
32
+ }
33
+ /**
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.
36
+ */
37
+ export interface QuestionTiming {
38
+ readonly closingGraceMs: number;
39
+ readonly passingWaitMs: number;
40
+ readonly relayWaitMs: number;
41
+ readonly dismissalGraceMs: number;
42
+ }
43
+ export interface QuestionPorts {
44
+ readonly storage: RosterStorage;
45
+ readonly session: Pick<Context["session"], "synthetic">;
46
+ /** The directory of the instance's location. */
47
+ readonly directory: string;
48
+ /** The clock, in epoch milliseconds, as OpenCode's location shutdowns are recorded. */
49
+ readonly now: () => number;
50
+ readonly timing: QuestionTiming;
51
+ readonly newID: () => string;
52
+ readonly log: (message: string) => void;
53
+ }
54
+ export interface WatchPorts {
55
+ readonly storage: RosterStorage;
56
+ /** `context` reads the last reply of a session that ended its turn without reporting. */
57
+ readonly session: Pick<Context["session"], "synthetic" | "context">;
58
+ readonly event: Pick<Context["event"], "subscribe">;
59
+ /**
60
+ * The permission domains of every loaded location, whose pending requests are relayed when the
61
+ * watcher (re)subscribes: one watcher serves them all, and an isolated child's are in its worktree's.
62
+ */
63
+ readonly permissions: () => Iterable<Pick<Context["permission"], "list">>;
64
+ /** The clock a location shutdown is recorded by: the question relay's, which judges it. */
65
+ readonly now: () => number;
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;
69
+ }
70
+ /**
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.
73
+ */
74
+ export interface WatchState {
75
+ readonly seen: Set<string>;
76
+ readonly waiting: Waiting;
77
+ readonly answered: Set<string>;
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>;
81
+ }
82
+ /** The forms sessions were told about, kept apart from the permission requests. */
83
+ export interface FormsTold {
84
+ /** Forms still shown whose notice went out, or is going out: the notice's delivery. */
85
+ readonly told: Map<string, Promise<unknown>>;
86
+ /** Forms settled before anyone was told, so a notice whose roster lookup was overtaken is not sent. */
87
+ readonly settled: Set<string>;
88
+ }
89
+ /**
90
+ * The webhook receiver, one per process: every instance configured with a `webhook` option joins
91
+ * `instances`, the first one starts the server, and the last one to unload stops it.
92
+ */
93
+ export interface Receiver {
94
+ readonly config: WebhookConfig;
95
+ readonly instances: Set<WebhookPorts>;
96
+ readonly server: Promise<Server | undefined>;
97
+ }
98
+ /** The current receiver, if any, and the previous one while it closes, which a new one waits for. */
99
+ export interface Receivers {
100
+ current?: Receiver;
101
+ closing?: Promise<void>;
102
+ }
103
+ /**
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.
106
+ */
107
+ export interface QuestionState {
108
+ readonly questions: Map<string, Question>;
109
+ readonly noticed: Set<string>;
110
+ readonly shown: Map<string, () => void>;
111
+ /** The questions whose answer is being passed on, each until that is done, and those whose answer went out. */
112
+ readonly passing: Map<string, Promise<unknown>>;
113
+ readonly answered: Set<string>;
114
+ /**
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.
117
+ */
118
+ readonly loaded: Set<QuestionPorts>;
119
+ /** How many instances follow OpenCode's events now, and whether any has since the process started. */
120
+ following: number;
121
+ followed: boolean;
122
+ /**
123
+ * When OpenCode last reported each location shutting down (`location.shutdown`), by directory
124
+ * in epoch milliseconds by the clock of the watcher that saw it; under `""` when the event named no location.
125
+ */
126
+ readonly shutdowns: Map<string, number>;
127
+ /** Woken when a location shuts down or an instance unloads: dismissals held to see whether one follows. */
128
+ readonly closingWaiters: Set<() => void>;
129
+ }
130
+ /**
131
+ * A loaded plugin instance: its location's directory and permission domain, and the ports a job
132
+ * run once for the whole process would run it through.
133
+ */
134
+ export interface Member {
135
+ readonly directory: string;
136
+ /**
137
+ * The location OpenCode set the instance up with, the very object the instance's Effect half is
138
+ * handed too, which finds its member by it (`portsAt`). A member of a copy before it has none.
139
+ */
140
+ readonly location?: object;
141
+ readonly permission: Permissions;
142
+ readonly later: LaterPorts;
143
+ readonly watch: WatchPorts;
144
+ readonly questions: QuestionPorts;
145
+ readonly log: (message: string) => void;
146
+ }
147
+ /**
148
+ * A tick under way: the member it runs through, when it started, by the wall clock every member's
149
+ * `now` reads (the next ticks compare it with another member's), and its end.
150
+ */
151
+ export interface Tick {
152
+ readonly member: Member;
153
+ readonly since: number;
154
+ /** Settles once the tick has ended, the owner key released if its loop stopped meanwhile; never rejects. */
155
+ readonly done: Promise<void>;
156
+ }
157
+ /**
158
+ * The scheduler's loop, one per hub: it runs while the hub has members, and each tick delivers the
159
+ * due `courier_later` messages through the ports of the member that joined first and is still loaded.
160
+ */
161
+ export interface Scheduler {
162
+ /** The loop's interval, while it runs. */
163
+ timer?: unknown;
164
+ /**
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`.
167
+ */
168
+ running?: Tick;
169
+ /**
170
+ * The member the tick under way runs through, if any, set and cleared with `running`, for a copy
171
+ * that reads only this: a tick that falls due meanwhile is skipped, unless that member has left.
172
+ */
173
+ ticking?: Member;
174
+ /** One tick, by the copy of the plugin that joined last, so a copy loaded after an update runs its own code. */
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;
178
+ }
179
+ /** How the hub starts and stops the scheduler's loop, and waits before re-reading the owner key; tests pass their own. */
180
+ export interface Timers {
181
+ readonly every: (run: () => void, ms: number) => unknown;
182
+ readonly stop: (timer: unknown) => void;
183
+ readonly wait: (ms: number) => Promise<void>;
184
+ }
185
+ /**
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).
188
+ */
189
+ export declare const OWNER_KEY = "scheduler/owner";
190
+ /** How long the owner key holds without being renewed; the owner renews it every tick, four times as often. */
191
+ export declare const OWNER_EXPIRY_MS: number;
192
+ /**
193
+ * How old the owner's own key may be for it to renew the key and deliver at once. An older one,
194
+ * which it may no longer hold by the time it writes, is taken again like a free one, with the wait.
195
+ */
196
+ export declare const OWNER_RENEW_MS: number;
197
+ /** The wait between writing the owner key and reading it back, at least `OWNER_WAIT_MS` and up to twice that. */
198
+ export declare const OWNER_WAIT_MS = 500;
199
+ /**
200
+ * This server's id in the owner key: the same for every copy of the plugin in the process, and
201
+ * different in every other process, a restart of this one included.
202
+ */
203
+ export declare const SERVER: string;
204
+ /**
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.
207
+ */
208
+ export declare const LEAVE_MS = 2000;
209
+ /** How many subscriptions to OpenCode's events a hub keeps, each through another member: one, and a standby. */
210
+ export declare const WATCHERS = 2;
211
+ /** One of the hub's subscriptions to OpenCode's events: the member whose ports it runs through, and how it ends. */
212
+ export interface Watcher {
213
+ readonly member: Member;
214
+ readonly stop: AbortController;
215
+ /** Settles once the subscription has ended, after `stop`: after the event it is handling, not the stream's close. */
216
+ readonly done: Promise<void>;
217
+ }
218
+ export interface Hub {
219
+ readonly version: typeof HUB_VERSION;
220
+ /** The instances loaded now, in the order they joined. */
221
+ readonly members: Set<Member>;
222
+ readonly scheduler: Scheduler;
223
+ /**
224
+ * The hub's subscriptions to OpenCode's events, each through another member: two while two members
225
+ * are loaded, so one is always connected when the other's member leaves.
226
+ */
227
+ readonly watchers: Watcher[];
228
+ /**
229
+ * Starts a subscription through a member, by the copy of the plugin that set it last; until one
230
+ * has, the hub follows no events.
231
+ */
232
+ subscribe?: (member: Member) => Watcher;
233
+ /** The ids of the `courier_later` messages being delivered. */
234
+ readonly claimed: Set<string>;
235
+ /** The events handled, and the permission requests a session was told about or that were answered first. */
236
+ readonly watched: Omit<WatchState, "forms" | "inbox">;
237
+ /** The forms sessions were told about, and those settled first. */
238
+ readonly forms: FormsTold;
239
+ /**
240
+ * The permission domain of every loaded instance, by instance. OpenCode keeps a request where its
241
+ * session runs, so a request of an isolated child is answered through the instance loaded in its worktree.
242
+ */
243
+ readonly locations: Map<object, Permissions>;
244
+ readonly receivers: Receivers;
245
+ readonly questions: QuestionState;
246
+ /** Whether the hub of another version found under `HUB_KEY` has been logged. */
247
+ skewLogged: boolean;
248
+ }
249
+ /** A hub, as `open` finds or makes it, and how instances join it. */
250
+ export interface Opened {
251
+ readonly hub: Hub;
252
+ /**
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.
255
+ */
256
+ readonly join: (member: Member) => () => Promise<void>;
257
+ /** Hands the hub work a member started, which its leave waits for; the member's own background work. */
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;
268
+ }
269
+ /**
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.
272
+ */
273
+ export declare function open(registry: Registry, timers?: Timers, server?: string): Opened;
274
+ /** This copy's hub, in the process. */
275
+ export declare const hub: Hub;
276
+ /** Joins this copy's hub; see `Opened.join`. */
277
+ export declare const join: (member: Member) => () => Promise<void>;
278
+ /** Hands this copy's hub work a member started; see `Opened.track`. */
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;
286
+ /** The member of this copy's hub set up with `location` that joined last, while it is loaded. */
287
+ export declare function memberAt(location: object): Member | undefined;
288
+ /**
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.
291
+ */
292
+ export declare function portsAt(location: object): () => QuestionPorts | undefined;
293
+ /** The permission domains of the loaded instances. */
294
+ export declare const permissions: () => MapIterator<Permissions>;
295
+ /**
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.
298
+ */
299
+ export declare function resetHub(): void;
300
+ export {};