opencode-courier 0.1.6

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