opencode-courier 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +247 -108
- package/dist/bounded.d.ts +8 -0
- package/dist/bounded.js +19 -0
- package/dist/cleanup.d.ts +21 -21
- package/dist/cleanup.js +61 -30
- package/dist/courier.d.ts +116 -15
- package/dist/courier.js +239 -74
- package/dist/group.d.ts +86 -0
- package/dist/group.js +247 -0
- package/dist/hub.d.ts +300 -0
- package/dist/hub.js +343 -0
- package/dist/index.d.ts +5 -13
- package/dist/index.js +82 -313
- package/dist/json.d.ts +8 -0
- package/dist/json.js +15 -0
- package/dist/later.d.ts +7 -7
- package/dist/later.js +8 -7
- package/dist/limits.d.ts +65 -0
- package/dist/limits.js +109 -0
- package/dist/notices.d.ts +356 -0
- package/dist/notices.js +678 -0
- package/dist/question/answer.d.ts +35 -0
- package/dist/question/answer.js +214 -0
- package/dist/question/index.d.ts +6 -0
- package/dist/question/index.js +7 -0
- package/dist/question/lifecycle.d.ts +43 -0
- package/dist/question/lifecycle.js +91 -0
- package/dist/question/pure.d.ts +48 -0
- package/dist/question/pure.js +74 -0
- package/dist/question/relay.d.ts +5 -0
- package/dist/question/relay.js +269 -0
- package/dist/question/runtime.d.ts +20 -0
- package/dist/question/runtime.js +74 -0
- package/dist/question/shared.d.ts +24 -0
- package/dist/question/shared.js +24 -0
- package/dist/relay.d.ts +32 -36
- package/dist/relay.js +6 -56
- package/dist/report.d.ts +48 -0
- package/dist/report.js +88 -0
- package/dist/roster.d.ts +48 -8
- package/dist/roster.js +140 -17
- 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/storage.d.ts +8 -1
- package/dist/storage.js +8 -5
- package/dist/tools.d.ts +94 -0
- package/dist/tools.js +297 -0
- 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 +70 -42
- package/dist/watch.js +346 -51
- package/dist/webhook.d.ts +11 -28
- package/dist/webhook.js +46 -146
- package/package.json +16 -5
- package/dist/question.d.ts +0 -158
- package/dist/question.js +0 -768
|
@@ -0,0 +1,269 @@
|
|
|
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`, through any instance still loaded then; without one, the next load tells it.
|
|
12
|
+
*/
|
|
13
|
+
function tellLater(ports, question) {
|
|
14
|
+
const tellNow = Effect.suspend(() => {
|
|
15
|
+
const later = anyLoaded();
|
|
16
|
+
if (!later || question.call || question.link || shared.questions.get(question.requestID) !== question)
|
|
17
|
+
return Effect.void;
|
|
18
|
+
return Effect.promise(() => tellCutOff(later, question, "restarted").catch((error) => later.log(`courier question: could not tell ${question.top} about ${question.requestID}: ${String(error)}`)));
|
|
19
|
+
});
|
|
20
|
+
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)}`));
|
|
21
|
+
}
|
|
22
|
+
/** Registers a question, stores it and tells the top session; false when storing or telling failed. */
|
|
23
|
+
async function relay(ports, question) {
|
|
24
|
+
shared.questions.set(question.requestID, question);
|
|
25
|
+
try {
|
|
26
|
+
await ports.storage.set(keyOf(question.requestID), storedOf(question));
|
|
27
|
+
await tell(ports, question, questionNotice(question), { asks: "question", request: question.requestID });
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
// The question stays registered and the call waits on: courier_status lists it, and an answer
|
|
32
|
+
// that comes anyway, through a link or courier_answer, still reaches the call.
|
|
33
|
+
ports.log(`courier question: could not tell ${question.top} about ${question.requestID}: ${String(error)}`);
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* After a relayed call ended: tidies up, and tells the top session if it was settled without it.
|
|
39
|
+
* `loaded` says whether the plugin instance that ran the call is still loaded.
|
|
40
|
+
*/
|
|
41
|
+
const settle = (ports, question, exit, loaded) => Effect.gen(function* () {
|
|
42
|
+
const unloaded = !loaded();
|
|
43
|
+
// A dismissal is held from now, alongside the wait below, so the grace is not spent waiting.
|
|
44
|
+
const dismissed = Exit.isFailure(exit) && isDismissal(exit.cause);
|
|
45
|
+
const held = dismissed && !unloaded ? yield* Effect.forkChild(closingSoon(ports, loaded, question.directory), { startImmediately: true }) : undefined;
|
|
46
|
+
// The top session is told first, for a while only, so what follows neither overtakes the notice
|
|
47
|
+
// nor waits forever on one that never returns; `told` is false when it was not told, or not yet.
|
|
48
|
+
const told = (yield* within(question.relaying, ports.timing.relayWaitMs)) === true;
|
|
49
|
+
// Answered by the top session.
|
|
50
|
+
if (Exit.isSuccess(exit) && exit.value.by === "top") {
|
|
51
|
+
if (shared.questions.get(question.requestID) === question)
|
|
52
|
+
shared.questions.delete(question.requestID);
|
|
53
|
+
return yield* Effect.promise(() => forget(ports, question));
|
|
54
|
+
}
|
|
55
|
+
// Never relayed, or dropped meanwhile.
|
|
56
|
+
if (shared.questions.get(question.requestID) !== question) {
|
|
57
|
+
return yield* Effect.promise(() => forget(ports, question));
|
|
58
|
+
}
|
|
59
|
+
// Cut off: the turn was stopped, or the closing location withdrew its forms as if dismissed, so a
|
|
60
|
+
// dismissal is held for a following shutdown or unload; the question stays, answered by message.
|
|
61
|
+
const closing = unloaded || !loaded() || (held !== undefined && (yield* Fiber.join(held)));
|
|
62
|
+
if (Exit.isFailure(exit) && (closing || (!dismissed && Exit.hasInterrupts(exit))))
|
|
63
|
+
return closing ? tellLater(ports, question) : yield* Effect.promise(() => stopped(ports, question));
|
|
64
|
+
yield* Effect.promise(() => settledElsewhere(ports, question, settledIn(exit), told));
|
|
65
|
+
});
|
|
66
|
+
/** A relayed call cut off by its stopped turn: the top session is told now, unless it asks the person. */
|
|
67
|
+
async function stopped(ports, question) {
|
|
68
|
+
if (!question.link)
|
|
69
|
+
await tellCutOff(ports, question, "stopped");
|
|
70
|
+
}
|
|
71
|
+
/** A relayed call settled in the asking session: `told` says whether the top session was told about it. */
|
|
72
|
+
async function settledElsewhere(ports, question, how, told) {
|
|
73
|
+
// Its record goes before its registration, so an answer that finds the question gone does not
|
|
74
|
+
// find the record still there and send it on.
|
|
75
|
+
await forget(ports, question);
|
|
76
|
+
shared.questions.delete(question.requestID);
|
|
77
|
+
if (question.link)
|
|
78
|
+
return question.link(how);
|
|
79
|
+
// A top session never told about the question is not told that it is settled either.
|
|
80
|
+
if (!told)
|
|
81
|
+
return;
|
|
82
|
+
await tell(ports, question, questionSettledNotice(question, how), {
|
|
83
|
+
answered: how.by === "child" ? "elsewhere" : how.by,
|
|
84
|
+
request: question.requestID,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Starts settling a relayed call that ended, kept on the question until it is through: an answer
|
|
89
|
+
* the call did not take waits for it.
|
|
90
|
+
*/
|
|
91
|
+
function startSettling(ports, question, exit, loaded) {
|
|
92
|
+
question.settling = background(settle(ports, question, exit, loaded), (error) => ports.log(`courier question: could not settle ${question.requestID}: ${String(error)}`)).finally(() => {
|
|
93
|
+
question.settling = undefined;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* A spawned session's question call: once its form is shown the top session is told, and whichever
|
|
98
|
+
* answer comes first ends the call, the other side's question withdrawn. Other calls run unchanged.
|
|
99
|
+
*/
|
|
100
|
+
function asking(ports, directory, original) {
|
|
101
|
+
return (input, context) => Effect.gen(function* () {
|
|
102
|
+
const current = ports();
|
|
103
|
+
const chain = current ? yield* Effect.promise(() => lineage(current.storage, context.sessionID).catch(() => [])) : [];
|
|
104
|
+
if (!current || !chain.length)
|
|
105
|
+
return yield* original(input, context);
|
|
106
|
+
const byTop = Deferred.makeUnsafe();
|
|
107
|
+
const accepted = Deferred.makeUnsafe();
|
|
108
|
+
// For a copy of the plugin that answers through `call`, which takes promises.
|
|
109
|
+
let acceptancePromise;
|
|
110
|
+
const acceptance = () => (acceptancePromise ??= Effect.runPromise(Deferred.await(accepted)));
|
|
111
|
+
// Handed one outcome only: a second, while the first still ends the call, is not taken.
|
|
112
|
+
let handed = false;
|
|
113
|
+
const question = {
|
|
114
|
+
requestID: current.newID(),
|
|
115
|
+
sessionID: context.sessionID,
|
|
116
|
+
top: chain.at(-1).parentID,
|
|
117
|
+
title: chain[0].title,
|
|
118
|
+
...(chain.length > 1 ? { startedBy: chain[0].parentID } : {}),
|
|
119
|
+
questions: promptsOf(input),
|
|
120
|
+
askedAt: current.now(),
|
|
121
|
+
directory,
|
|
122
|
+
call: (outcome) => {
|
|
123
|
+
if (handed)
|
|
124
|
+
return acceptance().then(() => false);
|
|
125
|
+
handed = true;
|
|
126
|
+
Deferred.doneUnsafe(byTop, Exit.succeed(outcome));
|
|
127
|
+
return acceptance();
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
// The form is shown only once OpenCode's permission check for the call has passed; a call
|
|
131
|
+
// refused there never reaches the top session.
|
|
132
|
+
const key = `${context.sessionID} ${context.id}`;
|
|
133
|
+
const loaded = () => ports() === current;
|
|
134
|
+
const shown = Deferred.makeUnsafe();
|
|
135
|
+
// Called as a plain function, by this copy's watcher or another copy's.
|
|
136
|
+
shared.shown.set(key, () => Deferred.doneUnsafe(shown, Exit.void));
|
|
137
|
+
const fromTop = Effect.gen(function* () {
|
|
138
|
+
yield* Deferred.await(shown);
|
|
139
|
+
// Started, not waited for: a store or a notice that hangs must not keep the top session's
|
|
140
|
+
// answer from the call. Kept on the question, so settle can wait for it.
|
|
141
|
+
question.relaying = relay(current, question);
|
|
142
|
+
return { by: "top", outcome: yield* Deferred.await(byTop) };
|
|
143
|
+
});
|
|
144
|
+
const ended = yield* Effect.raceFirst(original(input, context).pipe(Effect.map((result) => ({ by: "child", result }))), fromTop).pipe(
|
|
145
|
+
// Not waited for, so storing or a notice that hangs cannot hold the call up.
|
|
146
|
+
Effect.onExit((exit) => Effect.sync(() => {
|
|
147
|
+
shared.shown.delete(key);
|
|
148
|
+
// The call no longer waits, whatever settle makes of it: an answer from now on waits for settle.
|
|
149
|
+
const byTopSession = Exit.isSuccess(exit) && exit.value.by === "top";
|
|
150
|
+
if (!byTopSession)
|
|
151
|
+
question.call = undefined;
|
|
152
|
+
startSettling(current, question, exit, loaded);
|
|
153
|
+
// After settling is set, which an answer the call did not take then waits for.
|
|
154
|
+
Deferred.doneUnsafe(accepted, Exit.succeed(byTopSession));
|
|
155
|
+
})));
|
|
156
|
+
if (ended.by === "child")
|
|
157
|
+
return ended.result;
|
|
158
|
+
const outcome = ended.outcome;
|
|
159
|
+
if ("dismissed" in outcome)
|
|
160
|
+
return {
|
|
161
|
+
output: { answers: question.questions.map(() => []) },
|
|
162
|
+
content: DISMISSED,
|
|
163
|
+
metadata: { answers: question.questions.map(() => []), relayed: question.requestID, dismissed: true },
|
|
164
|
+
};
|
|
165
|
+
return {
|
|
166
|
+
output: { answers: outcome.answers },
|
|
167
|
+
content: answeredText(question.questions, outcome.answers),
|
|
168
|
+
metadata: { answers: outcome.answers, relayed: question.requestID, answeredIn: question.top },
|
|
169
|
+
};
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* The waiting question a top session's call asks again, the oldest if several: the same question with
|
|
174
|
+
* the same choices, in any order. Choices alone would let an unrelated yes-or-no answer a child's.
|
|
175
|
+
*/
|
|
176
|
+
function linkFor(sessionID, questions) {
|
|
177
|
+
const asked = { choices: choices(questions), wording: wording(questions) };
|
|
178
|
+
return [...shared.questions.values()]
|
|
179
|
+
.filter((question) => question.top === sessionID &&
|
|
180
|
+
!question.link &&
|
|
181
|
+
!isPassing(question.requestID) &&
|
|
182
|
+
choices(question.questions) === asked.choices &&
|
|
183
|
+
wording(question.questions) === asked.wording)
|
|
184
|
+
.sort((a, b) => a.askedAt - b.askedAt)[0];
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* The result of a top session's question call that no waiting question was linked to: when one waits
|
|
188
|
+
* with the same choices, reworded or asked earlier, it says how to pass the answers on.
|
|
189
|
+
*/
|
|
190
|
+
function unlinkedResult(result, sessionID, questions) {
|
|
191
|
+
if (typeof result.content !== "string")
|
|
192
|
+
return result;
|
|
193
|
+
const asked = choices(questions);
|
|
194
|
+
const similar = [...shared.questions.values()].filter((question) => question.top === sessionID && !question.link && !isPassing(question.requestID) && choices(question.questions) === asked);
|
|
195
|
+
if (!similar.length)
|
|
196
|
+
return result;
|
|
197
|
+
return { ...result, content: `${result.content}\n${unlinkedNote(similar)}` };
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* After a linked call failed with its location still loaded: a dismissal by the person dismisses the
|
|
201
|
+
* child's question too, or is logged if answered meanwhile; a stop while cut off tells the top.
|
|
202
|
+
*/
|
|
203
|
+
const linkedCallFailed = (ports, linked, cause, loaded, directory) => Effect.gen(function* () {
|
|
204
|
+
if (isDismissal(cause)) {
|
|
205
|
+
if (yield* closingSoon(ports, loaded, directory))
|
|
206
|
+
return;
|
|
207
|
+
if ((yield* deliver(ports, linked, { dismissed: true })) === undefined)
|
|
208
|
+
ports.log(`courier question: the dismissal of ${linked.requestID} was not passed on: it had already been answered or settled`);
|
|
209
|
+
}
|
|
210
|
+
else if (!linked.call && shared.questions.get(linked.requestID) === linked)
|
|
211
|
+
yield* Effect.promise(() => tellCutOff(ports, linked, "stopped"));
|
|
212
|
+
});
|
|
213
|
+
/** Logs that what happened to a linked call could not be passed on. */
|
|
214
|
+
const notPassedOn = (ports, linked) => (error) => ports.log(`courier question: could not pass on what happened to ${linked.requestID}: ${String(error)}`);
|
|
215
|
+
/**
|
|
216
|
+
* The question call of a session that a waiting question was relayed to, asking the person the
|
|
217
|
+
* same: what they choose is passed on, and the call is withdrawn if the question is settled first.
|
|
218
|
+
*/
|
|
219
|
+
function linking(ports, directory, ask) {
|
|
220
|
+
return (input, context) => Effect.gen(function* () {
|
|
221
|
+
const current = ports();
|
|
222
|
+
const questions = promptsOf(input);
|
|
223
|
+
const linked = current ? linkFor(context.sessionID, questions) : undefined;
|
|
224
|
+
if (!current || !linked) {
|
|
225
|
+
const result = yield* ask(input, context);
|
|
226
|
+
return current ? unlinkedResult(result, context.sessionID, questions) : result;
|
|
227
|
+
}
|
|
228
|
+
const loaded = () => ports() === current;
|
|
229
|
+
const elsewhere = Deferred.makeUnsafe();
|
|
230
|
+
// Called as a plain function, by this copy or another one.
|
|
231
|
+
const settled = (how) => void Deferred.doneUnsafe(elsewhere, Exit.succeed(how));
|
|
232
|
+
linked.link = settled;
|
|
233
|
+
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(() => {
|
|
234
|
+
if (linked.link === settled)
|
|
235
|
+
linked.link = undefined;
|
|
236
|
+
// Unloaded: OpenCode is closing the location, which withdraws the form; nobody dismissed it.
|
|
237
|
+
if (Exit.isSuccess(exit) || !loaded())
|
|
238
|
+
return;
|
|
239
|
+
// Not waited for, as in asking.
|
|
240
|
+
void background(linkedCallFailed(current, linked, exit.cause, loaded, directory), notPassedOn(current, linked));
|
|
241
|
+
})));
|
|
242
|
+
if (ended.by === "elsewhere")
|
|
243
|
+
return withdrawnResult(linked, ended.how);
|
|
244
|
+
const result = ended.result;
|
|
245
|
+
const answers = result.output?.answers ?? [];
|
|
246
|
+
const passed = yield* Effect.promise(() => Promise.resolve()
|
|
247
|
+
.then(() => run(deliver(current, linked, { answers: normalize(linked, answers) })))
|
|
248
|
+
.catch((error) => {
|
|
249
|
+
current.log(`courier question: could not pass on the answer to ${linked.requestID}: ${String(error)}`);
|
|
250
|
+
return `error: ${error instanceof Error ? error.message : String(error)}`;
|
|
251
|
+
}));
|
|
252
|
+
const note = passedNote(linked, passed);
|
|
253
|
+
return {
|
|
254
|
+
...result,
|
|
255
|
+
content: typeof result.content === "string" ? `${result.content}\n${note}` : note,
|
|
256
|
+
metadata: { ...result.metadata, relayed: linked.requestID, passed: passed === "result" || passed === "message" },
|
|
257
|
+
};
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
/** Wraps OpenCode's question tool for the relay. A tool by that id that is missing is left alone. */
|
|
261
|
+
export function relayQuestions(host, ports) {
|
|
262
|
+
// The wrapped tool runs the calls of the host's location: its shutdown is what withdraws their forms.
|
|
263
|
+
const { directory } = host.location;
|
|
264
|
+
return host.tool.transform((editor) => {
|
|
265
|
+
editor.update(QUESTION_TOOL, (tool) => {
|
|
266
|
+
tool.execute = linking(ports, directory, asking(ports, directory, tool.execute));
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Clock, Effect } from "effect";
|
|
2
|
+
/**
|
|
3
|
+
* Runs a relay effect in its own fiber by the relay's clock (`ports.now`): the promise settles as the
|
|
4
|
+
* effect ends, rejecting on failure or interruption; interrupting the waiter does not stop the fiber.
|
|
5
|
+
*/
|
|
6
|
+
export declare function run<A, E>(effect: Effect.Effect<A, E>): Promise<A>;
|
|
7
|
+
/**
|
|
8
|
+
* Runs an effect of the relay in the background, as `run` does; the promise resolves once it ended,
|
|
9
|
+
* however it ended. A failure is logged with `failed`; an interruption, by a test ending, is not.
|
|
10
|
+
*/
|
|
11
|
+
export declare function background(effect: Effect.Effect<unknown, unknown>, failed: (error: unknown) => void): Promise<void>;
|
|
12
|
+
/** The promise's value, or undefined once `ms` have passed or it failed. */
|
|
13
|
+
export declare const within: <T>(promise: Promise<T> | undefined, ms: number) => Effect.Effect<T | undefined>;
|
|
14
|
+
/**
|
|
15
|
+
* For tests: the clock the relay runs by from now on, a `TestClock` whose time `ports.now` reads,
|
|
16
|
+
* or the system clock again with none.
|
|
17
|
+
*/
|
|
18
|
+
export declare function useClock(next?: Clock.Clock): void;
|
|
19
|
+
/** For tests: interrupts every fiber the relay started, and goes back to the system clock. */
|
|
20
|
+
export declare function stopRelay(): Promise<void>;
|
|
@@ -0,0 +1,74 @@
|
|
|
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 a relay effect in its own fiber by the relay's clock (`ports.now`): the promise settles as the
|
|
42
|
+
* effect ends, rejecting on failure or interruption; interrupting the waiter does not stop the fiber.
|
|
43
|
+
*/
|
|
44
|
+
export function run(effect) {
|
|
45
|
+
return Effect.runPromise(Fiber.join(start(effect)));
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Runs an effect of the relay in the background, as `run` does; the promise resolves once it ended,
|
|
49
|
+
* however it ended. A failure is logged with `failed`; an interruption, by a test ending, is not.
|
|
50
|
+
*/
|
|
51
|
+
export function background(effect, failed) {
|
|
52
|
+
return Effect.runPromise(Fiber.await(start(effect))).then((exit) => {
|
|
53
|
+
if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause))
|
|
54
|
+
failed(Cause.squash(exit.cause));
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/** The promise's value, or undefined once `ms` have passed or it failed. */
|
|
58
|
+
export const within = (promise, ms) => promise
|
|
59
|
+
? Effect.promise(() => promise.then((value) => value, () => undefined)).pipe(Effect.timeoutOption(ms), Effect.map(Option.getOrUndefined))
|
|
60
|
+
: Effect.succeed(undefined);
|
|
61
|
+
/**
|
|
62
|
+
* For tests: the clock the relay runs by from now on, a `TestClock` whose time `ports.now` reads,
|
|
63
|
+
* or the system clock again with none.
|
|
64
|
+
*/
|
|
65
|
+
export function useClock(next) {
|
|
66
|
+
clock = next ?? live;
|
|
67
|
+
}
|
|
68
|
+
/** For tests: interrupts every fiber the relay started, and goes back to the system clock. */
|
|
69
|
+
export async function stopRelay() {
|
|
70
|
+
const ended = scope;
|
|
71
|
+
scope = Scope.makeUnsafe();
|
|
72
|
+
clock = live;
|
|
73
|
+
await Effect.runPromise(Scope.close(ended, Exit.void));
|
|
74
|
+
}
|
|
@@ -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,38 @@ 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
|
-
* Answers a permission request of a session started, directly or through others, from the
|
|
64
|
-
*
|
|
65
|
-
* started with courier_spawn cannot approve what its own children ask. `answered` is false when
|
|
66
|
-
* nothing was waiting.
|
|
48
|
+
* Answers a permission request of a session started, directly or through others, from the caller,
|
|
49
|
+
* which must be the top session; `answered` is false when no location loaded here holds the request.
|
|
67
50
|
*/
|
|
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
|
-
}>;
|
|
51
|
+
export declare function answer(ports: AnswerPorts, waiting: Waiting, callerID: string, input: AnswerInput): Promise<PermissionAnswered>;
|
|
74
52
|
/** The requests a session waits on, in whichever location holds them, for courier_status. */
|
|
75
53
|
export declare function pendingOf(permissions: Iterable<Permissions>, sessionID: string): Promise<Pending[]>;
|
|
54
|
+
/** The `metadata.kind` of the forms OpenCode's question tool asks with; the question relay passes those on. */
|
|
55
|
+
export declare const QUESTION_FORM = "question";
|
|
56
|
+
/** The part of OpenCode's `form.created` event the notice is made from. */
|
|
57
|
+
export interface FormCreated {
|
|
58
|
+
readonly id: string;
|
|
59
|
+
readonly data: {
|
|
60
|
+
readonly form: Form;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** OpenCode's `form.replied` or `form.cancelled` event: the form is no longer shown. */
|
|
64
|
+
export interface FormSettled {
|
|
65
|
+
readonly id: string;
|
|
66
|
+
readonly type: string;
|
|
67
|
+
readonly data: {
|
|
68
|
+
readonly id: string;
|
|
69
|
+
readonly sessionID: string;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
76
72
|
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. */
|
|
@@ -69,10 +19,8 @@ async function locate(ports, sessionID, requestID) {
|
|
|
69
19
|
return undefined;
|
|
70
20
|
}
|
|
71
21
|
/**
|
|
72
|
-
* Answers a permission request of a session started, directly or through others, from the
|
|
73
|
-
*
|
|
74
|
-
* started with courier_spawn cannot approve what its own children ask. `answered` is false when
|
|
75
|
-
* nothing was waiting.
|
|
22
|
+
* Answers a permission request of a session started, directly or through others, from the caller,
|
|
23
|
+
* which must be the top session; `answered` is false when no location loaded here holds the request.
|
|
76
24
|
*/
|
|
77
25
|
export async function answer(ports, waiting, callerID, input) {
|
|
78
26
|
const reply = input.reply;
|
|
@@ -116,3 +64,5 @@ export async function pendingOf(permissions, sessionID) {
|
|
|
116
64
|
});
|
|
117
65
|
return [...found.values()];
|
|
118
66
|
}
|
|
67
|
+
/** The `metadata.kind` of the forms OpenCode's question tool asks with; the question relay passes those on. */
|
|
68
|
+
export const QUESTION_FORM = "question";
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type Status } from "./notices.js";
|
|
2
|
+
import type { Storage } from "./storage.js";
|
|
3
|
+
export declare const promptKey: (sessionID: string) => string;
|
|
4
|
+
export declare const settledKey: (sessionID: string) => string;
|
|
5
|
+
export declare const progressKey: (sessionID: string) => string;
|
|
6
|
+
export declare const toldKey: (sessionID: string) => string;
|
|
7
|
+
export declare const stoppedKey: (sessionID: string) => string;
|
|
8
|
+
/** How a session's wait for a report ended: it reported to its parent, or its turn failed or was interrupted. */
|
|
9
|
+
export type Settled = "report" | "failed" | "interrupted";
|
|
10
|
+
/**
|
|
11
|
+
* Notes that a prompt or message reached a spawned session at `at`, in epoch milliseconds, unless a
|
|
12
|
+
* later one is noted: both of the hub's subscriptions note each event, one of them maybe behind.
|
|
13
|
+
*/
|
|
14
|
+
export declare function prompted(storage: Pick<Storage, "get" | "set">, sessionID: string, at: number): Promise<void>;
|
|
15
|
+
/**
|
|
16
|
+
* Notes that a spawned session reported to its parent, with `status` when its report carried one, or
|
|
17
|
+
* its turn failed or was interrupted, at `at`, unless a later end is noted.
|
|
18
|
+
*/
|
|
19
|
+
export declare function settled(storage: Pick<Storage, "get" | "set">, sessionID: string, by: Settled, at: number, status?: Status): Promise<void>;
|
|
20
|
+
/** Notes that a spawned session messaged its parent without a status at `at`, which is progress, not its report. */
|
|
21
|
+
export declare function progressed(storage: Pick<Storage, "get" | "set">, sessionID: string, at: number): Promise<void>;
|
|
22
|
+
/** Notes that a spawned session's parent was told, at `at`, that a turn of it ended without a report, unless a later telling is noted. */
|
|
23
|
+
export declare function told(storage: Pick<Storage, "get" | "set">, sessionID: string, at: number): Promise<void>;
|
|
24
|
+
/** Takes back the note `told` made at `at`, whose notice did not go out; a later one stays, and one that cannot be read goes. */
|
|
25
|
+
export declare function untold(storage: Pick<Storage, "get" | "remove">, sessionID: string, at: number): Promise<void>;
|
|
26
|
+
/** Notes that courier_stop stopped a spawned session at `at`, so the failure and silent-end notices stay quiet for it. */
|
|
27
|
+
export declare function stopped(storage: Pick<Storage, "set">, sessionID: string, at: number): Promise<void>;
|
|
28
|
+
/** Whether courier_stop stopped a session after its last prompt, or with none noted, at all. */
|
|
29
|
+
export declare function stoppedSincePrompt(storage: Pick<Storage, "get">, sessionID: string): Promise<boolean>;
|
|
30
|
+
/** The status of a spawned session's last report to its parent, if it has reported with one. */
|
|
31
|
+
export declare function lastStatus(storage: Pick<Storage, "get">, sessionID: string): Promise<Status | undefined>;
|
|
32
|
+
/**
|
|
33
|
+
* What is known of a spawned session's report: when a prompt last reached it, and whether since then it owes one, has
|
|
34
|
+
* messaged its parent without a status, or failed or was interrupted. Undefined for one spawned before this was kept.
|
|
35
|
+
*/
|
|
36
|
+
export declare function reportOf(storage: Pick<Storage, "get">, sessionID: string): Promise<{
|
|
37
|
+
prompt: number;
|
|
38
|
+
owes: boolean;
|
|
39
|
+
progressed: boolean;
|
|
40
|
+
ended: boolean;
|
|
41
|
+
} | undefined>;
|
|
42
|
+
/**
|
|
43
|
+
* Whether its parent still waits on a spawned session's report: it owes one, and the parent has not
|
|
44
|
+
* been told since its last prompt that it ended a turn without one.
|
|
45
|
+
*/
|
|
46
|
+
export declare function awaited(storage: Pick<Storage, "get">, sessionID: string): Promise<boolean>;
|
|
47
|
+
/** Drops what is kept about a session, with its roster entry. */
|
|
48
|
+
export declare function forgetReport(storage: Pick<Storage, "remove">, sessionID: string): Promise<void>;
|