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