opencode-courier 0.2.2 → 0.2.3
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 +130 -95
- package/dist/bounded.d.ts +10 -0
- package/dist/bounded.js +21 -0
- package/dist/cleanup.d.ts +13 -18
- package/dist/cleanup.js +38 -19
- package/dist/courier.d.ts +1 -5
- package/dist/courier.js +1 -20
- package/dist/hub.d.ts +325 -0
- package/dist/hub.js +340 -0
- package/dist/index.d.ts +4 -8
- package/dist/index.js +70 -308
- package/dist/json.d.ts +4 -0
- package/dist/json.js +4 -0
- package/dist/later.d.ts +1 -1
- package/dist/later.js +2 -2
- package/dist/notices.d.ts +246 -0
- package/dist/notices.js +399 -0
- package/dist/question/answer.d.ts +35 -0
- package/dist/question/answer.js +217 -0
- package/dist/question/index.d.ts +6 -0
- package/dist/question/index.js +7 -0
- package/dist/question/lifecycle.d.ts +52 -0
- package/dist/question/lifecycle.js +100 -0
- package/dist/question/pure.d.ts +49 -0
- package/dist/question/pure.js +75 -0
- package/dist/question/relay.d.ts +5 -0
- package/dist/question/relay.js +283 -0
- package/dist/question/runtime.d.ts +21 -0
- package/dist/question/runtime.js +75 -0
- package/dist/question/shared.d.ts +24 -0
- package/dist/question/shared.js +24 -0
- package/dist/relay.d.ts +31 -33
- package/dist/relay.js +5 -53
- package/dist/roster.d.ts +29 -1
- package/dist/roster.js +101 -9
- package/dist/storage.d.ts +8 -1
- package/dist/storage.js +8 -5
- package/dist/tools.d.ts +74 -0
- package/dist/tools.js +228 -0
- package/dist/watch.d.ts +28 -37
- package/dist/watch.js +148 -38
- package/dist/webhook.d.ts +1 -13
- package/dist/webhook.js +36 -127
- package/package.json +16 -5
- package/dist/question.d.ts +0 -158
- package/dist/question.js +0 -768
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { Deferred, Effect, Exit, Fiber } from "effect";
|
|
2
|
+
import { answeredText, DISMISSED, passedNote, questionNotice, questionSettledNotice, unlinkedNote, } from "../notices.js";
|
|
3
|
+
import { lineage } from "../roster.js";
|
|
4
|
+
import { deliver, forget, tell, tellCutOff } from "./answer.js";
|
|
5
|
+
import { closingSoon } from "./lifecycle.js";
|
|
6
|
+
import { choices, isDismissal, normalize, promptsOf, settledIn, withdrawnResult, wording, } from "./pure.js";
|
|
7
|
+
import { background, run, within } from "./runtime.js";
|
|
8
|
+
import { anyLoaded, isPassing, keyOf, QUESTION_TOOL, shared, storedOf } from "./shared.js";
|
|
9
|
+
/**
|
|
10
|
+
* Tells the top session about a question its closing location cut off, after the closing grace of
|
|
11
|
+
* `ports` (the instance whose call was cut off), through an instance still loaded then, if any (any will do: a notice reaches a session in any location).
|
|
12
|
+
* Without one, the next load tells it.
|
|
13
|
+
*/
|
|
14
|
+
function tellLater(ports, question) {
|
|
15
|
+
const tellNow = Effect.suspend(() => {
|
|
16
|
+
const later = anyLoaded();
|
|
17
|
+
if (!later || question.call || question.link || shared.questions.get(question.requestID) !== question)
|
|
18
|
+
return Effect.void;
|
|
19
|
+
return Effect.promise(() => tellCutOff(later, question, "restarted").catch((error) => later.log(`courier question: could not tell ${question.top} about ${question.requestID}: ${String(error)}`)));
|
|
20
|
+
});
|
|
21
|
+
void background(Effect.andThen(Effect.sleep(ports.timing.closingGraceMs), tellNow), (error) => ports.log(`courier question: could not tell ${question.top} about ${question.requestID}: ${String(error)}`));
|
|
22
|
+
}
|
|
23
|
+
/** Registers a question, stores it and tells the top session; false when storing or telling failed. */
|
|
24
|
+
async function relay(ports, question) {
|
|
25
|
+
shared.questions.set(question.requestID, question);
|
|
26
|
+
try {
|
|
27
|
+
await ports.storage.set(keyOf(question.requestID), storedOf(question));
|
|
28
|
+
await tell(ports, question, questionNotice(question), { asks: "question", request: question.requestID });
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
// The question stays registered and the call waits on: courier_status lists it, and an answer
|
|
33
|
+
// that comes anyway, through a link or courier_answer, still reaches the call.
|
|
34
|
+
ports.log(`courier question: could not tell ${question.top} about ${question.requestID}: ${String(error)}`);
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* After a relayed call ended: tidies up, and tells the top session if it was settled without it.
|
|
40
|
+
* `loaded` says whether the plugin instance that ran the call is still loaded.
|
|
41
|
+
*/
|
|
42
|
+
const settle = (ports, question, exit, loaded) => Effect.gen(function* () {
|
|
43
|
+
const unloaded = !loaded();
|
|
44
|
+
// A dismissal is held from now, alongside the wait below, so the grace is not spent waiting.
|
|
45
|
+
const dismissed = Exit.isFailure(exit) && isDismissal(exit.cause);
|
|
46
|
+
const held = dismissed && !unloaded ? yield* Effect.forkChild(closingSoon(ports, loaded, question.directory), { startImmediately: true }) : undefined;
|
|
47
|
+
// Telling the top session first, so what follows does not overtake the notice or come before it
|
|
48
|
+
// is stored; for a while only, so a notice that never returns does not hold everything else up.
|
|
49
|
+
// `told` is false when the top session was not told, or not yet.
|
|
50
|
+
const told = (yield* within(question.relaying, ports.timing.relayWaitMs)) === true;
|
|
51
|
+
// Answered by the top session.
|
|
52
|
+
if (Exit.isSuccess(exit) && exit.value.by === "top") {
|
|
53
|
+
if (shared.questions.get(question.requestID) === question)
|
|
54
|
+
shared.questions.delete(question.requestID);
|
|
55
|
+
return yield* Effect.promise(() => forget(ports, question));
|
|
56
|
+
}
|
|
57
|
+
// Never relayed, or dropped meanwhile.
|
|
58
|
+
if (shared.questions.get(question.requestID) !== question) {
|
|
59
|
+
return yield* Effect.promise(() => forget(ports, question));
|
|
60
|
+
}
|
|
61
|
+
// Cut off: the turn was stopped, or OpenCode is closing the location, which withdraws every open
|
|
62
|
+
// form there as if the person had dismissed it (on a server shutdown, say), before unloading the
|
|
63
|
+
// plugin there (since 2.0.22) or after (the beta). So a dismissal is held for a moment, to see
|
|
64
|
+
// whether the location's shutdown or this instance's unload follows it. The question stays,
|
|
65
|
+
// answered by message from now on; a top session already asking the person passes their answer
|
|
66
|
+
// on that way. A closing location is not told now: the next load tells it, in this process or
|
|
67
|
+
// after a restart, or an instance still loaded does, a little later, when the process is still
|
|
68
|
+
// running then. The unload is looked at again here: it may have come during the wait above, after
|
|
69
|
+
// a held dismissal had already been taken for the person's.
|
|
70
|
+
const closing = unloaded || !loaded() || (held !== undefined && (yield* Fiber.join(held)));
|
|
71
|
+
if (Exit.isFailure(exit) && (closing || (!dismissed && Exit.hasInterrupts(exit))))
|
|
72
|
+
return closing ? tellLater(ports, question) : yield* Effect.promise(() => stopped(ports, question));
|
|
73
|
+
yield* Effect.promise(() => settledElsewhere(ports, question, settledIn(exit), told));
|
|
74
|
+
});
|
|
75
|
+
/** A relayed call cut off by its stopped turn: the top session is told now, unless it asks the person. */
|
|
76
|
+
async function stopped(ports, question) {
|
|
77
|
+
if (!question.link)
|
|
78
|
+
await tellCutOff(ports, question, "stopped");
|
|
79
|
+
}
|
|
80
|
+
/** A relayed call settled in the asking session: `told` says whether the top session was told about it. */
|
|
81
|
+
async function settledElsewhere(ports, question, how, told) {
|
|
82
|
+
// Its record goes before its registration, so an answer that finds the question gone does not
|
|
83
|
+
// find the record still there and send it on.
|
|
84
|
+
await forget(ports, question);
|
|
85
|
+
shared.questions.delete(question.requestID);
|
|
86
|
+
if (question.link)
|
|
87
|
+
return question.link(how);
|
|
88
|
+
// A top session never told about the question is not told that it is settled either.
|
|
89
|
+
if (!told)
|
|
90
|
+
return;
|
|
91
|
+
await tell(ports, question, questionSettledNotice(question, how), {
|
|
92
|
+
answered: how.by === "child" ? "elsewhere" : how.by,
|
|
93
|
+
request: question.requestID,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Starts settling a relayed call that ended, kept on the question until it is through: an answer
|
|
98
|
+
* the call did not take waits for it.
|
|
99
|
+
*/
|
|
100
|
+
function startSettling(ports, question, exit, loaded) {
|
|
101
|
+
question.settling = background(settle(ports, question, exit, loaded), (error) => ports.log(`courier question: could not settle ${question.requestID}: ${String(error)}`)).finally(() => {
|
|
102
|
+
question.settling = undefined;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The question call of a session started with courier_spawn: once its form is shown, the session at
|
|
107
|
+
* the top is told, and whichever answer comes first ends the call, the other side's question being
|
|
108
|
+
* withdrawn. Other sessions' calls run unchanged.
|
|
109
|
+
*/
|
|
110
|
+
function asking(ports, directory, original) {
|
|
111
|
+
return (input, context) => Effect.gen(function* () {
|
|
112
|
+
const current = ports();
|
|
113
|
+
const chain = current ? yield* Effect.promise(() => lineage(current.storage, context.sessionID).catch(() => [])) : [];
|
|
114
|
+
if (!current || !chain.length)
|
|
115
|
+
return yield* original(input, context);
|
|
116
|
+
const byTop = Deferred.makeUnsafe();
|
|
117
|
+
const accepted = Deferred.makeUnsafe();
|
|
118
|
+
// For a copy of the plugin that answers through `call`, which takes promises.
|
|
119
|
+
let acceptancePromise;
|
|
120
|
+
const acceptance = () => (acceptancePromise ??= Effect.runPromise(Deferred.await(accepted)));
|
|
121
|
+
// Handed one outcome only: a second, while the first still ends the call, is not taken.
|
|
122
|
+
let handed = false;
|
|
123
|
+
const question = {
|
|
124
|
+
requestID: current.newID(),
|
|
125
|
+
sessionID: context.sessionID,
|
|
126
|
+
top: chain.at(-1).parentID,
|
|
127
|
+
title: chain[0].title,
|
|
128
|
+
...(chain.length > 1 ? { startedBy: chain[0].parentID } : {}),
|
|
129
|
+
questions: promptsOf(input),
|
|
130
|
+
askedAt: current.now(),
|
|
131
|
+
directory,
|
|
132
|
+
call: (outcome) => {
|
|
133
|
+
if (handed)
|
|
134
|
+
return acceptance().then(() => false);
|
|
135
|
+
handed = true;
|
|
136
|
+
Deferred.doneUnsafe(byTop, Exit.succeed(outcome));
|
|
137
|
+
return acceptance();
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
// The form is shown only once OpenCode's permission check for the call has passed; a call
|
|
141
|
+
// refused there never reaches the top session.
|
|
142
|
+
const key = `${context.sessionID} ${context.id}`;
|
|
143
|
+
const loaded = () => ports() === current;
|
|
144
|
+
const shown = Deferred.makeUnsafe();
|
|
145
|
+
// Called as a plain function, by this copy's watcher or another copy's.
|
|
146
|
+
shared.shown.set(key, () => Deferred.doneUnsafe(shown, Exit.void));
|
|
147
|
+
const fromTop = Effect.gen(function* () {
|
|
148
|
+
yield* Deferred.await(shown);
|
|
149
|
+
// Started, not waited for: a store or a notice that hangs must not keep the top session's
|
|
150
|
+
// answer from the call. Kept on the question, so settle can wait for it.
|
|
151
|
+
question.relaying = relay(current, question);
|
|
152
|
+
return { by: "top", outcome: yield* Deferred.await(byTop) };
|
|
153
|
+
});
|
|
154
|
+
const ended = yield* Effect.raceFirst(original(input, context).pipe(Effect.map((result) => ({ by: "child", result }))), fromTop).pipe(
|
|
155
|
+
// Not waited for, so storing or a notice that hangs cannot hold the call up.
|
|
156
|
+
Effect.onExit((exit) => Effect.sync(() => {
|
|
157
|
+
shared.shown.delete(key);
|
|
158
|
+
// The call no longer waits, whatever settle makes of it: an answer from now on waits for settle.
|
|
159
|
+
const byTopSession = Exit.isSuccess(exit) && exit.value.by === "top";
|
|
160
|
+
if (!byTopSession)
|
|
161
|
+
question.call = undefined;
|
|
162
|
+
startSettling(current, question, exit, loaded);
|
|
163
|
+
// After settling is set, which an answer the call did not take then waits for.
|
|
164
|
+
Deferred.doneUnsafe(accepted, Exit.succeed(byTopSession));
|
|
165
|
+
})));
|
|
166
|
+
if (ended.by === "child")
|
|
167
|
+
return ended.result;
|
|
168
|
+
const outcome = ended.outcome;
|
|
169
|
+
if ("dismissed" in outcome)
|
|
170
|
+
return {
|
|
171
|
+
output: { answers: question.questions.map(() => []) },
|
|
172
|
+
content: DISMISSED,
|
|
173
|
+
metadata: { answers: question.questions.map(() => []), relayed: question.requestID, dismissed: true },
|
|
174
|
+
};
|
|
175
|
+
return {
|
|
176
|
+
output: { answers: outcome.answers },
|
|
177
|
+
content: answeredText(question.questions, outcome.answers),
|
|
178
|
+
metadata: { answers: outcome.answers, relayed: question.requestID, answeredIn: question.top },
|
|
179
|
+
};
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* The waiting question a top session's call asks again, the oldest if several: the same questions
|
|
184
|
+
* with the same choices, in any order. Matching choices alone are not enough, or an unrelated
|
|
185
|
+
* yes-or-no question of the top session would answer a child's.
|
|
186
|
+
*/
|
|
187
|
+
function linkFor(sessionID, questions) {
|
|
188
|
+
const asked = { choices: choices(questions), wording: wording(questions) };
|
|
189
|
+
return [...shared.questions.values()]
|
|
190
|
+
.filter((question) => question.top === sessionID &&
|
|
191
|
+
!question.link &&
|
|
192
|
+
!isPassing(question.requestID) &&
|
|
193
|
+
choices(question.questions) === asked.choices &&
|
|
194
|
+
wording(question.questions) === asked.wording)
|
|
195
|
+
.sort((a, b) => a.askedAt - b.askedAt)[0];
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* The result of a top session's question call that no waiting question was linked to: when one
|
|
199
|
+
* waits with the same choices, reworded or asked before that session asked, it says how to pass
|
|
200
|
+
* the answers on.
|
|
201
|
+
*/
|
|
202
|
+
function unlinkedResult(result, sessionID, questions) {
|
|
203
|
+
if (typeof result.content !== "string")
|
|
204
|
+
return result;
|
|
205
|
+
const asked = choices(questions);
|
|
206
|
+
const similar = [...shared.questions.values()].filter((question) => question.top === sessionID && !question.link && !isPassing(question.requestID) && choices(question.questions) === asked);
|
|
207
|
+
if (!similar.length)
|
|
208
|
+
return result;
|
|
209
|
+
return { ...result, content: `${result.content}\n${unlinkedNote(similar)}` };
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* After a linked call failed, its location still loaded: a dismissal by the person, unless the
|
|
213
|
+
* location is closing (see settle), dismisses the question they were asked for too, or is logged
|
|
214
|
+
* when the question was answered meanwhile; a call stopped while the asking call was cut off as
|
|
215
|
+
* well tells the top session, which nobody has told yet.
|
|
216
|
+
*/
|
|
217
|
+
const linkedCallFailed = (ports, linked, cause, loaded, directory) => Effect.gen(function* () {
|
|
218
|
+
if (isDismissal(cause)) {
|
|
219
|
+
if (yield* closingSoon(ports, loaded, directory))
|
|
220
|
+
return;
|
|
221
|
+
if ((yield* deliver(ports, linked, { dismissed: true })) === undefined)
|
|
222
|
+
ports.log(`courier question: the dismissal of ${linked.requestID} was not passed on: it had already been answered or settled`);
|
|
223
|
+
}
|
|
224
|
+
else if (!linked.call && shared.questions.get(linked.requestID) === linked)
|
|
225
|
+
yield* Effect.promise(() => tellCutOff(ports, linked, "stopped"));
|
|
226
|
+
});
|
|
227
|
+
/** Logs that what happened to a linked call could not be passed on. */
|
|
228
|
+
const notPassedOn = (ports, linked) => (error) => ports.log(`courier question: could not pass on what happened to ${linked.requestID}: ${String(error)}`);
|
|
229
|
+
/**
|
|
230
|
+
* The question call of a session that a waiting question was relayed to, asking the person the
|
|
231
|
+
* same: what they choose is passed on, and the call is withdrawn if the question is settled first.
|
|
232
|
+
*/
|
|
233
|
+
function linking(ports, directory, ask) {
|
|
234
|
+
return (input, context) => Effect.gen(function* () {
|
|
235
|
+
const current = ports();
|
|
236
|
+
const questions = promptsOf(input);
|
|
237
|
+
const linked = current ? linkFor(context.sessionID, questions) : undefined;
|
|
238
|
+
if (!current || !linked) {
|
|
239
|
+
const result = yield* ask(input, context);
|
|
240
|
+
return current ? unlinkedResult(result, context.sessionID, questions) : result;
|
|
241
|
+
}
|
|
242
|
+
const loaded = () => ports() === current;
|
|
243
|
+
const elsewhere = Deferred.makeUnsafe();
|
|
244
|
+
// Called as a plain function, by this copy or another one.
|
|
245
|
+
const settled = (how) => void Deferred.doneUnsafe(elsewhere, Exit.succeed(how));
|
|
246
|
+
linked.link = settled;
|
|
247
|
+
const ended = yield* Effect.raceFirst(ask(input, context).pipe(Effect.map((result) => ({ by: "person", result }))), Deferred.await(elsewhere).pipe(Effect.map((how) => ({ by: "elsewhere", how })))).pipe(Effect.onExit((exit) => Effect.sync(() => {
|
|
248
|
+
if (linked.link === settled)
|
|
249
|
+
linked.link = undefined;
|
|
250
|
+
// Unloaded: OpenCode is closing the location, which withdraws the form; nobody dismissed it.
|
|
251
|
+
if (Exit.isSuccess(exit) || !loaded())
|
|
252
|
+
return;
|
|
253
|
+
// Not waited for, as in asking.
|
|
254
|
+
void background(linkedCallFailed(current, linked, exit.cause, loaded, directory), notPassedOn(current, linked));
|
|
255
|
+
})));
|
|
256
|
+
if (ended.by === "elsewhere")
|
|
257
|
+
return withdrawnResult(linked, ended.how);
|
|
258
|
+
const result = ended.result;
|
|
259
|
+
const answers = result.output?.answers ?? [];
|
|
260
|
+
const passed = yield* Effect.promise(() => Promise.resolve()
|
|
261
|
+
.then(() => run(deliver(current, linked, { answers: normalize(linked, answers) })))
|
|
262
|
+
.catch((error) => {
|
|
263
|
+
current.log(`courier question: could not pass on the answer to ${linked.requestID}: ${String(error)}`);
|
|
264
|
+
return `error: ${error instanceof Error ? error.message : String(error)}`;
|
|
265
|
+
}));
|
|
266
|
+
const note = passedNote(linked, passed);
|
|
267
|
+
return {
|
|
268
|
+
...result,
|
|
269
|
+
content: typeof result.content === "string" ? `${result.content}\n${note}` : note,
|
|
270
|
+
metadata: { ...result.metadata, relayed: linked.requestID, passed: passed === "result" || passed === "message" },
|
|
271
|
+
};
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
/** Wraps OpenCode's question tool for the relay. A tool by that id that is missing is left alone. */
|
|
275
|
+
export function relayQuestions(host, ports) {
|
|
276
|
+
// The wrapped tool runs the calls of the host's location: its shutdown is what withdraws their forms.
|
|
277
|
+
const { directory } = host.location;
|
|
278
|
+
return host.tool.transform((editor) => {
|
|
279
|
+
editor.update(QUESTION_TOOL, (tool) => {
|
|
280
|
+
tool.execute = linking(ports, directory, asking(ports, directory, tool.execute));
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Clock, Effect } from "effect";
|
|
2
|
+
/**
|
|
3
|
+
* Runs an effect of the relay in a fiber of its own, by the relay's clock, which is the clock of
|
|
4
|
+
* its ports (`ports.now`): the promise settles as the effect ends, and is rejected if it fails or
|
|
5
|
+
* is interrupted. Interrupting what waits on the promise does not stop the fiber.
|
|
6
|
+
*/
|
|
7
|
+
export declare function run<A, E>(effect: Effect.Effect<A, E>): Promise<A>;
|
|
8
|
+
/**
|
|
9
|
+
* Runs an effect of the relay in the background, as `run` does; the promise resolves once it ended,
|
|
10
|
+
* however it ended. A failure is logged with `failed`; an interruption, by a test ending, is not.
|
|
11
|
+
*/
|
|
12
|
+
export declare function background(effect: Effect.Effect<unknown, unknown>, failed: (error: unknown) => void): Promise<void>;
|
|
13
|
+
/** The promise's value, or undefined once `ms` have passed or it failed. */
|
|
14
|
+
export declare const within: <T>(promise: Promise<T> | undefined, ms: number) => Effect.Effect<T | undefined>;
|
|
15
|
+
/**
|
|
16
|
+
* For tests: the clock the relay runs by from now on, a `TestClock` whose time `ports.now` reads,
|
|
17
|
+
* or the system clock again with none.
|
|
18
|
+
*/
|
|
19
|
+
export declare function useClock(next?: Clock.Clock): void;
|
|
20
|
+
/** For tests: interrupts every fiber the relay started, and goes back to the system clock. */
|
|
21
|
+
export declare function stopRelay(): Promise<void>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Cause, Clock, Duration, Effect, Exit, Fiber, Option, Scope } from "effect";
|
|
2
|
+
// What the relay's own fibers run with: this copy's state, not the hub's. A copy of the plugin
|
|
3
|
+
// loaded next to this one has its own, and they meet only in the shared fields of the hub.
|
|
4
|
+
const system = Effect.runSync(Clock.clockWith(Effect.succeed));
|
|
5
|
+
/** Largest delay a timer takes; a longer one would fire at once, so it is slept in parts. */
|
|
6
|
+
const MAX_TIMER_MS = 2_147_483_647;
|
|
7
|
+
/**
|
|
8
|
+
* The system clock, whose sleeps do not keep the process running: OpenCode exits without waiting
|
|
9
|
+
* for a dismissal held, a notice told later or an answer waiting its turn.
|
|
10
|
+
*/
|
|
11
|
+
const live = {
|
|
12
|
+
currentTimeMillisUnsafe: () => system.currentTimeMillisUnsafe(),
|
|
13
|
+
currentTimeMillis: system.currentTimeMillis,
|
|
14
|
+
currentTimeNanosUnsafe: () => system.currentTimeNanosUnsafe(),
|
|
15
|
+
currentTimeNanos: system.currentTimeNanos,
|
|
16
|
+
monotonicTimeNanosUnsafe: () => system.monotonicTimeNanosUnsafe(),
|
|
17
|
+
monotonicTimeNanos: system.monotonicTimeNanos,
|
|
18
|
+
sleep: (duration) => sleep(Duration.toMillis(duration)),
|
|
19
|
+
};
|
|
20
|
+
function sleep(ms) {
|
|
21
|
+
if (ms <= 0)
|
|
22
|
+
return Effect.yieldNow;
|
|
23
|
+
if (!Number.isFinite(ms))
|
|
24
|
+
return Effect.never;
|
|
25
|
+
const rest = ms > MAX_TIMER_MS ? Effect.suspend(() => sleep(ms - MAX_TIMER_MS)) : Effect.void;
|
|
26
|
+
return Effect.callback((resume) => {
|
|
27
|
+
const timer = setTimeout(() => resume(rest), Math.min(ms, MAX_TIMER_MS));
|
|
28
|
+
timer.unref?.();
|
|
29
|
+
return Effect.sync(() => clearTimeout(timer));
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
let clock = live;
|
|
33
|
+
/** The fibers the relay starts, which run as long as the process unless a test ends them. */
|
|
34
|
+
let scope = Scope.makeUnsafe();
|
|
35
|
+
/**
|
|
36
|
+
* Starts an effect of the relay in a fiber of its own, by the relay's clock, in the relay's scope:
|
|
37
|
+
* at once, up to its first wait, as an async function would.
|
|
38
|
+
*/
|
|
39
|
+
const start = (effect) => Effect.runFork(Effect.provideService(effect, Clock.Clock, clock)).pipe(Fiber.runIn(scope));
|
|
40
|
+
/**
|
|
41
|
+
* Runs an effect of the relay in a fiber of its own, by the relay's clock, which is the clock of
|
|
42
|
+
* its ports (`ports.now`): the promise settles as the effect ends, and is rejected if it fails or
|
|
43
|
+
* is interrupted. Interrupting what waits on the promise does not stop the fiber.
|
|
44
|
+
*/
|
|
45
|
+
export function run(effect) {
|
|
46
|
+
return Effect.runPromise(Fiber.join(start(effect)));
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Runs an effect of the relay in the background, as `run` does; the promise resolves once it ended,
|
|
50
|
+
* however it ended. A failure is logged with `failed`; an interruption, by a test ending, is not.
|
|
51
|
+
*/
|
|
52
|
+
export function background(effect, failed) {
|
|
53
|
+
return Effect.runPromise(Fiber.await(start(effect))).then((exit) => {
|
|
54
|
+
if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause))
|
|
55
|
+
failed(Cause.squash(exit.cause));
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/** The promise's value, or undefined once `ms` have passed or it failed. */
|
|
59
|
+
export const within = (promise, ms) => promise
|
|
60
|
+
? Effect.promise(() => promise.then((value) => value, () => undefined)).pipe(Effect.timeoutOption(ms), Effect.map(Option.getOrUndefined))
|
|
61
|
+
: Effect.succeed(undefined);
|
|
62
|
+
/**
|
|
63
|
+
* For tests: the clock the relay runs by from now on, a `TestClock` whose time `ports.now` reads,
|
|
64
|
+
* or the system clock again with none.
|
|
65
|
+
*/
|
|
66
|
+
export function useClock(next) {
|
|
67
|
+
clock = next ?? live;
|
|
68
|
+
}
|
|
69
|
+
/** For tests: interrupts every fiber the relay started, and goes back to the system clock. */
|
|
70
|
+
export async function stopRelay() {
|
|
71
|
+
const ended = scope;
|
|
72
|
+
scope = Scope.makeUnsafe();
|
|
73
|
+
clock = live;
|
|
74
|
+
await Effect.runPromise(Scope.close(ended, Exit.void));
|
|
75
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type Question, type QuestionPorts } from "../hub.js";
|
|
2
|
+
import type { Asked } from "../notices.js";
|
|
3
|
+
/** OpenCode's own question tool, which the relay wraps. */
|
|
4
|
+
export declare const QUESTION_TOOL = "question";
|
|
5
|
+
export declare const PREFIX = "question/";
|
|
6
|
+
/** At most this many questions are kept in storage; the oldest go first. */
|
|
7
|
+
export declare const MAX_STORED = 100;
|
|
8
|
+
/** A question as stored; `answered` marks one whose answer went out but that could not be dropped. */
|
|
9
|
+
export type Stored = Asked & {
|
|
10
|
+
readonly answered?: true;
|
|
11
|
+
};
|
|
12
|
+
export declare const storedOf: ({ directory: _directory, call: _call, link: _link, relaying: _relaying, settling: _settling, ...asked }: Question) => Asked;
|
|
13
|
+
export declare const isPassing: (requestID: string) => boolean;
|
|
14
|
+
export declare const shared: import("../hub.js").QuestionState;
|
|
15
|
+
/**
|
|
16
|
+
* The ports of an instance still loaded, if any: a member of this copy's hub, or else one that a
|
|
17
|
+
* copy of another hub version, or a release before the hub, keeps in `loaded`.
|
|
18
|
+
*/
|
|
19
|
+
export declare const anyLoaded: () => QuestionPorts | undefined;
|
|
20
|
+
export declare const keyOf: (requestID: string) => string;
|
|
21
|
+
/** Whether a request id is a question's rather than a permission request's. */
|
|
22
|
+
export declare const isQuestion: (requestID: string) => boolean;
|
|
23
|
+
/** Adds to one of the shared bounded sets. */
|
|
24
|
+
export declare const claim: (set: Set<string>, value: string) => boolean;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { addBounded } from "../bounded.js";
|
|
2
|
+
import { hub } from "../hub.js";
|
|
3
|
+
/** OpenCode's own question tool, which the relay wraps. */
|
|
4
|
+
export const QUESTION_TOOL = "question";
|
|
5
|
+
export const PREFIX = "question/";
|
|
6
|
+
/** At most this many questions are kept in storage; the oldest go first. */
|
|
7
|
+
export const MAX_STORED = 100;
|
|
8
|
+
export const storedOf = ({ directory: _directory, call: _call, link: _link, relaying: _relaying, settling: _settling, ...asked }) => asked;
|
|
9
|
+
export const isPassing = (requestID) => shared.passing.has(requestID) || shared.answered.has(requestID);
|
|
10
|
+
// Shared by every plugin instance in the process, since a child and the session it asks can be in
|
|
11
|
+
// different locations.
|
|
12
|
+
export const shared = hub.questions;
|
|
13
|
+
/**
|
|
14
|
+
* The ports of an instance still loaded, if any: a member of this copy's hub, or else one that a
|
|
15
|
+
* copy of another hub version, or a release before the hub, keeps in `loaded`.
|
|
16
|
+
*/
|
|
17
|
+
export const anyLoaded = () => hub.members.values().next().value?.questions ?? shared.loaded.values().next().value;
|
|
18
|
+
export const keyOf = (requestID) => `${PREFIX}${requestID}`;
|
|
19
|
+
/** Whether a request id is a question's rather than a permission request's. */
|
|
20
|
+
export const isQuestion = (requestID) => requestID.startsWith("question_");
|
|
21
|
+
/** How many noticed and answered questions are remembered. */
|
|
22
|
+
const REMEMBERED = 1_000;
|
|
23
|
+
/** Adds to one of the shared bounded sets. */
|
|
24
|
+
export const claim = (set, value) => addBounded(set, value, REMEMBERED);
|
package/dist/relay.d.ts
CHANGED
|
@@ -1,28 +1,14 @@
|
|
|
1
1
|
import type { Plugin } from "@opencode/plugin";
|
|
2
2
|
import { type Pending } from "./courier.js";
|
|
3
|
+
import { type Form, type PermissionAnswered, type PermissionRequest, type Reply } from "./notices.js";
|
|
3
4
|
import { type RosterStorage } from "./roster.js";
|
|
4
5
|
type Context = Plugin.Context;
|
|
5
6
|
/** One location's pending permission requests. OpenCode keeps them per location, so an isolated child's are in its worktree's. */
|
|
6
7
|
export type Permissions = Pick<Context["permission"], "list" | "reply">;
|
|
7
|
-
/** The answers to a permission request, as OpenCode's own prompt offers them. */
|
|
8
|
-
export declare const REPLIES: readonly ["once", "always", "reject"];
|
|
9
|
-
export type Reply = (typeof REPLIES)[number];
|
|
10
|
-
/**
|
|
11
|
-
* Sent with a rejection that has no reason of its own. OpenCode ends the child's turn on a bare
|
|
12
|
-
* rejection, and the child would never report back; with a message, the call fails and it carries on.
|
|
13
|
-
*/
|
|
14
|
-
export declare const REJECTED = "Refused by the session that started you. Do without it, or report back why you cannot.";
|
|
15
8
|
/** The part of OpenCode's `permission.asked` event the notice is made from. */
|
|
16
9
|
export interface PermissionAsked {
|
|
17
10
|
readonly id: string;
|
|
18
|
-
readonly data:
|
|
19
|
-
readonly id: string;
|
|
20
|
-
readonly sessionID: string;
|
|
21
|
-
readonly action: string;
|
|
22
|
-
readonly resources: ReadonlyArray<string>;
|
|
23
|
-
readonly save?: ReadonlyArray<string>;
|
|
24
|
-
readonly message?: string;
|
|
25
|
-
};
|
|
11
|
+
readonly data: PermissionRequest;
|
|
26
12
|
}
|
|
27
13
|
/** The part of OpenCode's `permission.replied` event. */
|
|
28
14
|
export interface PermissionReplied {
|
|
@@ -38,16 +24,6 @@ export interface PermissionReplied {
|
|
|
38
24
|
* notice went to a parent and that courier_answer has not answered yet.
|
|
39
25
|
*/
|
|
40
26
|
export type Waiting = Set<string>;
|
|
41
|
-
/** How a notice names where the asking session came from, to the session at the top of its lineage. */
|
|
42
|
-
export declare const origin: (startedBy?: string) => string;
|
|
43
|
-
/** For a notice that a session's turn ended without it finishing. */
|
|
44
|
-
export declare const STAYS_QUIET = "and then it does not report back on its own: if it stays quiet, message it with courier_send to have it carry on.";
|
|
45
|
-
/**
|
|
46
|
-
* What the session at the top is told. `startedBy` names the session that started the asking one
|
|
47
|
-
* when that is not the top session itself, but one started from it.
|
|
48
|
-
*/
|
|
49
|
-
export declare function permissionNotice(title: string, request: PermissionAsked["data"], startedBy?: string): string;
|
|
50
|
-
export declare function settledNotice(title: string, requestID: string, reply: Reply): string;
|
|
51
27
|
export interface AnswerPorts {
|
|
52
28
|
readonly storage: RosterStorage;
|
|
53
29
|
/** The permission domains of every loaded location; the request is answered where it is pending. */
|
|
@@ -59,18 +35,40 @@ export interface AnswerInput {
|
|
|
59
35
|
readonly reply: string;
|
|
60
36
|
readonly message?: string;
|
|
61
37
|
}
|
|
38
|
+
/** A session's pending requests in every location, with the domain holding each; a location that cannot be read gives its error. */
|
|
39
|
+
export declare function listEverywhere<D extends Pick<Permissions, "list">>(permissions: Iterable<D>, sessionID: string): Promise<({
|
|
40
|
+
domain: D;
|
|
41
|
+
requests: never[];
|
|
42
|
+
error: unknown;
|
|
43
|
+
} | {
|
|
44
|
+
domain: D;
|
|
45
|
+
requests: import("@opencode/client").PermissionRequest[];
|
|
46
|
+
})[]>;
|
|
62
47
|
/**
|
|
63
48
|
* Answers a permission request of a session started, directly or through others, from the
|
|
64
49
|
* caller, which must be the session at the top: requests go there, to the person, so a session
|
|
65
50
|
* started with courier_spawn cannot approve what its own children ask. `answered` is false when
|
|
66
|
-
*
|
|
51
|
+
* no location loaded in this server holds the request, which may then wait in another server.
|
|
67
52
|
*/
|
|
68
|
-
export declare function answer(ports: AnswerPorts, waiting: Waiting, callerID: string, input: AnswerInput): Promise<
|
|
69
|
-
sessionID: string;
|
|
70
|
-
requestID: string;
|
|
71
|
-
reply: "once" | "always" | "reject";
|
|
72
|
-
answered: boolean;
|
|
73
|
-
}>;
|
|
53
|
+
export declare function answer(ports: AnswerPorts, waiting: Waiting, callerID: string, input: AnswerInput): Promise<PermissionAnswered>;
|
|
74
54
|
/** The requests a session waits on, in whichever location holds them, for courier_status. */
|
|
75
55
|
export declare function pendingOf(permissions: Iterable<Permissions>, sessionID: string): Promise<Pending[]>;
|
|
56
|
+
/** The `metadata.kind` of the forms OpenCode's question tool asks with; the question relay passes those on. */
|
|
57
|
+
export declare const QUESTION_FORM = "question";
|
|
58
|
+
/** The part of OpenCode's `form.created` event the notice is made from. */
|
|
59
|
+
export interface FormCreated {
|
|
60
|
+
readonly id: string;
|
|
61
|
+
readonly data: {
|
|
62
|
+
readonly form: Form;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** OpenCode's `form.replied` or `form.cancelled` event: the form is no longer shown. */
|
|
66
|
+
export interface FormSettled {
|
|
67
|
+
readonly id: string;
|
|
68
|
+
readonly type: string;
|
|
69
|
+
readonly data: {
|
|
70
|
+
readonly id: string;
|
|
71
|
+
readonly sessionID: string;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
76
74
|
export {};
|
package/dist/relay.js
CHANGED
|
@@ -1,58 +1,8 @@
|
|
|
1
1
|
import { describeFailure } from "./courier.js";
|
|
2
|
+
import { REJECTED, REPLIES } from "./notices.js";
|
|
2
3
|
import { answeringTop } from "./roster.js";
|
|
3
|
-
/** The answers to a permission request, as OpenCode's own prompt offers them. */
|
|
4
|
-
export const REPLIES = ["once", "always", "reject"];
|
|
5
|
-
/**
|
|
6
|
-
* Sent with a rejection that has no reason of its own. OpenCode ends the child's turn on a bare
|
|
7
|
-
* rejection, and the child would never report back; with a message, the call fails and it carries on.
|
|
8
|
-
*/
|
|
9
|
-
export const REJECTED = "Refused by the session that started you. Do without it, or report back why you cannot.";
|
|
10
|
-
const MAX_RESOURCES = 20;
|
|
11
|
-
const MAX_RESOURCE_LENGTH = 300;
|
|
12
|
-
const clip = (text) => (text.length > MAX_RESOURCE_LENGTH ? `${text.slice(0, MAX_RESOURCE_LENGTH - 3)}...` : text);
|
|
13
|
-
function alwaysChoice(request) {
|
|
14
|
-
const save = request.save ?? [];
|
|
15
|
-
if (!save.length)
|
|
16
|
-
return [];
|
|
17
|
-
const scope = save.length === 1 && save[0] === "*" ? `every ${request.action} request` : `requests matching ${save.join(", ")}`;
|
|
18
|
-
return [`- always: allow it, and from now on ${scope} in this project`];
|
|
19
|
-
}
|
|
20
|
-
/** How a notice names where the asking session came from, to the session at the top of its lineage. */
|
|
21
|
-
export const origin = (startedBy) => startedBy ? `which ${startedBy} started with courier_spawn, a session started from yours,` : "which you started with courier_spawn,";
|
|
22
|
-
/** For a notice that a session's turn ended without it finishing. */
|
|
23
|
-
export const STAYS_QUIET = "and then it does not report back on its own: if it stays quiet, message it with courier_send to have it carry on.";
|
|
24
|
-
/**
|
|
25
|
-
* What the session at the top is told. `startedBy` names the session that started the asking one
|
|
26
|
-
* when that is not the top session itself, but one started from it.
|
|
27
|
-
*/
|
|
28
|
-
export function permissionNotice(title, request, startedBy) {
|
|
29
|
-
const resources = request.resources.slice(0, MAX_RESOURCES).map((resource) => `- ${clip(resource)}`);
|
|
30
|
-
if (request.resources.length > MAX_RESOURCES)
|
|
31
|
-
resources.push(`- and ${request.resources.length - MAX_RESOURCES} more`);
|
|
32
|
-
return [
|
|
33
|
-
`This session, "${title}", ${origin(startedBy)} is waiting for permission and does nothing until it is answered.`,
|
|
34
|
-
`It asks for: ${request.action}`,
|
|
35
|
-
...(resources.length ? ["On:", ...resources] : []),
|
|
36
|
-
...(request.message ? [`Note: ${request.message}`] : []),
|
|
37
|
-
"",
|
|
38
|
-
"Do not decide this yourself. Ask the person you are working with (with your question tool, if you have one), offering exactly these choices:",
|
|
39
|
-
"- once: allow this request only",
|
|
40
|
-
...alwaysChoice(request),
|
|
41
|
-
"- reject: refuse it, with a reason if they give one; the session's call fails and it carries on",
|
|
42
|
-
`When they have chosen, call courier_answer with sessionID "${request.sessionID}", requestID "${request.id}", reply set to their choice and, with reject, message set to their reason.`,
|
|
43
|
-
].join("\n");
|
|
44
|
-
}
|
|
45
|
-
export function settledNotice(title, requestID, reply) {
|
|
46
|
-
return [
|
|
47
|
-
`The permission request ${requestID} of this session, "${title}", has been answered (${reply}) without courier_answer, so it no longer waits on you.`,
|
|
48
|
-
"If you asked someone about it, tell them it is settled; there is nothing to pass on.",
|
|
49
|
-
...(reply === "reject"
|
|
50
|
-
? [`A refusal without a reason ends the session's turn, ${STAYS_QUIET}`]
|
|
51
|
-
: []),
|
|
52
|
-
].join("\n");
|
|
53
|
-
}
|
|
54
4
|
/** A session's pending requests in every location, with the domain holding each; a location that cannot be read gives its error. */
|
|
55
|
-
function listEverywhere(permissions, sessionID) {
|
|
5
|
+
export function listEverywhere(permissions, sessionID) {
|
|
56
6
|
return Promise.all([...permissions].map((domain) => domain.list({ sessionID }).then((requests) => ({ domain, requests: requests.filter((request) => request.sessionID === sessionID) }), (error) => ({ domain, requests: [], error }))));
|
|
57
7
|
}
|
|
58
8
|
/** The location holding a pending request, and the request; undefined when none does, and an error when one could not be read. */
|
|
@@ -72,7 +22,7 @@ async function locate(ports, sessionID, requestID) {
|
|
|
72
22
|
* Answers a permission request of a session started, directly or through others, from the
|
|
73
23
|
* caller, which must be the session at the top: requests go there, to the person, so a session
|
|
74
24
|
* started with courier_spawn cannot approve what its own children ask. `answered` is false when
|
|
75
|
-
*
|
|
25
|
+
* no location loaded in this server holds the request, which may then wait in another server.
|
|
76
26
|
*/
|
|
77
27
|
export async function answer(ports, waiting, callerID, input) {
|
|
78
28
|
const reply = input.reply;
|
|
@@ -116,3 +66,5 @@ export async function pendingOf(permissions, sessionID) {
|
|
|
116
66
|
});
|
|
117
67
|
return [...found.values()];
|
|
118
68
|
}
|
|
69
|
+
/** The `metadata.kind` of the forms OpenCode's question tool asks with; the question relay passes those on. */
|
|
70
|
+
export const QUESTION_FORM = "question";
|
package/dist/roster.d.ts
CHANGED
|
@@ -19,9 +19,27 @@ export interface RosterEntry {
|
|
|
19
19
|
/** For an isolated child, the commit its worktree was made from; its own work is what came after. */
|
|
20
20
|
readonly base?: string;
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* What the reverse index holds for a child: the sessions above it, its parent first and the session
|
|
24
|
+
* at the top, which courier_spawn did not start, last. Its roster entry is under the first, and each
|
|
25
|
+
* of the others is the parent of the one before it.
|
|
26
|
+
*/
|
|
27
|
+
export interface ReverseEntry {
|
|
28
|
+
readonly ancestors: ReadonlyArray<string>;
|
|
29
|
+
}
|
|
22
30
|
export type RosterStorage = Storage;
|
|
23
31
|
export declare function rosterKey(parentID: string, sessionID: string): string;
|
|
32
|
+
export declare function reverseKey(sessionID: string): string;
|
|
33
|
+
/**
|
|
34
|
+
* Records a child under its parent, then indexes it. The entry is what counts: a failed index
|
|
35
|
+
* write leaves it to be found by a scan, and to be indexed when the plugin is next loaded.
|
|
36
|
+
*/
|
|
24
37
|
export declare function record(storage: RosterStorage, entry: RosterEntry): Promise<void>;
|
|
38
|
+
/**
|
|
39
|
+
* Removes a child's roster entry and its reverse key. Only the entry's removal can fail it: a
|
|
40
|
+
* reverse key left behind leads nowhere, and the next load drops it.
|
|
41
|
+
*/
|
|
42
|
+
export declare function remove(storage: RosterStorage, parentID: string, sessionID: string): Promise<void>;
|
|
25
43
|
/** Removes a child from its parent's roster; false when it was not there. */
|
|
26
44
|
export declare function forget(storage: RosterStorage, parentID: string, sessionID: string): Promise<boolean>;
|
|
27
45
|
/** A parent's children, oldest first. */
|
|
@@ -49,5 +67,15 @@ export declare function answeringTop(storage: RosterStorage, sessionID: string,
|
|
|
49
67
|
export type Exists = (directory: string) => boolean;
|
|
50
68
|
/** A parent's children, oldest first, after dropping the expired ones. */
|
|
51
69
|
export declare function current(storage: RosterStorage, parentID: string, now: number, exists?: Exists): Promise<RosterEntry[]>;
|
|
52
|
-
/**
|
|
70
|
+
/**
|
|
71
|
+
* On loading: drops expired entries of every parent, so parents that never list their children
|
|
72
|
+
* don't keep them forever, then brings the reverse index in line with what is left (`backfill`).
|
|
73
|
+
*/
|
|
53
74
|
export declare function pruneExpired(storage: RosterStorage, now: number, exists?: Exists): Promise<void>;
|
|
75
|
+
/**
|
|
76
|
+
* Writes the reverse keys missing for the given entries, or naming another parent, all of the roster, such as those an older
|
|
77
|
+
* copy of the plugin recorded, and drops the reverse keys whose entry is gone, such as those of an
|
|
78
|
+
* entry an older copy removed. Keys that lead to their entry are left as they are, so running it
|
|
79
|
+
* again changes nothing.
|
|
80
|
+
*/
|
|
81
|
+
export declare function backfill(storage: RosterStorage, entries: ReadonlyArray<RosterEntry>): Promise<void>;
|