opencode-courier 0.2.3 → 0.4.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 (143) hide show
  1. package/README.md +198 -193
  2. package/dist/index.d.ts +4 -8
  3. package/dist/index.js +66 -85
  4. package/dist/kernel/protocol.d.ts +20 -0
  5. package/dist/kernel/protocol.js +36 -0
  6. package/dist/kernel/tool.d.ts +5 -0
  7. package/dist/kernel/tool.js +5 -0
  8. package/dist/person/events.d.ts +23 -0
  9. package/dist/person/events.js +113 -0
  10. package/dist/person/form.d.ts +26 -0
  11. package/dist/person/form.js +64 -0
  12. package/dist/person/index.d.ts +17 -0
  13. package/dist/person/index.js +5 -0
  14. package/dist/{relay.d.ts → person/permission.d.ts} +29 -25
  15. package/dist/{relay.js → person/permission.js} +11 -9
  16. package/dist/person/ports.d.ts +58 -0
  17. package/dist/person/ports.js +2 -0
  18. package/dist/{question → person/question}/answer.d.ts +33 -4
  19. package/dist/{question → person/question}/answer.js +49 -17
  20. package/dist/person/question/index.d.ts +7 -0
  21. package/dist/{question → person/question}/index.js +3 -3
  22. package/dist/person/question/lifecycle.d.ts +43 -0
  23. package/dist/{question → person/question}/lifecycle.js +10 -19
  24. package/dist/{question → person/question}/pure.d.ts +4 -6
  25. package/dist/{question → person/question}/pure.js +3 -4
  26. package/dist/{question → person/question}/relay.d.ts +1 -1
  27. package/dist/{question → person/question}/relay.js +27 -35
  28. package/dist/{question → person/question}/runtime.d.ts +2 -3
  29. package/dist/{question → person/question}/runtime.js +2 -3
  30. package/dist/{question → person/question}/shared.d.ts +13 -3
  31. package/dist/person/question/shared.js +52 -0
  32. package/dist/person/question/types.d.ts +121 -0
  33. package/dist/person/question/types.js +1 -0
  34. package/dist/person/rpc-handlers.d.ts +160 -0
  35. package/dist/person/rpc-handlers.js +62 -0
  36. package/dist/person/rpc.d.ts +227 -0
  37. package/dist/person/rpc.js +70 -0
  38. package/dist/person/text.d.ts +68 -0
  39. package/dist/{notices.js → person/text.js} +53 -190
  40. package/dist/person/tools.d.ts +20 -0
  41. package/dist/person/tools.js +55 -0
  42. package/dist/person/tui.d.ts +46 -0
  43. package/dist/platform/bounded.d.ts +12 -0
  44. package/dist/{bounded.js → platform/bounded.js} +6 -4
  45. package/dist/platform/events.d.ts +34 -0
  46. package/dist/platform/events.js +121 -0
  47. package/dist/platform/hub.d.ts +195 -0
  48. package/dist/{hub.js → platform/hub.js} +78 -119
  49. package/dist/platform/json.d.ts +8 -0
  50. package/dist/platform/json.js +15 -0
  51. package/dist/platform/version.d.ts +16 -0
  52. package/dist/{version.js → platform/version.js} +5 -11
  53. package/dist/rpc.d.ts +2 -0
  54. package/dist/rpc.js +3 -0
  55. package/dist/scheduling/index.d.ts +6 -0
  56. package/dist/scheduling/index.js +5 -0
  57. package/dist/{later.d.ts → scheduling/later.d.ts} +13 -9
  58. package/dist/{later.js → scheduling/later.js} +18 -9
  59. package/dist/scheduling/text.d.ts +6 -0
  60. package/dist/scheduling/text.js +10 -0
  61. package/dist/scheduling/tools.d.ts +14 -0
  62. package/dist/scheduling/tools.js +48 -0
  63. package/dist/skill.d.ts +17 -0
  64. package/dist/skill.js +62 -0
  65. package/dist/{cleanup.d.ts → tree/cleanup.d.ts} +5 -26
  66. package/dist/{cleanup.js → tree/cleanup.js} +20 -15
  67. package/dist/tree/context.d.ts +15 -0
  68. package/dist/tree/context.js +34 -0
  69. package/dist/tree/domain.d.ts +101 -0
  70. package/dist/tree/domain.js +68 -0
  71. package/dist/tree/events.d.ts +71 -0
  72. package/dist/tree/events.js +204 -0
  73. package/dist/tree/forget.d.ts +24 -0
  74. package/dist/tree/forget.js +59 -0
  75. package/dist/tree/group.d.ts +82 -0
  76. package/dist/tree/group.js +203 -0
  77. package/dist/tree/index.d.ts +10 -0
  78. package/dist/tree/index.js +11 -0
  79. package/dist/tree/limits.d.ts +10 -0
  80. package/dist/tree/limits.js +60 -0
  81. package/dist/tree/panel.d.ts +13 -0
  82. package/dist/tree/panel.js +75 -0
  83. package/dist/tree/ports.d.ts +276 -0
  84. package/dist/tree/ports.js +1 -0
  85. package/dist/tree/report.d.ts +23 -0
  86. package/dist/tree/report.js +33 -0
  87. package/dist/tree/roster.d.ts +27 -0
  88. package/dist/tree/roster.js +62 -0
  89. package/dist/tree/rpc-handlers.d.ts +201 -0
  90. package/dist/tree/rpc-handlers.js +20 -0
  91. package/dist/tree/rpc.d.ts +344 -0
  92. package/dist/tree/rpc.js +83 -0
  93. package/dist/tree/send.d.ts +14 -0
  94. package/dist/tree/send.js +99 -0
  95. package/dist/tree/spawn.d.ts +21 -0
  96. package/dist/tree/spawn.js +173 -0
  97. package/dist/tree/status.d.ts +128 -0
  98. package/dist/tree/status.js +62 -0
  99. package/dist/tree/stop.d.ts +33 -0
  100. package/dist/tree/stop.js +116 -0
  101. package/dist/tree/store.d.ts +24 -0
  102. package/dist/tree/store.js +264 -0
  103. package/dist/tree/subtree.d.ts +101 -0
  104. package/dist/tree/subtree.js +60 -0
  105. package/dist/tree/summary.d.ts +7 -0
  106. package/dist/tree/summary.js +35 -0
  107. package/dist/tree/text.d.ts +77 -0
  108. package/dist/tree/text.js +287 -0
  109. package/dist/tree/tools.d.ts +54 -0
  110. package/dist/tree/tools.js +167 -0
  111. package/dist/tree/tui.d.ts +53 -0
  112. package/dist/tui.d.ts +3 -0
  113. package/dist/tui.js +1219 -0
  114. package/dist/webhook/index.d.ts +5 -0
  115. package/dist/webhook/index.js +3 -0
  116. package/dist/{webhook.d.ts → webhook/receiver.d.ts} +25 -36
  117. package/dist/{webhook.js → webhook/receiver.js} +66 -67
  118. package/dist/webhook/subscriptions.d.ts +29 -0
  119. package/dist/webhook/subscriptions.js +52 -0
  120. package/dist/webhook/text.d.ts +23 -0
  121. package/dist/webhook/text.js +82 -0
  122. package/dist/webhook/tools.d.ts +19 -0
  123. package/dist/webhook/tools.js +46 -0
  124. package/package.json +18 -7
  125. package/dist/bounded.d.ts +0 -10
  126. package/dist/courier.d.ts +0 -95
  127. package/dist/courier.js +0 -131
  128. package/dist/hub.d.ts +0 -325
  129. package/dist/json.d.ts +0 -4
  130. package/dist/json.js +0 -4
  131. package/dist/notices.d.ts +0 -246
  132. package/dist/question/index.d.ts +0 -6
  133. package/dist/question/lifecycle.d.ts +0 -52
  134. package/dist/question/shared.js +0 -24
  135. package/dist/roster.d.ts +0 -81
  136. package/dist/roster.js +0 -175
  137. package/dist/tools.d.ts +0 -74
  138. package/dist/tools.js +0 -228
  139. package/dist/version.d.ts +0 -22
  140. package/dist/watch.d.ts +0 -61
  141. package/dist/watch.js +0 -289
  142. /package/dist/{storage.d.ts → platform/storage.d.ts} +0 -0
  143. /package/dist/{storage.js → platform/storage.js} +0 -0
