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
package/dist/question.js
DELETED
|
@@ -1,768 +0,0 @@
|
|
|
1
|
-
import { Cause, Effect, Exit, Result } from "effect";
|
|
2
|
-
import { END_TURN, envelope } from "./courier.js";
|
|
3
|
-
import { origin, STAYS_QUIET } from "./relay.js";
|
|
4
|
-
import { allEntries, answeringTop, lineage, lineageIn, RETENTION_MS } from "./roster.js";
|
|
5
|
-
import { processWide, scanAll } from "./storage.js";
|
|
6
|
-
/** OpenCode's own question tool, which the relay wraps. */
|
|
7
|
-
export const QUESTION_TOOL = "question";
|
|
8
|
-
const PREFIX = "question/";
|
|
9
|
-
/** At most this many questions are kept in storage; the oldest go first. */
|
|
10
|
-
export const MAX_STORED = 100;
|
|
11
|
-
const storedOf = ({ directory: _directory, call: _call, link: _link, relaying: _relaying, settling: _settling, ...asked }) => asked;
|
|
12
|
-
const isPassing = (requestID) => shared.passing.has(requestID) || shared.answered.has(requestID);
|
|
13
|
-
// Field by field, so a copy of the plugin loaded later in the process gets what an older copy lacks.
|
|
14
|
-
const sharedState = processWide("opencode-courier.questions", () => ({}));
|
|
15
|
-
sharedState.questions ??= new Map();
|
|
16
|
-
sharedState.noticed ??= new Set();
|
|
17
|
-
sharedState.shown ??= new Map();
|
|
18
|
-
sharedState.passing ??= new Map();
|
|
19
|
-
sharedState.answered ??= new Set();
|
|
20
|
-
sharedState.loaded ??= new Set();
|
|
21
|
-
sharedState.following ??= 0;
|
|
22
|
-
sharedState.followed ??= false;
|
|
23
|
-
sharedState.shutdowns ??= new Map();
|
|
24
|
-
sharedState.closingWaiters ??= new Set();
|
|
25
|
-
const shared = sharedState;
|
|
26
|
-
/**
|
|
27
|
-
* Timings, changed by the tests: how long a question cut off by a closing location waits before an
|
|
28
|
-
* instance still loaded tells its top session, how long an answer waits for another one to it that
|
|
29
|
-
* is still being passed on, how long what follows a call's end waits for the notice of the
|
|
30
|
-
* question to go out, and how long a dismissal is held to see whether the location is shutting down.
|
|
31
|
-
*/
|
|
32
|
-
export const timing = { closingGraceMs: 30_000, passingWaitMs: 30_000, relayWaitMs: 30_000, dismissalGraceMs: 2_000 };
|
|
33
|
-
/** The key of a shutdown reported without a location: it counts for every location. */
|
|
34
|
-
const ANYWHERE = "";
|
|
35
|
-
/** When the location at `directory` (any location, with none) was last reported shutting down; 0 if never. */
|
|
36
|
-
const shutdownAt = (directory) => directory === undefined
|
|
37
|
-
? Math.max(0, ...shared.shutdowns.values())
|
|
38
|
-
: Math.max(shared.shutdowns.get(directory) ?? 0, shared.shutdowns.get(ANYWHERE) ?? 0);
|
|
39
|
-
/**
|
|
40
|
-
* For tests: when the location at `directory` was last reported shutting down, if at all; a shutdown
|
|
41
|
-
* reported without a location counts for every directory, and with none given, any location's counts.
|
|
42
|
-
*/
|
|
43
|
-
export const shutdownReportedAt = (directory) => shutdownAt(directory) || undefined;
|
|
44
|
-
/**
|
|
45
|
-
* Called for OpenCode's `location.shutdown`: the location at `directory` is closing, which
|
|
46
|
-
* withdraws its open forms as if the person had dismissed them (before unloading the plugin
|
|
47
|
-
* there, since OpenCode 2.0.22). The event's location is optional in the schema; without it,
|
|
48
|
-
* the shutdown counts for every location.
|
|
49
|
-
*/
|
|
50
|
-
export function locationClosing(directory) {
|
|
51
|
-
shared.shutdowns.set(directory ?? ANYWHERE, Date.now());
|
|
52
|
-
for (const wake of shared.closingWaiters)
|
|
53
|
-
wake();
|
|
54
|
-
}
|
|
55
|
-
/**
|
|
56
|
-
* Whether a dismissal just seen in the location at `directory` was that location closing rather
|
|
57
|
-
* than the person: true when it shut down within `ms` before, or does so, or this instance
|
|
58
|
-
* unloads, within `ms` from now. A shutdown reported without a location counts for every
|
|
59
|
-
* location, and with the directory unknown, any location's shutdown counts.
|
|
60
|
-
*/
|
|
61
|
-
function closingSoon(loaded, directory, ms) {
|
|
62
|
-
const closing = () => !loaded() || Date.now() - shutdownAt(directory) <= ms;
|
|
63
|
-
if (closing())
|
|
64
|
-
return Promise.resolve(true);
|
|
65
|
-
return new Promise((resolve) => {
|
|
66
|
-
const done = (result) => {
|
|
67
|
-
clearTimeout(timer);
|
|
68
|
-
shared.closingWaiters.delete(wake);
|
|
69
|
-
resolve(result);
|
|
70
|
-
};
|
|
71
|
-
// Woken by any shutdown or unload: only one that answers the question ends the wait.
|
|
72
|
-
const wake = () => {
|
|
73
|
-
if (closing())
|
|
74
|
-
done(true);
|
|
75
|
-
};
|
|
76
|
-
const timer = setTimeout(() => done(false), ms);
|
|
77
|
-
timer.unref?.();
|
|
78
|
-
shared.closingWaiters.add(wake);
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
/** The promise's value, or undefined once `ms` have passed or it failed. */
|
|
82
|
-
function within(promise, ms) {
|
|
83
|
-
if (!promise)
|
|
84
|
-
return Promise.resolve(undefined);
|
|
85
|
-
return new Promise((resolve) => {
|
|
86
|
-
const timer = setTimeout(() => resolve(undefined), ms);
|
|
87
|
-
timer.unref?.();
|
|
88
|
-
promise.then((value) => {
|
|
89
|
-
clearTimeout(timer);
|
|
90
|
-
resolve(value);
|
|
91
|
-
}, () => {
|
|
92
|
-
clearTimeout(timer);
|
|
93
|
-
resolve(undefined);
|
|
94
|
-
});
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
const keyOf = (requestID) => `${PREFIX}${requestID}`;
|
|
98
|
-
/** Whether a request id is a question's rather than a permission request's. */
|
|
99
|
-
export const isQuestion = (requestID) => requestID.startsWith("question_");
|
|
100
|
-
/** The questions of a call, without anything the tool does not define. */
|
|
101
|
-
function promptsOf(input) {
|
|
102
|
-
const questions = input?.questions ?? [];
|
|
103
|
-
return questions.map((prompt) => ({
|
|
104
|
-
question: prompt.question,
|
|
105
|
-
header: prompt.header,
|
|
106
|
-
options: prompt.options.map((option) => ({ label: option.label, description: option.description })),
|
|
107
|
-
...(prompt.multiple ? { multiple: true } : {}),
|
|
108
|
-
}));
|
|
109
|
-
}
|
|
110
|
-
/** What OpenCode's question tool tells the asking model once it has the answers. */
|
|
111
|
-
export function answeredText(questions, answers) {
|
|
112
|
-
const formatted = questions
|
|
113
|
-
.map((prompt, index) => `"${prompt.question}"="${answers[index]?.length ? answers[index].join(", ") : "Unanswered"}"`)
|
|
114
|
-
.join(", ");
|
|
115
|
-
return `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`;
|
|
116
|
-
}
|
|
117
|
-
const describeOption = (option) => option.description ? ` - ${option.label}: ${option.description}` : ` - ${option.label}`;
|
|
118
|
-
const describeQuestions = (questions) => questions.flatMap((prompt, index) => [
|
|
119
|
-
`${index + 1}. ${prompt.header}: ${prompt.question} (${prompt.multiple ? "any of" : "one of"}, or an answer of their own:)`,
|
|
120
|
-
...prompt.options.map(describeOption),
|
|
121
|
-
]);
|
|
122
|
-
const CUT_OFF = {
|
|
123
|
-
stopped: "its turn was stopped (interrupted, or ended by OpenCode after an hour without activity)",
|
|
124
|
-
restarted: "OpenCode restarted, or closed the session's project",
|
|
125
|
-
};
|
|
126
|
-
/** What the top session is told about a question, when it is asked or after its call was cut off. */
|
|
127
|
-
export function questionNotice(asked, cutOff) {
|
|
128
|
-
return [
|
|
129
|
-
cutOff
|
|
130
|
-
? `This session, "${asked.title}", ${origin(asked.startedBy)} was asking the question below when ${CUT_OFF[cutOff]}. The question is no longer shown anywhere, and the session does nothing until it gets the answer.`
|
|
131
|
-
: `This session, "${asked.title}", ${origin(asked.startedBy)} asks the question below and waits for the answer.`,
|
|
132
|
-
...describeQuestions(asked.questions),
|
|
133
|
-
"",
|
|
134
|
-
"Do not answer it yourself. Ask the person you are working with, using your question tool with exactly these questions:",
|
|
135
|
-
JSON.stringify({ questions: asked.questions }),
|
|
136
|
-
`What they choose there is passed on to the session${cutOff ? " as a message, which wakes it" : ""}. If you cannot use your question tool, or they answer some other way, call courier_answer with sessionID "${asked.sessionID}", requestID "${asked.requestID}" and answers: one entry per question, in order, each the label they chose or the text they gave (a list of labels where a question allows several).`,
|
|
137
|
-
].join("\n");
|
|
138
|
-
}
|
|
139
|
-
/** How a question was settled without the top session, as its settled notice puts it. */
|
|
140
|
-
function settledHow(asked, how) {
|
|
141
|
-
if (how.by === "child")
|
|
142
|
-
return `answered in its own session (${answeredText(asked.questions, how.answers)})`;
|
|
143
|
-
if (how.by === "dismissed")
|
|
144
|
-
return "dismissed in its own session, without an answer";
|
|
145
|
-
return `ended without an answer: its question call failed (${how.error})`;
|
|
146
|
-
}
|
|
147
|
-
/** What the top session is told about a question settled without it. */
|
|
148
|
-
export function settledNotice(asked, how) {
|
|
149
|
-
const what = settledHow(asked, how);
|
|
150
|
-
return [
|
|
151
|
-
`The question ${asked.requestID} of this session, "${asked.title}", was ${what}, so it no longer waits on you.`,
|
|
152
|
-
"If you asked someone about it, tell them it is settled; there is nothing to pass on.",
|
|
153
|
-
...(how.by === "child"
|
|
154
|
-
? []
|
|
155
|
-
: [`That ends the session's turn, ${STAYS_QUIET}`]),
|
|
156
|
-
].join("\n");
|
|
157
|
-
}
|
|
158
|
-
const DISMISSED = "The person dismissed this question without answering it. Carry on without the answers, or report back with courier_send to the session that started you why you cannot.";
|
|
159
|
-
async function tell(ports, asked, text, attributes) {
|
|
160
|
-
await ports.session.synthetic({
|
|
161
|
-
sessionID: asked.top,
|
|
162
|
-
text: envelope(asked.sessionID, text, attributes),
|
|
163
|
-
description: `Session ${asked.sessionID} asks a question`,
|
|
164
|
-
metadata: { source: "courier", from: asked.sessionID, requestID: asked.requestID, ...attributes },
|
|
165
|
-
delivery: "steer",
|
|
166
|
-
});
|
|
167
|
-
}
|
|
168
|
-
/** Tells the top session that a question's call was cut off, once per question and process. */
|
|
169
|
-
async function tellCutOff(ports, asked, cutOff) {
|
|
170
|
-
// Not while an answer to it is being passed on, which makes the notice moot.
|
|
171
|
-
if (shared.noticed.has(asked.requestID) || isPassing(asked.requestID))
|
|
172
|
-
return;
|
|
173
|
-
// Claimed first, so two tellers at once send one notice; given back if it did not go out.
|
|
174
|
-
claim(shared.noticed, asked.requestID);
|
|
175
|
-
try {
|
|
176
|
-
await tell(ports, asked, questionNotice(asked, cutOff), { asks: "question", request: asked.requestID, [cutOff]: "true" });
|
|
177
|
-
}
|
|
178
|
-
catch (error) {
|
|
179
|
-
shared.noticed.delete(asked.requestID);
|
|
180
|
-
throw error;
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
/** The answers in the question tool's shape, checked against the questions. */
|
|
184
|
-
function normalize(asked, answers) {
|
|
185
|
-
if (answers.length !== asked.questions.length)
|
|
186
|
-
throw new Error(`answers needs one entry per question, ${asked.questions.length} in all, not ${answers.length}.`);
|
|
187
|
-
return answers.map((answer, index) => {
|
|
188
|
-
const list = typeof answer === "string" ? [answer] : [...answer];
|
|
189
|
-
const prompt = asked.questions[index];
|
|
190
|
-
if (!prompt.multiple && list.length > 1)
|
|
191
|
-
throw new Error(`question ${index + 1} ("${prompt.header}") takes one answer, not ${list.length}.`);
|
|
192
|
-
return list;
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
/**
|
|
196
|
-
* Passes the top session's answer, or the person's dismissal, to a question: as the result of the
|
|
197
|
-
* call that waits on it, or as a message when that call was cut off. False when it was settled.
|
|
198
|
-
*/
|
|
199
|
-
async function deliver(ports, asked, outcome) {
|
|
200
|
-
const id = asked.requestID;
|
|
201
|
-
// One answer at a time, and once, per question in this process: storage cannot be claimed
|
|
202
|
-
// atomically. A second answer waits for the first, and is passed on if the first was not.
|
|
203
|
-
// While one is under way, nobody is told that the question was cut off, nor links to it.
|
|
204
|
-
const since = Date.now();
|
|
205
|
-
for (let under = shared.passing.get(id); under; under = shared.passing.get(id)) {
|
|
206
|
-
const left = timing.passingWaitMs - (Date.now() - since);
|
|
207
|
-
if (left <= 0)
|
|
208
|
-
throw new Error(`another answer to ${id} is still being passed on; try again in a while.`);
|
|
209
|
-
await within(under, left);
|
|
210
|
-
}
|
|
211
|
-
if (shared.answered.has(id))
|
|
212
|
-
return undefined;
|
|
213
|
-
const passing = passOn(ports, asked, outcome);
|
|
214
|
-
shared.passing.set(id, passing);
|
|
215
|
-
// One that hangs, on a notice that never returns, gives way after a while, so the question can be
|
|
216
|
-
// answered again; should it still go through, the child is told twice.
|
|
217
|
-
const release = setTimeout(() => shared.passing.get(id) === passing && shared.passing.delete(id), timing.passingWaitMs);
|
|
218
|
-
release.unref?.();
|
|
219
|
-
try {
|
|
220
|
-
return await passing;
|
|
221
|
-
}
|
|
222
|
-
finally {
|
|
223
|
-
clearTimeout(release);
|
|
224
|
-
if (shared.passing.get(id) === passing)
|
|
225
|
-
shared.passing.delete(id);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
async function passOn(ports, asked, outcome) {
|
|
229
|
-
const id = asked.requestID;
|
|
230
|
-
let known = shared.questions.get(id);
|
|
231
|
-
// Taken only if the call ends with it: one already ending some other way, its turn being
|
|
232
|
-
// stopped or the person answering in its session, does not.
|
|
233
|
-
if (known?.call && (await known.call(outcome))) {
|
|
234
|
-
claim(shared.answered, id);
|
|
235
|
-
shared.questions.delete(id);
|
|
236
|
-
// A call of the top session linked to it, asking the person, is withdrawn: it is settled.
|
|
237
|
-
known.link?.({ by: "top", outcome });
|
|
238
|
-
return "result";
|
|
239
|
-
}
|
|
240
|
-
// Its call has just ended: whether it was cut off, and so stays registered, or was settled, and
|
|
241
|
-
// so is dropped, is known once settle is through. The registry decides, not storage, which a
|
|
242
|
-
// question whose record could not be written is missing from.
|
|
243
|
-
if (known) {
|
|
244
|
-
await known.settling;
|
|
245
|
-
if (shared.questions.get(id) !== known)
|
|
246
|
-
return undefined;
|
|
247
|
-
}
|
|
248
|
-
else {
|
|
249
|
-
// Not known to this process: stored by one before it, and not yet loaded here.
|
|
250
|
-
const stored = (await ports.storage.get(keyOf(id)));
|
|
251
|
-
if (!stored || stored.answered)
|
|
252
|
-
return undefined;
|
|
253
|
-
known = { ...stored };
|
|
254
|
-
shared.questions.set(id, known);
|
|
255
|
-
}
|
|
256
|
-
await sendAnswer(ports, asked, outcome);
|
|
257
|
-
// Dropped only once the message is out, so a failed send leaves it to be answered again; the
|
|
258
|
-
// linked call, if any, goes then too, not before the answer is out.
|
|
259
|
-
claim(shared.answered, id);
|
|
260
|
-
shared.questions.delete(id);
|
|
261
|
-
known.link?.({ by: "top", outcome });
|
|
262
|
-
await forget(ports, asked);
|
|
263
|
-
return "message";
|
|
264
|
-
}
|
|
265
|
-
/**
|
|
266
|
-
* Drops a stored question whose answer went out. If that fails, it is marked answered instead, so
|
|
267
|
-
* it is neither listed nor answered again, nor told about after a restart.
|
|
268
|
-
*/
|
|
269
|
-
async function forget(ports, asked) {
|
|
270
|
-
try {
|
|
271
|
-
await ports.storage.remove(keyOf(asked.requestID));
|
|
272
|
-
}
|
|
273
|
-
catch (error) {
|
|
274
|
-
ports.log(`courier question: could not forget ${asked.requestID}: ${String(error)}`);
|
|
275
|
-
const marked = { ...storedOf(asked), answered: true };
|
|
276
|
-
await ports.storage.set(keyOf(asked.requestID), marked).catch(() => undefined);
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
/** Adds to a bounded set, dropping the oldest entry. */
|
|
280
|
-
function claim(set, value) {
|
|
281
|
-
set.add(value);
|
|
282
|
-
if (set.size > 1_000)
|
|
283
|
-
set.delete(set.values().next().value);
|
|
284
|
-
}
|
|
285
|
-
async function sendAnswer(ports, asked, outcome) {
|
|
286
|
-
const text = "answers" in outcome
|
|
287
|
-
? `Your question ${asked.requestID} was cut off before it was answered; here is the answer. ${answeredText(asked.questions, outcome.answers)}`
|
|
288
|
-
: `Your question ${asked.requestID} was cut off before it was answered, and then dismissed without an answer. Carry on without the answers, or report back with courier_send why you cannot.`;
|
|
289
|
-
await ports.session.synthetic({
|
|
290
|
-
sessionID: asked.sessionID,
|
|
291
|
-
text: envelope(asked.top, text, { answers: asked.requestID, ...("answers" in outcome ? {} : { dismissed: "true" }) }),
|
|
292
|
-
description: `Answer from ${asked.top}`,
|
|
293
|
-
metadata: { source: "courier", from: asked.top, answers: asked.requestID },
|
|
294
|
-
delivery: "steer",
|
|
295
|
-
});
|
|
296
|
-
}
|
|
297
|
-
/**
|
|
298
|
-
* Answers a question of a session started, directly or through others, from the caller, which must
|
|
299
|
-
* be the session at the top, as for permission requests. `answered` is false when it no longer waits.
|
|
300
|
-
*/
|
|
301
|
-
export async function answerQuestion(ports, callerID, input) {
|
|
302
|
-
const { sessionID, requestID } = input;
|
|
303
|
-
await answeringTop(ports.storage, sessionID, callerID, "questions");
|
|
304
|
-
const asked = shared.questions.get(requestID) ?? (await ports.storage.get(keyOf(requestID)));
|
|
305
|
-
if (asked?.sessionID !== sessionID || asked.answered)
|
|
306
|
-
return { sessionID, requestID, answered: false };
|
|
307
|
-
const answers = normalize(asked, input.answers);
|
|
308
|
-
const by = await deliver(ports, asked, { answers });
|
|
309
|
-
return by ? { sessionID, requestID, answered: true, by, answers } : { sessionID, requestID, answered: false };
|
|
310
|
-
}
|
|
311
|
-
/** The questions a session waits on, for courier_status; `stopped` when its call was cut off. */
|
|
312
|
-
export async function pendingQuestions(storage, sessionID) {
|
|
313
|
-
const found = new Map();
|
|
314
|
-
const add = (asked, stopped) => found.set(asked.requestID, {
|
|
315
|
-
type: "question",
|
|
316
|
-
requestID: asked.requestID,
|
|
317
|
-
questions: [...asked.questions],
|
|
318
|
-
...(stopped ? { stopped: true } : {}),
|
|
319
|
-
});
|
|
320
|
-
for (const question of shared.questions.values())
|
|
321
|
-
if (question.sessionID === sessionID && !isPassing(question.requestID))
|
|
322
|
-
add(question, !question.call);
|
|
323
|
-
for (const asked of await scanAll(storage, PREFIX))
|
|
324
|
-
if (asked.sessionID === sessionID && !asked.answered && !isPassing(asked.requestID) && !found.has(asked.requestID))
|
|
325
|
-
add(asked, true);
|
|
326
|
-
return [...found.values()];
|
|
327
|
-
}
|
|
328
|
-
/**
|
|
329
|
-
* On loading: drops stored questions that are too old, whose session is no longer on a roster, or
|
|
330
|
-
* beyond the newest `MAX_STORED`, and tells the top session about the rest, which a restart cut off.
|
|
331
|
-
*/
|
|
332
|
-
export async function noticeCutOff(ports) {
|
|
333
|
-
const stored = (await scanAll(ports.storage, PREFIX)).sort((a, b) => b.askedAt - a.askedAt);
|
|
334
|
-
if (!stored.length)
|
|
335
|
-
return;
|
|
336
|
-
const roster = await allEntries(ports.storage);
|
|
337
|
-
const now = ports.now();
|
|
338
|
-
let kept = 0;
|
|
339
|
-
// Which to keep is decided here, newest first; the drops and the notices, one per question, then
|
|
340
|
-
// go out together.
|
|
341
|
-
const work = [];
|
|
342
|
-
for (const asked of stored) {
|
|
343
|
-
const known = shared.questions.get(asked.requestID);
|
|
344
|
-
// Its call waits, or its top session is asking the person: nothing to tell.
|
|
345
|
-
if (known?.call || known?.link || known?.settling || shared.passing.has(asked.requestID)) {
|
|
346
|
-
kept++;
|
|
347
|
-
continue;
|
|
348
|
-
}
|
|
349
|
-
const current = !asked.answered &&
|
|
350
|
-
!shared.answered.has(asked.requestID) &&
|
|
351
|
-
kept < MAX_STORED &&
|
|
352
|
-
now - asked.askedAt <= RETENTION_MS &&
|
|
353
|
-
lineageIn(roster, asked.sessionID).length > 0;
|
|
354
|
-
if (!current) {
|
|
355
|
-
shared.questions.delete(asked.requestID);
|
|
356
|
-
work.push(ports.storage
|
|
357
|
-
.remove(keyOf(asked.requestID))
|
|
358
|
-
.catch((error) => ports.log(`courier question: could not drop ${asked.requestID}: ${String(error)}`)));
|
|
359
|
-
continue;
|
|
360
|
-
}
|
|
361
|
-
if (!known)
|
|
362
|
-
shared.questions.set(asked.requestID, { ...asked });
|
|
363
|
-
kept++;
|
|
364
|
-
work.push(tellCutOff(ports, known ?? asked, "restarted").catch((error) => ports.log(`courier question: could not tell ${asked.top} about ${asked.requestID}: ${String(error)}`)));
|
|
365
|
-
}
|
|
366
|
-
await Promise.all(work);
|
|
367
|
-
}
|
|
368
|
-
/**
|
|
369
|
-
* A plugin instance whose ports the relay may use, until the returned function is called. A
|
|
370
|
-
* location loading again forgets the shutdown recorded for it, so a dismissal there is not
|
|
371
|
-
* mistaken for that shutdown. One recorded for every location is left alone, since a dismissal
|
|
372
|
-
* held in another location may still need it; it ages out with the grace.
|
|
373
|
-
*/
|
|
374
|
-
export function joinRelay(ports) {
|
|
375
|
-
shared.loaded.add(ports);
|
|
376
|
-
shared.shutdowns.delete(ports.directory);
|
|
377
|
-
return () => {
|
|
378
|
-
shared.loaded.delete(ports);
|
|
379
|
-
for (const wake of shared.closingWaiters)
|
|
380
|
-
wake();
|
|
381
|
-
};
|
|
382
|
-
}
|
|
383
|
-
/**
|
|
384
|
-
* Tells the top session about a question its closing location cut off, a little later, through an
|
|
385
|
-
* instance still loaded then, if any (any will do: a notice reaches a session in any location).
|
|
386
|
-
* Without one, the next load tells it.
|
|
387
|
-
*/
|
|
388
|
-
function tellLater(question) {
|
|
389
|
-
const timer = setTimeout(() => {
|
|
390
|
-
const ports = shared.loaded.values().next().value;
|
|
391
|
-
if (!ports || question.call || question.link || shared.questions.get(question.requestID) !== question)
|
|
392
|
-
return;
|
|
393
|
-
void tellCutOff(ports, question, "restarted").catch((error) => ports.log(`courier question: could not tell ${question.top} about ${question.requestID}: ${String(error)}`));
|
|
394
|
-
}, timing.closingGraceMs);
|
|
395
|
-
timer.unref?.();
|
|
396
|
-
}
|
|
397
|
-
/** Called for OpenCode's `form.created`: a question call's form is shown, so its question can be relayed. */
|
|
398
|
-
export function formShown(event) {
|
|
399
|
-
const call = event.data.form.metadata?.tool?.id;
|
|
400
|
-
if (typeof call !== "string")
|
|
401
|
-
return;
|
|
402
|
-
const key = `${event.data.form.sessionID} ${call}`;
|
|
403
|
-
shared.shown.get(key)?.();
|
|
404
|
-
shared.shown.delete(key);
|
|
405
|
-
}
|
|
406
|
-
/**
|
|
407
|
-
* Called when an event stream may have missed forms being shown: every call still waiting for its
|
|
408
|
-
* form is taken as shown. One still waiting for its permission check is relayed early, which is the
|
|
409
|
-
* lesser harm.
|
|
410
|
-
*/
|
|
411
|
-
export function formsMayHaveBeenMissed() {
|
|
412
|
-
for (const shown of shared.shown.values())
|
|
413
|
-
shown();
|
|
414
|
-
shared.shown.clear();
|
|
415
|
-
}
|
|
416
|
-
/**
|
|
417
|
-
* Called when an instance starts following OpenCode's events. Every instance is sent every event,
|
|
418
|
-
* so a form shown is missed only while none follows them; then the calls waiting are released, and
|
|
419
|
-
* true is returned, for the caller to release them again on its first event, once it is connected.
|
|
420
|
-
*/
|
|
421
|
-
export function eventsFollowed() {
|
|
422
|
-
const missed = shared.following === 0 && shared.followed;
|
|
423
|
-
shared.following++;
|
|
424
|
-
shared.followed = true;
|
|
425
|
-
if (missed)
|
|
426
|
-
formsMayHaveBeenMissed();
|
|
427
|
-
return missed;
|
|
428
|
-
}
|
|
429
|
-
/** Called when an instance's event stream ended or broke. */
|
|
430
|
-
export function eventsLeft() {
|
|
431
|
-
shared.following = Math.max(0, shared.following - 1);
|
|
432
|
-
}
|
|
433
|
-
const defectTag = (cause) => {
|
|
434
|
-
const defect = Cause.findDefect(cause);
|
|
435
|
-
return Result.isSuccess(defect) ? defect.success?._tag : undefined;
|
|
436
|
-
};
|
|
437
|
-
/** OpenCode's question tool dies with this when the person dismisses the question. */
|
|
438
|
-
const isDismissal = (cause) => defectTag(cause) === "QuestionTool.CancelledError";
|
|
439
|
-
/** Registers a question, stores it and tells the top session; false when storing or telling failed. */
|
|
440
|
-
async function relay(ports, question) {
|
|
441
|
-
shared.questions.set(question.requestID, question);
|
|
442
|
-
try {
|
|
443
|
-
await ports.storage.set(keyOf(question.requestID), storedOf(question));
|
|
444
|
-
await tell(ports, question, questionNotice(question), { asks: "question", request: question.requestID });
|
|
445
|
-
return true;
|
|
446
|
-
}
|
|
447
|
-
catch (error) {
|
|
448
|
-
// The question stays registered and the call waits on: courier_status lists it, and an answer
|
|
449
|
-
// that comes anyway, through a link or courier_answer, still reaches the call.
|
|
450
|
-
ports.log(`courier question: could not tell ${question.top} about ${question.requestID}: ${String(error)}`);
|
|
451
|
-
return false;
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
/** How a relayed call that the top session did not answer was settled in the asking session. */
|
|
455
|
-
function settledIn(exit) {
|
|
456
|
-
if (Exit.isSuccess(exit))
|
|
457
|
-
return {
|
|
458
|
-
by: "child",
|
|
459
|
-
answers: exit.value.result?.output?.answers ?? [],
|
|
460
|
-
};
|
|
461
|
-
if (isDismissal(exit.cause))
|
|
462
|
-
return { by: "dismissed" };
|
|
463
|
-
return { by: "failed", error: String(Cause.squash(exit.cause)) };
|
|
464
|
-
}
|
|
465
|
-
/**
|
|
466
|
-
* After a relayed call ended: tidies up, and tells the top session if it was settled without it.
|
|
467
|
-
* `loaded` says whether the plugin instance that ran the call is still loaded.
|
|
468
|
-
*/
|
|
469
|
-
async function settle(ports, question, exit, loaded) {
|
|
470
|
-
const unloaded = !loaded();
|
|
471
|
-
// A dismissal is held from now, alongside the wait below, so the grace is not spent waiting.
|
|
472
|
-
const dismissed = Exit.isFailure(exit) && isDismissal(exit.cause);
|
|
473
|
-
const held = dismissed && !unloaded ? closingSoon(loaded, question.directory, timing.dismissalGraceMs) : undefined;
|
|
474
|
-
// Telling the top session first, so what follows does not overtake the notice or come before it
|
|
475
|
-
// is stored; for a while only, so a notice that never returns does not hold everything else up.
|
|
476
|
-
// `told` is false when the top session was not told, or not yet.
|
|
477
|
-
const told = (await within(question.relaying, timing.relayWaitMs)) === true;
|
|
478
|
-
// Answered by the top session.
|
|
479
|
-
if (Exit.isSuccess(exit) && exit.value.by === "top") {
|
|
480
|
-
if (shared.questions.get(question.requestID) === question)
|
|
481
|
-
shared.questions.delete(question.requestID);
|
|
482
|
-
await forget(ports, question);
|
|
483
|
-
return;
|
|
484
|
-
}
|
|
485
|
-
// Never relayed, or dropped meanwhile.
|
|
486
|
-
if (shared.questions.get(question.requestID) !== question) {
|
|
487
|
-
await forget(ports, question);
|
|
488
|
-
return;
|
|
489
|
-
}
|
|
490
|
-
// Cut off: the turn was stopped, or OpenCode is closing the location, which withdraws every open
|
|
491
|
-
// form there as if the person had dismissed it (on a server shutdown, say), before unloading the
|
|
492
|
-
// plugin there (since 2.0.22) or after (the beta). So a dismissal is held for a moment, to see
|
|
493
|
-
// whether the location's shutdown or this instance's unload follows it. The question stays,
|
|
494
|
-
// answered by message from now on; a top session already asking the person passes their answer
|
|
495
|
-
// on that way. A closing location is not told now: the next load tells it, in this process or
|
|
496
|
-
// after a restart, or an instance still loaded does, a little later, when the process is still
|
|
497
|
-
// running then. The unload is looked at again here: it may have come during the wait above, after
|
|
498
|
-
// a held dismissal had already been taken for the person's.
|
|
499
|
-
const closing = unloaded || !loaded() || (held !== undefined && (await held));
|
|
500
|
-
if (Exit.isFailure(exit) && (closing || (!dismissed && Exit.hasInterrupts(exit))))
|
|
501
|
-
return closing ? tellLater(question) : stopped(ports, question);
|
|
502
|
-
await settledElsewhere(ports, question, settledIn(exit), told);
|
|
503
|
-
}
|
|
504
|
-
/** A relayed call cut off by its stopped turn: the top session is told now, unless it asks the person. */
|
|
505
|
-
async function stopped(ports, question) {
|
|
506
|
-
if (!question.link)
|
|
507
|
-
await tellCutOff(ports, question, "stopped");
|
|
508
|
-
}
|
|
509
|
-
/** A relayed call settled in the asking session: `told` says whether the top session was told about it. */
|
|
510
|
-
async function settledElsewhere(ports, question, how, told) {
|
|
511
|
-
// Its record goes before its registration, so an answer that finds the question gone does not
|
|
512
|
-
// find the record still there and send it on.
|
|
513
|
-
await forget(ports, question);
|
|
514
|
-
shared.questions.delete(question.requestID);
|
|
515
|
-
if (question.link)
|
|
516
|
-
return question.link(how);
|
|
517
|
-
// A top session never told about the question is not told that it is settled either.
|
|
518
|
-
if (!told)
|
|
519
|
-
return;
|
|
520
|
-
await tell(ports, question, settledNotice(question, how), {
|
|
521
|
-
answered: how.by === "child" ? "elsewhere" : how.by,
|
|
522
|
-
request: question.requestID,
|
|
523
|
-
});
|
|
524
|
-
}
|
|
525
|
-
/**
|
|
526
|
-
* Starts settling a relayed call that ended, kept on the question until it is through: an answer
|
|
527
|
-
* the call did not take waits for it.
|
|
528
|
-
*/
|
|
529
|
-
function startSettling(ports, question, exit, loaded) {
|
|
530
|
-
question.settling = settle(ports, question, exit, loaded)
|
|
531
|
-
.catch((error) => ports.log(`courier question: could not settle ${question.requestID}: ${String(error)}`))
|
|
532
|
-
.finally(() => {
|
|
533
|
-
question.settling = undefined;
|
|
534
|
-
});
|
|
535
|
-
}
|
|
536
|
-
/**
|
|
537
|
-
* The question call of a session started with courier_spawn: once its form is shown, the session at
|
|
538
|
-
* the top is told, and whichever answer comes first ends the call, the other side's question being
|
|
539
|
-
* withdrawn. Other sessions' calls run unchanged.
|
|
540
|
-
*/
|
|
541
|
-
function asking(ports, directory, original) {
|
|
542
|
-
return (input, context) => Effect.gen(function* () {
|
|
543
|
-
const current = ports();
|
|
544
|
-
const chain = current ? yield* Effect.promise(() => lineage(current.storage, context.sessionID).catch(() => [])) : [];
|
|
545
|
-
if (!current || !chain.length)
|
|
546
|
-
return yield* original(input, context);
|
|
547
|
-
let answered;
|
|
548
|
-
const byTop = new Promise((resolve) => (answered = resolve));
|
|
549
|
-
let accept;
|
|
550
|
-
const accepted = new Promise((resolve) => (accept = resolve));
|
|
551
|
-
// Handed one outcome only: a second, while the first still ends the call, is not taken.
|
|
552
|
-
let handed = false;
|
|
553
|
-
const question = {
|
|
554
|
-
requestID: current.newID(),
|
|
555
|
-
sessionID: context.sessionID,
|
|
556
|
-
top: chain.at(-1).parentID,
|
|
557
|
-
title: chain[0].title,
|
|
558
|
-
...(chain.length > 1 ? { startedBy: chain[0].parentID } : {}),
|
|
559
|
-
questions: promptsOf(input),
|
|
560
|
-
askedAt: current.now(),
|
|
561
|
-
directory,
|
|
562
|
-
call: (outcome) => {
|
|
563
|
-
if (handed)
|
|
564
|
-
return accepted.then(() => false);
|
|
565
|
-
handed = true;
|
|
566
|
-
answered(outcome);
|
|
567
|
-
return accepted;
|
|
568
|
-
},
|
|
569
|
-
};
|
|
570
|
-
// The form is shown only once OpenCode's permission check for the call has passed; a call
|
|
571
|
-
// refused there never reaches the top session.
|
|
572
|
-
const key = `${context.sessionID} ${context.id}`;
|
|
573
|
-
const loaded = () => ports() === current;
|
|
574
|
-
const shown = new Promise((resolve) => shared.shown.set(key, resolve));
|
|
575
|
-
const fromTop = Effect.gen(function* () {
|
|
576
|
-
yield* Effect.promise(() => shown);
|
|
577
|
-
// Started, not waited for: a store or a notice that hangs must not keep the top session's
|
|
578
|
-
// answer from the call. Kept on the question, so settle can wait for it.
|
|
579
|
-
question.relaying = relay(current, question);
|
|
580
|
-
return { by: "top", outcome: yield* Effect.promise(() => byTop) };
|
|
581
|
-
});
|
|
582
|
-
const ended = yield* Effect.raceFirst(original(input, context).pipe(Effect.map((result) => ({ by: "child", result }))), fromTop).pipe(
|
|
583
|
-
// Not waited for, so storing or a notice that hangs cannot hold the call up.
|
|
584
|
-
Effect.onExit((exit) => Effect.sync(() => {
|
|
585
|
-
shared.shown.delete(key);
|
|
586
|
-
// The call no longer waits, whatever settle makes of it: an answer from now on waits for settle.
|
|
587
|
-
const byTopSession = Exit.isSuccess(exit) && exit.value.by === "top";
|
|
588
|
-
if (!byTopSession)
|
|
589
|
-
question.call = undefined;
|
|
590
|
-
startSettling(current, question, exit, loaded);
|
|
591
|
-
// After settling is set, which an answer the call did not take then waits for.
|
|
592
|
-
accept(byTopSession);
|
|
593
|
-
})));
|
|
594
|
-
if (ended.by === "child")
|
|
595
|
-
return ended.result;
|
|
596
|
-
const outcome = ended.outcome;
|
|
597
|
-
if ("dismissed" in outcome)
|
|
598
|
-
return {
|
|
599
|
-
output: { answers: question.questions.map(() => []) },
|
|
600
|
-
content: DISMISSED,
|
|
601
|
-
metadata: { answers: question.questions.map(() => []), relayed: question.requestID, dismissed: true },
|
|
602
|
-
};
|
|
603
|
-
return {
|
|
604
|
-
output: { answers: outcome.answers },
|
|
605
|
-
content: answeredText(question.questions, outcome.answers),
|
|
606
|
-
metadata: { answers: outcome.answers, relayed: question.requestID, answeredIn: question.top },
|
|
607
|
-
};
|
|
608
|
-
});
|
|
609
|
-
}
|
|
610
|
-
// The labels in any order: the answers are labels, so a top session listing them differently still asks the same.
|
|
611
|
-
const choices = (questions) => JSON.stringify(questions.map((prompt) => [!!prompt.multiple, prompt.options.map((option) => option.label).sort()]));
|
|
612
|
-
const wording = (questions) => JSON.stringify(questions.map((prompt) => prompt.question.trim().replace(/\s+/g, " ").toLowerCase()));
|
|
613
|
-
/**
|
|
614
|
-
* The waiting question a top session's call asks again, the oldest if several: the same questions
|
|
615
|
-
* with the same choices, in any order. Matching choices alone are not enough, or an unrelated
|
|
616
|
-
* yes-or-no question of the top session would answer a child's.
|
|
617
|
-
*/
|
|
618
|
-
function linkFor(sessionID, questions) {
|
|
619
|
-
const asked = { choices: choices(questions), wording: wording(questions) };
|
|
620
|
-
return [...shared.questions.values()]
|
|
621
|
-
.filter((question) => question.top === sessionID &&
|
|
622
|
-
!question.link &&
|
|
623
|
-
!isPassing(question.requestID) &&
|
|
624
|
-
choices(question.questions) === asked.choices &&
|
|
625
|
-
wording(question.questions) === asked.wording)
|
|
626
|
-
.sort((a, b) => a.askedAt - b.askedAt)[0];
|
|
627
|
-
}
|
|
628
|
-
/**
|
|
629
|
-
* The result of a top session's question call that no waiting question was linked to: when one
|
|
630
|
-
* waits with the same choices, reworded or asked before that session asked, it says how to pass
|
|
631
|
-
* the answers on.
|
|
632
|
-
*/
|
|
633
|
-
function unlinkedResult(result, sessionID, questions) {
|
|
634
|
-
if (typeof result.content !== "string")
|
|
635
|
-
return result;
|
|
636
|
-
const asked = choices(questions);
|
|
637
|
-
const similar = [...shared.questions.values()].filter((question) => question.top === sessionID && !question.link && !isPassing(question.requestID) && choices(question.questions) === asked);
|
|
638
|
-
if (!similar.length)
|
|
639
|
-
return result;
|
|
640
|
-
const which = similar.map((question) => `session ${question.sessionID} (requestID "${question.requestID}")`).join(", ");
|
|
641
|
-
return {
|
|
642
|
-
...result,
|
|
643
|
-
content: `${result.content}\nIf you asked this for ${which}, these answers were not passed on by themselves: pass them on with courier_answer.`,
|
|
644
|
-
};
|
|
645
|
-
}
|
|
646
|
-
/**
|
|
647
|
-
* After a linked call failed, its location still loaded: a dismissal by the person, unless the
|
|
648
|
-
* location is closing (see settle), dismisses the question they were asked for too, or is logged
|
|
649
|
-
* when the question was answered meanwhile; a call stopped while the asking call was cut off as
|
|
650
|
-
* well tells the top session, which nobody has told yet.
|
|
651
|
-
*/
|
|
652
|
-
async function linkedCallFailed(ports, linked, cause, loaded, directory) {
|
|
653
|
-
try {
|
|
654
|
-
if (isDismissal(cause)) {
|
|
655
|
-
if (await closingSoon(loaded, directory, timing.dismissalGraceMs))
|
|
656
|
-
return;
|
|
657
|
-
if ((await deliver(ports, linked, { dismissed: true })) === undefined)
|
|
658
|
-
ports.log(`courier question: the dismissal of ${linked.requestID} was not passed on: it had already been answered or settled`);
|
|
659
|
-
}
|
|
660
|
-
else if (!linked.call && shared.questions.get(linked.requestID) === linked)
|
|
661
|
-
await tellCutOff(ports, linked, "stopped");
|
|
662
|
-
}
|
|
663
|
-
catch (error) {
|
|
664
|
-
ports.log(`courier question: could not pass on what happened to ${linked.requestID}: ${String(error)}`);
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
/** How a linked call's question was settled without it, as its withdrawn result puts it, with the answers that count. */
|
|
668
|
-
function withdrawnHow(linked, how) {
|
|
669
|
-
const none = () => linked.questions.map(() => []);
|
|
670
|
-
switch (how.by) {
|
|
671
|
-
case "top":
|
|
672
|
-
return "answers" in how.outcome
|
|
673
|
-
? { what: `already answered with courier_answer (${answeredText(linked.questions, how.outcome.answers)})`, answers: how.outcome.answers }
|
|
674
|
-
: { what: "already dismissed in your session, so it carries on without the answers", answers: none() };
|
|
675
|
-
case "child":
|
|
676
|
-
return { what: `answered in its own session (${answeredText(linked.questions, how.answers)})`, answers: how.answers };
|
|
677
|
-
case "dismissed":
|
|
678
|
-
return { what: "dismissed in its own session, which ends its turn; message it with courier_send if it should carry on", answers: none() };
|
|
679
|
-
default:
|
|
680
|
-
return { what: `ended: its question call failed (${how.error})`, answers: none() };
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
/** The result of a linked call withdrawn because its question was settled without it. */
|
|
684
|
-
function withdrawnResult(linked, how) {
|
|
685
|
-
const { what, answers } = withdrawnHow(linked, how);
|
|
686
|
-
return {
|
|
687
|
-
output: { answers },
|
|
688
|
-
content: `Session ${linked.sessionID} no longer waits on this question: it was ${what}. There is nothing to pass on; tell the person it is settled.`,
|
|
689
|
-
metadata: { relayed: linked.requestID, withdrawn: true },
|
|
690
|
-
};
|
|
691
|
-
}
|
|
692
|
-
/** What a linked call adds to the person's answers about passing them on: `passed` is what deliver gave, or the error. */
|
|
693
|
-
function passedNote(linked, passed) {
|
|
694
|
-
if (passed === "result")
|
|
695
|
-
return `These answers were passed on to session ${linked.sessionID}, which carries on with them; do not call courier_answer for ${linked.requestID}. ${END_TURN}`;
|
|
696
|
-
if (passed === "message")
|
|
697
|
-
return `These answers were passed on to session ${linked.sessionID} as a message, since its question had been cut off; it carries on with them. Do not call courier_answer for ${linked.requestID}. ${END_TURN}`;
|
|
698
|
-
if (passed === undefined)
|
|
699
|
-
return `Session ${linked.sessionID} no longer waits on this question: it had already been answered or settled, so these answers were not passed on; tell the person so.`;
|
|
700
|
-
return `Passing these answers on to session ${linked.sessionID} failed (${passed.slice(7)}); call courier_answer with requestID "${linked.requestID}" to pass them on.`;
|
|
701
|
-
}
|
|
702
|
-
/**
|
|
703
|
-
* The question call of a session that a waiting question was relayed to, asking the person the
|
|
704
|
-
* same: what they choose is passed on, and the call is withdrawn if the question is settled first.
|
|
705
|
-
*/
|
|
706
|
-
function linking(ports, directory, ask) {
|
|
707
|
-
return (input, context) => Effect.gen(function* () {
|
|
708
|
-
const current = ports();
|
|
709
|
-
const questions = promptsOf(input);
|
|
710
|
-
const linked = current ? linkFor(context.sessionID, questions) : undefined;
|
|
711
|
-
if (!current || !linked) {
|
|
712
|
-
const result = yield* ask(input, context);
|
|
713
|
-
return current ? unlinkedResult(result, context.sessionID, questions) : result;
|
|
714
|
-
}
|
|
715
|
-
const loaded = () => ports() === current;
|
|
716
|
-
let settled;
|
|
717
|
-
const elsewhere = new Promise((resolve) => (settled = resolve));
|
|
718
|
-
linked.link = settled;
|
|
719
|
-
const ended = yield* Effect.raceFirst(ask(input, context).pipe(Effect.map((result) => ({ by: "person", result }))), Effect.promise(() => elsewhere).pipe(Effect.map((how) => ({ by: "elsewhere", how })))).pipe(Effect.onExit((exit) => Effect.sync(() => {
|
|
720
|
-
if (linked.link === settled)
|
|
721
|
-
linked.link = undefined;
|
|
722
|
-
// Unloaded: OpenCode is closing the location, which withdraws the form; nobody dismissed it.
|
|
723
|
-
if (Exit.isSuccess(exit) || !loaded())
|
|
724
|
-
return;
|
|
725
|
-
// Not waited for, as in asking.
|
|
726
|
-
void linkedCallFailed(current, linked, exit.cause, loaded, directory);
|
|
727
|
-
})));
|
|
728
|
-
if (ended.by === "elsewhere")
|
|
729
|
-
return withdrawnResult(linked, ended.how);
|
|
730
|
-
const result = ended.result;
|
|
731
|
-
const answers = result.output?.answers ?? [];
|
|
732
|
-
const passed = yield* Effect.promise(() => Promise.resolve()
|
|
733
|
-
.then(() => deliver(current, linked, { answers: normalize(linked, answers) }))
|
|
734
|
-
.catch((error) => {
|
|
735
|
-
current.log(`courier question: could not pass on the answer to ${linked.requestID}: ${String(error)}`);
|
|
736
|
-
return `error: ${error instanceof Error ? error.message : String(error)}`;
|
|
737
|
-
}));
|
|
738
|
-
const note = passedNote(linked, passed);
|
|
739
|
-
return {
|
|
740
|
-
...result,
|
|
741
|
-
content: typeof result.content === "string" ? `${result.content}\n${note}` : note,
|
|
742
|
-
metadata: { ...result.metadata, relayed: linked.requestID, passed: passed === "result" || passed === "message" },
|
|
743
|
-
};
|
|
744
|
-
});
|
|
745
|
-
}
|
|
746
|
-
/** Wraps OpenCode's question tool for the relay. A tool by that id that is missing is left alone. */
|
|
747
|
-
export function relayQuestions(host, ports) {
|
|
748
|
-
// The wrapped tool runs the calls of the host's location: its shutdown is what withdraws their forms.
|
|
749
|
-
const { directory } = host.location;
|
|
750
|
-
return host.tool.transform((editor) => {
|
|
751
|
-
editor.update(QUESTION_TOOL, (tool) => {
|
|
752
|
-
tool.execute = linking(ports, directory, asking(ports, directory, tool.execute));
|
|
753
|
-
});
|
|
754
|
-
});
|
|
755
|
-
}
|
|
756
|
-
/** For tests: forgets every question this process knows. */
|
|
757
|
-
export function forgetQuestions() {
|
|
758
|
-
shared.questions.clear();
|
|
759
|
-
shared.noticed.clear();
|
|
760
|
-
shared.shown.clear();
|
|
761
|
-
shared.passing.clear();
|
|
762
|
-
shared.answered.clear();
|
|
763
|
-
shared.loaded.clear();
|
|
764
|
-
shared.following = 0;
|
|
765
|
-
shared.followed = false;
|
|
766
|
-
shared.shutdowns.clear();
|
|
767
|
-
shared.closingWaiters.clear();
|
|
768
|
-
}
|