@@ -0,0 +1,34 @@
1
+ import type { Plugin } from "@opencode/plugin";
2
+ import type { Hub } from "./hub.js";
3
+ /** One of OpenCode's events, as a subscription yields it. */
4
+ export interface HostEvent {
5
+ readonly type: string;
6
+ readonly data?: unknown;
7
+ }
8
+ /** What following OpenCode's events needs of a member's ports: the stream, and a log. */
9
+ export interface EventPorts {
10
+ readonly event: Pick<Plugin.Context["event"], "subscribe">;
11
+ readonly log: (message: string) => void;
12
+ }
13
+ /** What a module does with OpenCode's events, by phase; each phase runs the modules in list order. */
14
+ export interface EventModule<P extends EventPorts> {
15
+ /** For each event; the first to throw ends the phase. */
16
+ readonly handle?: (ports: P, event: HostEvent) => Promise<unknown> | undefined;
17
+ /** For each event, once the `handle` phase has ended, however it did; the first to throw ends this one too. */
18
+ readonly after?: (ports: P, event: HostEvent) => Promise<void> | undefined;
19
+ /** As a subscription starts; returns what to run at its first event, if anything. */
20
+ readonly followed?: () => (() => void) | undefined;
21
+ /** Alongside each (re)subscription; the watcher's end waits for it. */
22
+ readonly subscribed?: (ports: P) => Promise<void>;
23
+ /** Once a subscription has ended or broken. */
24
+ readonly left?: () => void;
25
+ }
26
+ /** How long to wait before subscribing again after the event stream ended or broke. */
27
+ export declare const RESUBSCRIBE_MS = 5000;
28
+ /** Follows OpenCode's events through `ports`, subscribing again after `retryMs` when a subscription ends, until `signal` aborts. */
29
+ export declare function follow<P extends EventPorts>(ports: P, modules: readonly EventModule<P>[], signal: AbortSignal, retryMs?: number): Promise<void>;
30
+ /**
31
+ * Makes the hub start its subscriptions to OpenCode's events with this copy's modules, the copy loaded
32
+ * last; `join` starts them. The second is a standby, connected before the first's member leaves.
33
+ */
34
+ export declare function watchFromHub<P extends EventPorts>(hub: Pick<Hub, "subscribe">, modules: readonly EventModule<P>[], retryMs?: number): void;
@@ -0,0 +1,121 @@
1
+ /** How long to wait before subscribing again after the event stream ended or broke. */
2
+ export const RESUBSCRIBE_MS = 5_000;
3
+ const pause = (ms, signal) => new Promise((resolve) => {
4
+ const timer = setTimeout(done, ms);
5
+ function done() {
6
+ clearTimeout(timer);
7
+ signal.removeEventListener("abort", done);
8
+ resolve();
9
+ }
10
+ signal.addEventListener("abort", done);
11
+ });
12
+ const ENDED = { done: true, value: undefined };
13
+ /**
14
+ * `events` until it ends or `signal` aborts, the stream left open; each wait hears the abort only while
15
+ * it waits, so a watcher that follows events for the life of the process holds nothing per event.
16
+ */
17
+ const until = (events, signal) => ({
18
+ [Symbol.asyncIterator]: () => ({
19
+ next: () => new Promise((resolve, reject) => {
20
+ if (signal.aborted)
21
+ return resolve(ENDED);
22
+ const stop = () => resolve(ENDED);
23
+ signal.addEventListener("abort", stop, { once: true });
24
+ // Once the signal has aborted, whatever the stream ends this wait with is not wanted.
25
+ events.next().then((next) => {
26
+ signal.removeEventListener("abort", stop);
27
+ resolve(next);
28
+ }, (error) => {
29
+ signal.removeEventListener("abort", stop);
30
+ reject(error);
31
+ });
32
+ }),
33
+ }),
34
+ });
35
+ /** One event through the `handle` phase, then, however that ended, the `after` phase. */
36
+ async function handle(ports, modules, event) {
37
+ try {
38
+ for (const module of modules) {
39
+ const handling = module.handle?.(ports, event);
40
+ if (handling)
41
+ await handling;
42
+ }
43
+ }
44
+ finally {
45
+ for (const module of modules) {
46
+ const after = module.after?.(ports, event);
47
+ if (after)
48
+ await after;
49
+ }
50
+ }
51
+ }
52
+ /** Handles one event, logging a failure rather than ending the watch. */
53
+ function handleLogged(ports, modules, event) {
54
+ return handle(ports, modules, event).catch((error) => {
55
+ const data = event.data;
56
+ const sessionID = data?.sessionID ?? data?.form?.sessionID;
57
+ ports.log(`courier watch: could not handle ${event.type} of ${sessionID}: ${String(error)}`);
58
+ });
59
+ }
60
+ /** Runs each module's `subscribed` alongside the new subscription, kept in `alongside` until done. */
61
+ function subscribed(ports, modules, alongside) {
62
+ for (const module of modules) {
63
+ if (!module.subscribed)
64
+ continue;
65
+ const running = module
66
+ .subscribed(ports)
67
+ .catch((error) => ports.log(`courier watch: ${String(error)}`))
68
+ .finally(() => alongside.delete(running));
69
+ alongside.add(running);
70
+ }
71
+ }
72
+ /** One subscription, its events through `modules`' phases, until its stream ends or breaks or `signal` aborts. */
73
+ async function subscription(ports, modules, signal, alongside) {
74
+ let following = false;
75
+ let events;
76
+ try {
77
+ events = ports.event.subscribe({ signal })[Symbol.asyncIterator]();
78
+ let first = modules.flatMap((module) => module.followed?.() ?? []);
79
+ following = true;
80
+ subscribed(ports, modules, alongside);
81
+ for await (const event of until(events, signal)) {
82
+ for (const run of first)
83
+ run();
84
+ first = [];
85
+ await handleLogged(ports, modules, event);
86
+ }
87
+ }
88
+ catch (error) {
89
+ if (!signal.aborted)
90
+ ports.log(`courier watch: event stream broke: ${String(error)}`);
91
+ }
92
+ finally {
93
+ if (following)
94
+ for (const module of modules)
95
+ module.left?.();
96
+ // Closed without waiting: closing the adapter's stream waits on OpenCode, which may not answer
97
+ // while a location closes, and the leave waiting for this watcher must not wait for that too.
98
+ void events?.return?.().catch(() => { });
99
+ }
100
+ }
101
+ /** Follows OpenCode's events through `ports`, subscribing again after `retryMs` when a subscription ends, until `signal` aborts. */
102
+ export async function follow(ports, modules, signal, retryMs = RESUBSCRIBE_MS) {
103
+ const alongside = new Set();
104
+ while (!signal.aborted) {
105
+ await subscription(ports, modules, signal, alongside);
106
+ if (!signal.aborted)
107
+ await pause(retryMs, signal);
108
+ }
109
+ await Promise.allSettled(alongside);
110
+ }
111
+ /**
112
+ * Makes the hub start its subscriptions to OpenCode's events with this copy's modules, the copy loaded
113
+ * last; `join` starts them. The second is a standby, connected before the first's member leaves.
114
+ */
115
+ export function watchFromHub(hub, modules, retryMs = RESUBSCRIBE_MS) {
116
+ hub.subscribe = (member) => {
117
+ const stop = new AbortController();
118
+ // A member of any copy runs them: every hub-v2 member's `watch` ports are the same shape.
119
+ return { member, stop, done: follow(member.watch, modules, stop.signal, retryMs) };
120
+ };
121
+ }
@@ -0,0 +1,195 @@
1
+ import { type Registry, type Storage } from "./storage.js";
2
+ /**
3
+ * The plugin's process-wide state on `globalThis`, shared by every loaded copy; only this module puts
4
+ * it there. `HUB_VERSION`, the fixed claim keys and version skew: docs/maintainers/architecture.md, § Several copies in one process.
5
+ */
6
+ export declare const HUB_VERSION = 2;
7
+ /** The key of the hub; a copy that finds another version there keeps its own under `${HUB_KEY}@<version>`. */
8
+ export declare const HUB_KEY = "opencode-courier.hub";
9
+ /** The ports a member's scheduler keeps the owner key through: what every member's `later` ports have. */
10
+ export interface TickPorts {
11
+ readonly storage: Pick<Storage, "get" | "set" | "remove">;
12
+ readonly now: () => number;
13
+ }
14
+ /**
15
+ * The events handled, and the permission requests a session was told about or that were answered
16
+ * first: claim sets shared by every subscription and copy, so each event is handled once.
17
+ */
18
+ export interface Watched {
19
+ readonly seen: Set<string>;
20
+ readonly waiting: Set<string>;
21
+ readonly answered: Set<string>;
22
+ }
23
+ /** The forms sessions were told about, kept apart from the permission requests. */
24
+ export interface FormsTold {
25
+ /** Forms still shown whose notice went out, or is going out: the notice's delivery. */
26
+ readonly told: Map<string, Promise<unknown>>;
27
+ /** Forms settled before anyone was told, so a notice whose roster lookup was overtaken is not sent. */
28
+ readonly settled: Set<string>;
29
+ }
30
+ /**
31
+ * A loaded plugin instance: its location's directory and permission domain, and the ports a job
32
+ * run once for the whole process would run it through.
33
+ */
34
+ export interface Member {
35
+ readonly directory: string;
36
+ /**
37
+ * The location OpenCode set the instance up with, the very object the instance's Effect half is
38
+ * handed too, which finds its member by it (`portsAt`). A member of a copy before it has none.
39
+ */
40
+ readonly location?: object;
41
+ /** The location's permission domain, kept in `locations`. */
42
+ readonly permission: unknown;
43
+ /** The ports the scheduler's tick runs through: the owner key's, and the tick jobs'. */
44
+ readonly later: TickPorts;
45
+ /** The ports the hub's subscriptions to OpenCode's events run through. */
46
+ readonly watch: unknown;
47
+ /** The question relay's ports. */
48
+ readonly questions: unknown;
49
+ readonly log: (message: string) => void;
50
+ }
51
+ /**
52
+ * A tick under way: the member it runs through, when it started, by the wall clock every member's
53
+ * `now` reads (the next ticks compare it with another member's), and its end.
54
+ */
55
+ export interface Tick {
56
+ readonly member: Member;
57
+ readonly since: number;
58
+ /** Settles once the tick has ended, the owner key released if its loop stopped meanwhile; never rejects. */
59
+ readonly done: Promise<void>;
60
+ }
61
+ /**
62
+ * The scheduler's loop, one per hub: it runs while the hub has members, and each tick runs the jobs
63
+ * registered with `open` through the ports of the member that joined first and is still loaded.
64
+ */
65
+ export interface Scheduler {
66
+ /** The loop's interval, while it runs. */
67
+ timer?: unknown;
68
+ /**
69
+ * The tick under way, whichever copy started it: for a minute the key is renewed for it and due
70
+ * ticks skip; past it, one whose member left holds nothing up. Older copies have only `ticking`.
71
+ */
72
+ running?: Tick;
73
+ /**
74
+ * The member the tick under way runs through, if any, set and cleared with `running`, for a copy
75
+ * that reads only this: a tick that falls due meanwhile is skipped, unless that member has left.
76
+ */
77
+ ticking?: Member;
78
+ /** One tick, by the copy of the plugin that joined last, so a copy loaded after an update runs its own code. */
79
+ tick?: () => Promise<void>;
80
+ /** Whether a nudge came since the tick under way started, whichever copy's, so it ticks once more when done. */
81
+ nudged?: boolean;
82
+ }
83
+ /** How the hub starts and stops the scheduler's loop, and waits before re-reading the owner key; tests pass their own. */
84
+ export interface Timers {
85
+ readonly every: (run: () => void, ms: number) => unknown;
86
+ readonly stop: (timer: unknown) => void;
87
+ readonly wait: (ms: number) => Promise<void>;
88
+ }
89
+ /** How often the hub's scheduler ticks: what a job delivers, a `courier_later` message, can be this late. */
90
+ export declare const TICK_MS = 15000;
91
+ /**
92
+ * The storage key, shared by every server on one data directory, naming the server whose scheduler
93
+ * delivers `courier_later` messages: `{ server, at }`, its id and when it last wrote (epoch ms).
94
+ */
95
+ export declare const OWNER_KEY = "scheduler/owner";
96
+ /** How long the owner key holds without being renewed; the owner renews it every tick, four times as often. */
97
+ export declare const OWNER_EXPIRY_MS: number;
98
+ /**
99
+ * How old the owner's own key may be for it to renew the key and deliver at once. An older one,
100
+ * which it may no longer hold by the time it writes, is taken again like a free one, with the wait.
101
+ */
102
+ export declare const OWNER_RENEW_MS: number;
103
+ /** The wait between writing the owner key and reading it back, at least `OWNER_WAIT_MS` and up to twice that. */
104
+ export declare const OWNER_WAIT_MS = 500;
105
+ /**
106
+ * This server's id in the owner key: the same for every copy of the plugin in the process, and
107
+ * different in every other process, a restart of this one included.
108
+ */
109
+ export declare const SERVER: string;
110
+ /**
111
+ * How long an unloading instance waits for the work it leaves behind: what it started itself, a tick
112
+ * or a notice under way through it, and on the last leave the owner key's release.
113
+ */
114
+ export declare const LEAVE_MS = 2000;
115
+ /** How many subscriptions to OpenCode's events a hub keeps, each through another member: one, and a standby. */
116
+ export declare const WATCHERS = 2;
117
+ /** One of the hub's subscriptions to OpenCode's events: the member whose ports it runs through, and how it ends. */
118
+ export interface Watcher {
119
+ readonly member: Member;
120
+ readonly stop: AbortController;
121
+ /** Settles once the subscription has ended, after `stop`: after the event it is handling, not the stream's close. */
122
+ readonly done: Promise<void>;
123
+ }
124
+ export interface Hub {
125
+ readonly version: typeof HUB_VERSION;
126
+ /** The instances loaded now, in the order they joined. */
127
+ readonly members: Set<Member>;
128
+ readonly scheduler: Scheduler;
129
+ /**
130
+ * The hub's subscriptions to OpenCode's events, each through another member: two while two members
131
+ * are loaded, so one is always connected when the other's member leaves.
132
+ */
133
+ readonly watchers: Watcher[];
134
+ /**
135
+ * Starts a subscription through a member, by the copy of the plugin that set it last; until one
136
+ * has, the hub follows no events.
137
+ */
138
+ subscribe?: (member: Member) => Watcher;
139
+ /** The ids of the `courier_later` messages being delivered. */
140
+ readonly claimed: Set<string>;
141
+ /** The events handled, and the permission requests a session was told about or that were answered first. */
142
+ readonly watched: Watched;
143
+ /** The forms sessions were told about, and those settled first. */
144
+ readonly forms: FormsTold;
145
+ /**
146
+ * The permission domain of every loaded instance, by instance. OpenCode keeps a request where its
147
+ * session runs, so a request of an isolated child is answered through the instance loaded in its worktree.
148
+ */
149
+ readonly locations: Map<object, unknown>;
150
+ /** The webhook receiver's state: the object every copy shares under the key `receiver`. */
151
+ readonly receivers: object;
152
+ /** The question relay's state: the object every copy shares under the key `questions`. */
153
+ readonly questions: object;
154
+ /** Whether the hub of another version found under `HUB_KEY` has been logged. */
155
+ skewLogged: boolean;
156
+ }
157
+ /**
158
+ * A job each tick runs, in its turn, through the first loaded member, which may be another copy's:
159
+ * it uses only the ports every hub-v2 member has. `claimed` is the hub's claim set; a throw ends the tick.
160
+ */
161
+ export type TickJob = (owner: Member, claimed: Set<string>) => Promise<void>;
162
+ /** A hub, as `open` finds or makes it, and how instances join it. */
163
+ export interface Opened {
164
+ readonly hub: Hub;
165
+ /**
166
+ * Adds a loaded instance to the hub and `locations`; returns its leave. The first join starts the
167
+ * scheduler and watchers, the last leave stops them; a leave waits `LEAVE_MS` for the member's work.
168
+ */
169
+ readonly join: (member: Member) => () => Promise<void>;
170
+ /** Hands the hub work a member started, which its leave waits for; the member's own background work. */
171
+ readonly track: (member: Member, work: Promise<unknown>) => void;
172
+ /**
173
+ * Runs a tick now, or once the tick under way has ended, so what has just fallen due (a released
174
+ * group) is delivered at once rather than at the next tick; nothing while no instance is loaded.
175
+ */
176
+ readonly nudge: () => void;
177
+ }
178
+ /**
179
+ * The value under `opencode-courier.<name>` every copy in the process shares, made by `create` the first
180
+ * time; another version, or a copy before the hub, may have made it, so its shape is kept for good.
181
+ */
182
+ export declare function sharedState<T>(name: string, create: () => T, registry?: Registry): T;
183
+ /**
184
+ * This copy's hub in `registry`, and how its instances join it. Its ticks run `jobs` in order, kept in
185
+ * this copy's tick, which the hub runs once this copy has joined last, never in the hub itself.
186
+ */
187
+ export declare function open(registry: Registry, jobs: readonly TickJob[], timers?: Timers, server?: string): Opened;
188
+ /** This copy's hub, in the process. */
189
+ export declare const hub: Hub;
190
+ /** Opens this copy's hub in the process, with its tick jobs; see `open`. */
191
+ export declare const openInProcess: (jobs: readonly TickJob[]) => Opened;
192
+ /** The member of this copy's hub set up with `location` that joined last, while it is loaded. */
193
+ export declare function memberAt(location: object): Member | undefined;
194
+ /** The permission domains of the loaded instances. */
195
+ export declare const permissions: () => MapIterator<unknown>;
@@ -1,33 +1,12 @@
1
1
  import { randomInt } from "node:crypto";
2
2
  import { hostname } from "node:os";
3
3
  import { num, obj, str } from "./json.js";
4
- import { deliverDue, TICK_MS } from "./later.js";
5
4
  import { processWide } from "./storage.js";
6
5
  /**
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.
6
+ * The plugin's process-wide state on `globalThis`, shared by every loaded copy; only this module puts
7
+ * it there. `HUB_VERSION`, the fixed claim keys and version skew: docs/maintainers/architecture.md, § Several copies in one process.
29
8
  */
30
- export const HUB_VERSION = 1;
9
+ export const HUB_VERSION = 2;
31
10
  /** The key of the hub; a copy that finds another version there keeps its own under `${HUB_KEY}@<version>`. */
32
11
  export const HUB_KEY = "opencode-courier.hub";
33
12
  const realTimers = {
@@ -35,11 +14,11 @@ const realTimers = {
35
14
  stop: (timer) => clearInterval(timer),
36
15
  wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
37
16
  };
17
+ /** How often the hub's scheduler ticks: what a job delivers, a `courier_later` message, can be this late. */
18
+ export const TICK_MS = 15_000;
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,27 +51,18 @@ 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
+ * The value under `opencode-courier.<name>` every copy in the process shares, made by `create` the first
55
+ * time; another version, or a copy before the hub, may have made it, so its shape is kept for good.
79
56
  */
80
- export function open(registry, timers = realTimers, server = SERVER) {
81
- const shared = (name, create) => processWide(`opencode-courier.${name}`, create, registry);
82
- const questionState = () => {
83
- // Field by field, since a copy before the hub may have made it without the fields added later.
84
- const state = shared("questions", () => ({}));
85
- state.questions ??= new Map();
86
- state.noticed ??= new Set();
87
- state.shown ??= new Map();
88
- state.passing ??= new Map();
89
- state.answered ??= new Set();
90
- state.loaded ??= new Set();
91
- state.following ??= 0;
92
- state.followed ??= false;
93
- state.shutdowns ??= new Map();
94
- state.closingWaiters ??= new Set();
95
- return state;
96
- };
57
+ export function sharedState(name, create, registry = globalThis) {
58
+ return processWide(`opencode-courier.${name}`, create, registry);
59
+ }
60
+ /**
61
+ * Finds or makes this copy's hub in `registry`: the one under `HUB_KEY`, or its own under a versioned
62
+ * key when another version holds that; its claim sets are the pre-hub keys', made when missing.
63
+ */
64
+ function find(registry) {
65
+ const shared = (name, create) => sharedState(name, create, registry);
97
66
  const create = () => ({
98
67
  version: HUB_VERSION,
99
68
  members: new Set(),
@@ -104,17 +73,24 @@ export function open(registry, timers = realTimers, server = SERVER) {
104
73
  forms: shared("forms", () => ({ told: new Map(), settled: new Set() })),
105
74
  locations: shared("locations", () => new Map()),
106
75
  receivers: shared("receiver", () => ({})),
107
- questions: questionState(),
76
+ // Filled in by `person/question/`, field by field, since a copy before the hub may have made it without them.
77
+ questions: shared("questions", () => ({})),
108
78
  skewLogged: false,
109
79
  });
110
80
  const found = registry[Symbol.for(HUB_KEY)];
111
81
  // The version of the hub of another copy found under `HUB_KEY`, if one is there.
112
82
  const skew = found != null && found.version !== HUB_VERSION ? { version: found.version } : undefined;
113
- const hub = processWide(skew ? `${HUB_KEY}@${HUB_VERSION}` : HUB_KEY, create, registry);
83
+ return { hub: processWide(skew ? `${HUB_KEY}@${HUB_VERSION}` : HUB_KEY, create, registry), skew };
84
+ }
85
+ /**
86
+ * This copy's hub in `registry`, and how its instances join it. Its ticks run `jobs` in order, kept in
87
+ * this copy's tick, which the hub runs once this copy has joined last, never in the hub itself.
88
+ */
89
+ export function open(registry, jobs, timers = realTimers, server = SERVER) {
90
+ const { hub, skew } = find(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))
206
- await deliverDue(owner.later, hub.claimed);
182
+ if ((await own(owner.later)) && scheduler.timer !== undefined && hub.members.has(owner)) {
183
+ for (const job of jobs)
184
+ await job(owner, 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,15 +276,17 @@ 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
+ nudge,
284
+ };
290
285
  }
291
- const opened = open(globalThis);
292
286
  /** This copy's hub, in the process. */
293
- export const hub = opened.hub;
294
- /** Joins this copy's hub; see `Opened.join`. */
295
- export const join = opened.join;
296
- /** Hands this copy's hub work a member started; see `Opened.track`. */
297
- export const track = opened.track;
287
+ export const hub = find(globalThis).hub;
288
+ /** Opens this copy's hub in the process, with its tick jobs; see `open`. */
289
+ export const openInProcess = (jobs) => open(globalThis, jobs);
298
290
  /** The member of this copy's hub set up with `location` that joined last, while it is loaded. */
299
291
  export function memberAt(location) {
300
292
  let found;
@@ -303,38 +295,5 @@ export function memberAt(location) {
303
295
  found = member;
304
296
  return found;
305
297
  }
306
- /**
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
- */
311
- export function portsAt(location) {
312
- const own = memberAt(location);
313
- return () => (own && hub.members.has(own) ? own.questions : undefined);
314
- }
315
298
  /** The permission domains of the loaded instances. */
316
299
  export const permissions = () => hub.locations.values();
317
- /**
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.
321
- */
322
- export function resetHub() {
323
- hub.claimed.clear();
324
- hub.watched.seen.clear();
325
- hub.watched.waiting.clear();
326
- hub.watched.answered.clear();
327
- hub.forms.told.clear();
328
- hub.forms.settled.clear();
329
- const questions = hub.questions;
330
- questions.questions.clear();
331
- questions.noticed.clear();
332
- questions.shown.clear();
333
- questions.passing.clear();
334
- questions.answered.clear();
335
- questions.loaded.clear();
336
- questions.following = 0;
337
- questions.followed = false;
338
- questions.shutdowns.clear();
339
- questions.closingWaiters.clear();
340
- }