opencode-courier 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +139 -94
  2. package/dist/bounded.d.ts +10 -0
  3. package/dist/bounded.js +21 -0
  4. package/dist/cleanup.d.ts +13 -18
  5. package/dist/cleanup.js +38 -19
  6. package/dist/courier.d.ts +1 -5
  7. package/dist/courier.js +11 -23
  8. package/dist/hub.d.ts +325 -0
  9. package/dist/hub.js +340 -0
  10. package/dist/index.d.ts +4 -8
  11. package/dist/index.js +72 -310
  12. package/dist/json.d.ts +4 -0
  13. package/dist/json.js +4 -0
  14. package/dist/later.d.ts +1 -1
  15. package/dist/later.js +14 -5
  16. package/dist/notices.d.ts +246 -0
  17. package/dist/notices.js +399 -0
  18. package/dist/question/answer.d.ts +35 -0
  19. package/dist/question/answer.js +217 -0
  20. package/dist/question/index.d.ts +6 -0
  21. package/dist/question/index.js +7 -0
  22. package/dist/question/lifecycle.d.ts +52 -0
  23. package/dist/question/lifecycle.js +100 -0
  24. package/dist/question/pure.d.ts +49 -0
  25. package/dist/question/pure.js +75 -0
  26. package/dist/question/relay.d.ts +5 -0
  27. package/dist/question/relay.js +283 -0
  28. package/dist/question/runtime.d.ts +21 -0
  29. package/dist/question/runtime.js +75 -0
  30. package/dist/question/shared.d.ts +24 -0
  31. package/dist/question/shared.js +24 -0
  32. package/dist/relay.d.ts +31 -33
  33. package/dist/relay.js +5 -53
  34. package/dist/roster.d.ts +29 -1
  35. package/dist/roster.js +101 -9
  36. package/dist/storage.d.ts +12 -0
  37. package/dist/storage.js +16 -3
  38. package/dist/tools.d.ts +74 -0
  39. package/dist/tools.js +228 -0
  40. package/dist/watch.d.ts +28 -37
  41. package/dist/watch.js +158 -46
  42. package/dist/webhook.d.ts +1 -13
  43. package/dist/webhook.js +121 -177
  44. package/package.json +16 -5
  45. package/dist/question.d.ts +0 -158
  46. package/dist/question.js +0 -693
@@ -0,0 +1,35 @@
1
+ import { Effect } from "effect";
2
+ import type { QuestionPorts } from "../hub.js";
3
+ import { type Asked, type CutOff, type Outcome, type Prompt, type QuestionAnswered } from "../notices.js";
4
+ import { type RosterStorage } from "../roster.js";
5
+ import { type QuestionAnswerInput } from "./pure.js";
6
+ export declare function tell(ports: QuestionPorts, asked: Asked, text: string, attributes: Record<string, string>): Promise<void>;
7
+ /** Tells the top session that a question's call was cut off, once per question and process. */
8
+ export declare function tellCutOff(ports: QuestionPorts, asked: Asked, cutOff: CutOff): Promise<void>;
9
+ /**
10
+ * Passes the top session's answer, or the person's dismissal, to a question: as the result of the
11
+ * call that waits on it, or as a message when that call was cut off. Undefined when it was settled.
12
+ */
13
+ export declare const deliver: (ports: QuestionPorts, asked: Asked, outcome: Outcome) => Effect.Effect<"result" | "message" | undefined, unknown>;
14
+ /**
15
+ * Drops a stored question whose answer went out. If that fails, it is marked answered instead, so
16
+ * it is neither listed nor answered again, nor told about after a restart.
17
+ */
18
+ export declare function forget(ports: QuestionPorts, asked: Asked): Promise<void>;
19
+ /**
20
+ * Answers a question of a session started, directly or through others, from the caller, which must
21
+ * be the session at the top, as for permission requests. `answered` is false when it no longer waits.
22
+ */
23
+ export declare function answerQuestion(ports: QuestionPorts, callerID: string, input: QuestionAnswerInput): Promise<QuestionAnswered>;
24
+ /** The questions a session waits on, for courier_status; `stopped` when its call was cut off. */
25
+ export declare function pendingQuestions(storage: RosterStorage, sessionID: string): Promise<{
26
+ type: "question";
27
+ requestID: string;
28
+ questions: Prompt[];
29
+ stopped?: true;
30
+ }[]>;
31
+ /**
32
+ * On loading: drops stored questions that are too old, whose session is no longer on a roster, or
33
+ * beyond the newest `MAX_STORED`, and tells the top session about the rest, which a restart cut off.
34
+ */
35
+ export declare function noticeCutOff(ports: QuestionPorts): Promise<void>;
@@ -0,0 +1,217 @@
1
+ import { Deferred, Effect, Semaphore } from "effect";
2
+ import { cutOffAnswer, envelope, questionNotice } from "../notices.js";
3
+ import { allEntries, answeringTop, lineageIn, RETENTION_MS } from "../roster.js";
4
+ import { scanAll } from "../storage.js";
5
+ import { within, run } from "./runtime.js";
6
+ import { normalize } from "./pure.js";
7
+ import { claim, isPassing, keyOf, MAX_STORED, PREFIX, shared, storedOf } from "./shared.js";
8
+ export async function tell(ports, asked, text, attributes) {
9
+ await ports.session.synthetic({
10
+ sessionID: asked.top,
11
+ text: envelope(asked.sessionID, text, attributes),
12
+ description: `Session ${asked.sessionID} asks a question`,
13
+ metadata: { source: "courier", from: asked.sessionID, requestID: asked.requestID, ...attributes },
14
+ delivery: "steer",
15
+ });
16
+ }
17
+ /** Tells the top session that a question's call was cut off, once per question and process. */
18
+ export async function tellCutOff(ports, asked, cutOff) {
19
+ // Not while an answer to it is being passed on, which makes the notice moot.
20
+ if (shared.noticed.has(asked.requestID) || isPassing(asked.requestID))
21
+ return;
22
+ // Claimed first, so two tellers at once send one notice; given back if it did not go out.
23
+ claim(shared.noticed, asked.requestID);
24
+ try {
25
+ await tell(ports, asked, questionNotice(asked, cutOff), { asks: "question", request: asked.requestID, [cutOff]: "true" });
26
+ }
27
+ catch (error) {
28
+ shared.noticed.delete(asked.requestID);
29
+ throw error;
30
+ }
31
+ }
32
+ const busy = (id) => new Error(`another answer to ${id} is still being passed on; try again in a while.`);
33
+ /** This copy's lock per question, kept while an answer to it is passed on or waits its turn. */
34
+ const locks = new Map();
35
+ /**
36
+ * Runs `effect` holding the question's lock, once it is taken; fails if that takes more than `ms`.
37
+ * Only this copy of the plugin takes the lock: another copy's answer shows in `shared.passing`.
38
+ */
39
+ const holding = (id, ms, effect) => Effect.acquireUseRelease(Effect.sync(() => {
40
+ const lock = locks.get(id) ?? { semaphore: Semaphore.makeUnsafe(1), users: 0 };
41
+ lock.users++;
42
+ locks.set(id, lock);
43
+ return lock;
44
+ }), (lock) => {
45
+ const taken = Deferred.makeUnsafe();
46
+ // Whichever ends first: the effect, or the wait, which fails only if the lock was not taken by then.
47
+ const late = Effect.andThen(Effect.sleep(ms), Effect.suspend(() => (Deferred.isDoneUnsafe(taken) ? Effect.never : Effect.fail(busy(id)))));
48
+ return Effect.raceFirst(lock.semaphore.withPermit(Effect.andThen(Deferred.succeed(taken, undefined), effect)), late);
49
+ }, (lock) => Effect.sync(() => {
50
+ if (--lock.users === 0)
51
+ locks.delete(id);
52
+ }));
53
+ /**
54
+ * Passes the top session's answer, or the person's dismissal, to a question: as the result of the
55
+ * call that waits on it, or as a message when that call was cut off. Undefined when it was settled.
56
+ */
57
+ export const deliver = (ports, asked, outcome) => Effect.suspend(() => {
58
+ const id = asked.requestID;
59
+ const wait = ports.timing.passingWaitMs;
60
+ // One answer at a time, and once, per question in this process: storage cannot be claimed
61
+ // atomically. A second answer waits for the first, and is passed on if the first was not.
62
+ // While one is under way, nobody is told that the question was cut off, nor links to it.
63
+ const since = ports.now();
64
+ const left = () => wait - (ports.now() - since);
65
+ const turn = holding(id, wait, Effect.gen(function* () {
66
+ // An answer that another copy of the plugin, loaded beside this one, passes on.
67
+ for (let under = shared.passing.get(id); under; under = shared.passing.get(id)) {
68
+ if (left() <= 0)
69
+ return yield* Effect.fail(busy(id));
70
+ yield* within(under, left());
71
+ }
72
+ if (shared.answered.has(id))
73
+ return undefined;
74
+ const passing = passOn(ports, asked, outcome);
75
+ shared.passing.set(id, passing);
76
+ // Held while it is passed on. One that hangs, on a notice that never returns, gives way
77
+ // after a while, so the question can be answered again; should it still go through, the
78
+ // child is told twice.
79
+ yield* within(passing, wait).pipe(Effect.ensuring(Effect.sync(() => {
80
+ if (shared.passing.get(id) === passing)
81
+ shared.passing.delete(id);
82
+ })));
83
+ return passing;
84
+ }));
85
+ return Effect.flatMap(turn, (passing) => passing ? Effect.tryPromise({ try: () => passing, catch: (error) => error }) : Effect.succeed(undefined));
86
+ });
87
+ async function passOn(ports, asked, outcome) {
88
+ const id = asked.requestID;
89
+ let known = shared.questions.get(id);
90
+ // Taken only if the call ends with it: one already ending some other way, its turn being
91
+ // stopped or the person answering in its session, does not.
92
+ if (known?.call && (await known.call(outcome))) {
93
+ claim(shared.answered, id);
94
+ shared.questions.delete(id);
95
+ // A call of the top session linked to it, asking the person, is withdrawn: it is settled.
96
+ known.link?.({ by: "top", outcome });
97
+ return "result";
98
+ }
99
+ // Its call has just ended: whether it was cut off, and so stays registered, or was settled, and
100
+ // so is dropped, is known once settle is through. The registry decides, not storage, which a
101
+ // question whose record could not be written is missing from.
102
+ if (known) {
103
+ await known.settling;
104
+ if (shared.questions.get(id) !== known)
105
+ return undefined;
106
+ }
107
+ else {
108
+ // Not known to this process: stored by one before it, and not yet loaded here.
109
+ const stored = (await ports.storage.get(keyOf(id)));
110
+ if (!stored || stored.answered)
111
+ return undefined;
112
+ known = { ...stored };
113
+ shared.questions.set(id, known);
114
+ }
115
+ await sendAnswer(ports, asked, outcome);
116
+ // Dropped only once the message is out, so a failed send leaves it to be answered again; the
117
+ // linked call, if any, goes then too, not before the answer is out.
118
+ claim(shared.answered, id);
119
+ shared.questions.delete(id);
120
+ known.link?.({ by: "top", outcome });
121
+ await forget(ports, asked);
122
+ return "message";
123
+ }
124
+ /**
125
+ * Drops a stored question whose answer went out. If that fails, it is marked answered instead, so
126
+ * it is neither listed nor answered again, nor told about after a restart.
127
+ */
128
+ export async function forget(ports, asked) {
129
+ try {
130
+ await ports.storage.remove(keyOf(asked.requestID));
131
+ }
132
+ catch (error) {
133
+ ports.log(`courier question: could not forget ${asked.requestID}: ${String(error)}`);
134
+ const marked = { ...storedOf(asked), answered: true };
135
+ await ports.storage.set(keyOf(asked.requestID), marked).catch(() => undefined);
136
+ }
137
+ }
138
+ async function sendAnswer(ports, asked, outcome) {
139
+ await ports.session.synthetic({
140
+ sessionID: asked.sessionID,
141
+ text: envelope(asked.top, cutOffAnswer(asked, outcome), { answers: asked.requestID, ...("answers" in outcome ? {} : { dismissed: "true" }) }),
142
+ description: `Answer from ${asked.top}`,
143
+ metadata: { source: "courier", from: asked.top, answers: asked.requestID },
144
+ delivery: "steer",
145
+ });
146
+ }
147
+ /**
148
+ * Answers a question of a session started, directly or through others, from the caller, which must
149
+ * be the session at the top, as for permission requests. `answered` is false when it no longer waits.
150
+ */
151
+ export async function answerQuestion(ports, callerID, input) {
152
+ const { sessionID, requestID } = input;
153
+ await answeringTop(ports.storage, sessionID, callerID, "questions");
154
+ const asked = shared.questions.get(requestID) ?? (await ports.storage.get(keyOf(requestID)));
155
+ if (asked?.sessionID !== sessionID || asked.answered)
156
+ return { sessionID, requestID, answered: false };
157
+ const answers = normalize(asked, input.answers);
158
+ const by = await run(deliver(ports, asked, { answers }));
159
+ return by ? { sessionID, requestID, answered: true, by, answers } : { sessionID, requestID, answered: false };
160
+ }
161
+ /** The questions a session waits on, for courier_status; `stopped` when its call was cut off. */
162
+ export async function pendingQuestions(storage, sessionID) {
163
+ const found = new Map();
164
+ const add = (asked, stopped) => found.set(asked.requestID, {
165
+ type: "question",
166
+ requestID: asked.requestID,
167
+ questions: [...asked.questions],
168
+ ...(stopped ? { stopped: true } : {}),
169
+ });
170
+ for (const question of shared.questions.values())
171
+ if (question.sessionID === sessionID && !isPassing(question.requestID))
172
+ add(question, !question.call);
173
+ for (const asked of await scanAll(storage, PREFIX))
174
+ if (asked.sessionID === sessionID && !asked.answered && !isPassing(asked.requestID) && !found.has(asked.requestID))
175
+ add(asked, true);
176
+ return [...found.values()];
177
+ }
178
+ /**
179
+ * On loading: drops stored questions that are too old, whose session is no longer on a roster, or
180
+ * beyond the newest `MAX_STORED`, and tells the top session about the rest, which a restart cut off.
181
+ */
182
+ export async function noticeCutOff(ports) {
183
+ const stored = (await scanAll(ports.storage, PREFIX)).sort((a, b) => b.askedAt - a.askedAt);
184
+ if (!stored.length)
185
+ return;
186
+ const roster = await allEntries(ports.storage);
187
+ const now = ports.now();
188
+ let kept = 0;
189
+ // Which to keep is decided here, newest first; the drops and the notices, one per question, then
190
+ // go out together.
191
+ const work = [];
192
+ for (const asked of stored) {
193
+ const known = shared.questions.get(asked.requestID);
194
+ // Its call waits, or its top session is asking the person: nothing to tell.
195
+ if (known?.call || known?.link || known?.settling || shared.passing.has(asked.requestID)) {
196
+ kept++;
197
+ continue;
198
+ }
199
+ const current = !asked.answered &&
200
+ !shared.answered.has(asked.requestID) &&
201
+ kept < MAX_STORED &&
202
+ now - asked.askedAt <= RETENTION_MS &&
203
+ lineageIn(roster, asked.sessionID).length > 0;
204
+ if (!current) {
205
+ shared.questions.delete(asked.requestID);
206
+ work.push(ports.storage
207
+ .remove(keyOf(asked.requestID))
208
+ .catch((error) => ports.log(`courier question: could not drop ${asked.requestID}: ${String(error)}`)));
209
+ continue;
210
+ }
211
+ if (!known)
212
+ shared.questions.set(asked.requestID, { ...asked });
213
+ kept++;
214
+ work.push(tellCutOff(ports, known ?? asked, "restarted").catch((error) => ports.log(`courier question: could not tell ${asked.top} about ${asked.requestID}: ${String(error)}`)));
215
+ }
216
+ await Promise.all(work);
217
+ }
@@ -0,0 +1,6 @@
1
+ export { answerQuestion, noticeCutOff, pendingQuestions } from "./answer.js";
2
+ export { eventsFollowed, eventsLeft, formShown, formsMayHaveBeenMissed, joinRelay, locationClosing, shutdownReportedAt } from "./lifecycle.js";
3
+ export { byCodeUnit, type QuestionAnswerInput } from "./pure.js";
4
+ export { relayQuestions } from "./relay.js";
5
+ export { isQuestion, MAX_STORED, QUESTION_TOOL } from "./shared.js";
6
+ export type { QuestionPorts, QuestionTiming } from "../hub.js";
@@ -0,0 +1,7 @@
1
+ // The question relay: what the rest of the plugin uses of it. The relay's state is kept in the hub
2
+ // (`../hub.ts`), shared by every plugin instance in the process.
3
+ export { answerQuestion, noticeCutOff, pendingQuestions } from "./answer.js";
4
+ export { eventsFollowed, eventsLeft, formShown, formsMayHaveBeenMissed, joinRelay, locationClosing, shutdownReportedAt } from "./lifecycle.js";
5
+ export { byCodeUnit } from "./pure.js";
6
+ export { relayQuestions } from "./relay.js";
7
+ export { isQuestion, MAX_STORED, QUESTION_TOOL } from "./shared.js";
@@ -0,0 +1,52 @@
1
+ import { Effect } from "effect";
2
+ import type { QuestionPorts } from "../hub.js";
3
+ /**
4
+ * For tests: when the location at `directory` was last reported shutting down, if at all; a shutdown
5
+ * reported without a location counts for every directory, and with none given, any location's counts.
6
+ */
7
+ export declare const shutdownReportedAt: (directory?: string) => number | undefined;
8
+ /**
9
+ * Called for OpenCode's `location.shutdown`: the location at `directory` is closing, which
10
+ * withdraws its open forms as if the person had dismissed them (before unloading the plugin
11
+ * there, since OpenCode 2.0.22). The event's location is optional in the schema; without it,
12
+ * the shutdown counts for every location. `at` is when, by the watcher's clock (epoch milliseconds).
13
+ */
14
+ export declare function locationClosing(at: number, directory?: string): void;
15
+ /**
16
+ * Whether a dismissal just seen in the location at `directory` was that location closing rather
17
+ * than the person: true when it shut down within the dismissal grace before, or does so, or this
18
+ * instance unloads, within the grace from now, by the clock of `ports`. A shutdown reported without a location counts for every
19
+ * location, and with the directory unknown, any location's shutdown counts.
20
+ */
21
+ export declare const closingSoon: (ports: QuestionPorts, loaded: () => boolean, directory: string | undefined) => Effect.Effect<boolean>;
22
+ /**
23
+ * Adds a plugin instance's ports to `loaded`, which copies of other versions read to tell a cut-off
24
+ * question through it, until the returned function is called. A location loading again forgets the
25
+ * shutdown recorded for it, so a dismissal there is not mistaken for that shutdown. One recorded for
26
+ * every location is left alone, since a dismissal held in another location may still need it; it
27
+ * ages out with the grace.
28
+ */
29
+ export declare function joinRelay(ports: QuestionPorts): () => void;
30
+ /** Called for OpenCode's `form.created`: a question call's form is shown, so its question can be relayed. */
31
+ export declare function formShown(event: {
32
+ readonly data: {
33
+ readonly form: {
34
+ readonly sessionID: string;
35
+ readonly metadata?: unknown;
36
+ };
37
+ };
38
+ }): void;
39
+ /**
40
+ * Called when an event stream may have missed forms being shown: every call still waiting for its
41
+ * form is taken as shown. One still waiting for its permission check is relayed early, which is the
42
+ * lesser harm.
43
+ */
44
+ export declare function formsMayHaveBeenMissed(): void;
45
+ /**
46
+ * Called when an instance starts following OpenCode's events. Every instance is sent every event,
47
+ * so a form shown is missed only while none follows them; then the calls waiting are released, and
48
+ * true is returned, for the caller to release them again on its first event, once it is connected.
49
+ */
50
+ export declare function eventsFollowed(): boolean;
51
+ /** Called when an instance's event stream ended or broke. */
52
+ export declare function eventsLeft(): void;
@@ -0,0 +1,100 @@
1
+ import { Deferred, Effect, Exit } from "effect";
2
+ import { shared } from "./shared.js";
3
+ /** The key of a shutdown reported without a location: it counts for every location. */
4
+ const ANYWHERE = "";
5
+ /** When the location at `directory` (any location, with none) was last reported shutting down; 0 if never. */
6
+ const shutdownAt = (directory) => directory === undefined
7
+ ? Math.max(0, ...shared.shutdowns.values())
8
+ : Math.max(shared.shutdowns.get(directory) ?? 0, shared.shutdowns.get(ANYWHERE) ?? 0);
9
+ /**
10
+ * For tests: when the location at `directory` was last reported shutting down, if at all; a shutdown
11
+ * reported without a location counts for every directory, and with none given, any location's counts.
12
+ */
13
+ export const shutdownReportedAt = (directory) => shutdownAt(directory) || undefined;
14
+ /**
15
+ * Called for OpenCode's `location.shutdown`: the location at `directory` is closing, which
16
+ * withdraws its open forms as if the person had dismissed them (before unloading the plugin
17
+ * there, since OpenCode 2.0.22). The event's location is optional in the schema; without it,
18
+ * the shutdown counts for every location. `at` is when, by the watcher's clock (epoch milliseconds).
19
+ */
20
+ export function locationClosing(at, directory) {
21
+ shared.shutdowns.set(directory ?? ANYWHERE, at);
22
+ for (const wake of shared.closingWaiters)
23
+ wake();
24
+ }
25
+ /**
26
+ * Whether a dismissal just seen in the location at `directory` was that location closing rather
27
+ * than the person: true when it shut down within the dismissal grace before, or does so, or this
28
+ * instance unloads, within the grace from now, by the clock of `ports`. A shutdown reported without a location counts for every
29
+ * location, and with the directory unknown, any location's shutdown counts.
30
+ */
31
+ export const closingSoon = (ports, loaded, directory) => Effect.suspend(() => {
32
+ const ms = ports.timing.dismissalGraceMs;
33
+ const closing = () => !loaded() || ports.now() - shutdownAt(directory) <= ms;
34
+ if (closing())
35
+ return Effect.succeed(true);
36
+ const closed = Deferred.makeUnsafe();
37
+ // Woken by any shutdown or unload: only one that answers the question ends the wait.
38
+ const wake = () => {
39
+ if (closing())
40
+ Deferred.doneUnsafe(closed, Exit.succeed(true));
41
+ };
42
+ return Effect.acquireUseRelease(
43
+ // Looked at as the waiter is added, so a shutdown cannot come in between unseen.
44
+ Effect.sync(() => {
45
+ shared.closingWaiters.add(wake);
46
+ wake();
47
+ }), () => (Deferred.isDoneUnsafe(closed) ? Effect.succeed(true) : Effect.raceFirst(Deferred.await(closed), Effect.as(Effect.sleep(ms), false))), () => Effect.sync(() => shared.closingWaiters.delete(wake)));
48
+ });
49
+ /**
50
+ * Adds a plugin instance's ports to `loaded`, which copies of other versions read to tell a cut-off
51
+ * question through it, until the returned function is called. A location loading again forgets the
52
+ * shutdown recorded for it, so a dismissal there is not mistaken for that shutdown. One recorded for
53
+ * every location is left alone, since a dismissal held in another location may still need it; it
54
+ * ages out with the grace.
55
+ */
56
+ export function joinRelay(ports) {
57
+ shared.loaded.add(ports);
58
+ shared.shutdowns.delete(ports.directory);
59
+ return () => {
60
+ shared.loaded.delete(ports);
61
+ for (const wake of shared.closingWaiters)
62
+ wake();
63
+ };
64
+ }
65
+ /** Called for OpenCode's `form.created`: a question call's form is shown, so its question can be relayed. */
66
+ export function formShown(event) {
67
+ const call = event.data.form.metadata?.tool?.id;
68
+ if (typeof call !== "string")
69
+ return;
70
+ const key = `${event.data.form.sessionID} ${call}`;
71
+ shared.shown.get(key)?.();
72
+ shared.shown.delete(key);
73
+ }
74
+ /**
75
+ * Called when an event stream may have missed forms being shown: every call still waiting for its
76
+ * form is taken as shown. One still waiting for its permission check is relayed early, which is the
77
+ * lesser harm.
78
+ */
79
+ export function formsMayHaveBeenMissed() {
80
+ for (const shown of shared.shown.values())
81
+ shown();
82
+ shared.shown.clear();
83
+ }
84
+ /**
85
+ * Called when an instance starts following OpenCode's events. Every instance is sent every event,
86
+ * so a form shown is missed only while none follows them; then the calls waiting are released, and
87
+ * true is returned, for the caller to release them again on its first event, once it is connected.
88
+ */
89
+ export function eventsFollowed() {
90
+ const missed = shared.following === 0 && shared.followed;
91
+ shared.following++;
92
+ shared.followed = true;
93
+ if (missed)
94
+ formsMayHaveBeenMissed();
95
+ return missed;
96
+ }
97
+ /** Called when an instance's event stream ended or broke. */
98
+ export function eventsLeft() {
99
+ shared.following = Math.max(0, shared.following - 1);
100
+ }
@@ -0,0 +1,49 @@
1
+ import type { Plugin as EffectPlugin } from "@opencode/plugin/effect";
2
+ import { Cause, Exit, type Effect } from "effect";
3
+ import type { Question } from "../hub.js";
4
+ import { type Answers, type Asked, type Elsewhere, type Prompt, type Withdrawal } from "../notices.js";
5
+ type ToolEditor = Parameters<Parameters<EffectPlugin.Context["tool"]["transform"]>[0]>[0];
6
+ type HostTool = Parameters<Parameters<ToolEditor["update"]>[1]>[0];
7
+ export type Execute = HostTool["execute"];
8
+ export type ToolResult = Effect.Success<ReturnType<Execute>>;
9
+ /** The questions of a call, without anything the tool does not define. */
10
+ export declare function promptsOf(input: unknown): Prompt[];
11
+ export interface QuestionAnswerInput {
12
+ readonly sessionID: string;
13
+ readonly requestID: string;
14
+ /** One entry per question: a label or the text the person gave, or a list of them. */
15
+ readonly answers: ReadonlyArray<string | ReadonlyArray<string>>;
16
+ }
17
+ /** The answers in the question tool's shape, checked against the questions. */
18
+ export declare function normalize(asked: Asked, answers: QuestionAnswerInput["answers"]): Answers;
19
+ /** OpenCode's question tool dies with this when the person dismisses the question. */
20
+ export declare const isDismissal: (cause: Cause.Cause<unknown>) => boolean;
21
+ /** How a relayed call ended. */
22
+ export type CallExit = Exit.Exit<{
23
+ by: "child";
24
+ result: ToolResult;
25
+ } | {
26
+ by: "top";
27
+ }, unknown>;
28
+ /** How a relayed call that the top session did not answer was settled in the asking session. */
29
+ export declare function settledIn(exit: CallExit): Elsewhere;
30
+ /**
31
+ * Orders strings by their UTF-16 code units, the default sort's order, stated. The order is never
32
+ * shown; it only has to come out the same on both sides of a comparison, which a locale-aware
33
+ * compare would not promise: it can rank two different labels as equal and leave them as listed.
34
+ */
35
+ export declare function byCodeUnit(a: string, b: string): 1 | 0 | -1;
36
+ export declare const choices: (questions: ReadonlyArray<Prompt>) => string;
37
+ export declare const wording: (questions: ReadonlyArray<Prompt>) => string;
38
+ /** The result of a linked call withdrawn because its question was settled without it. */
39
+ export declare function withdrawnResult(linked: Question, how: Withdrawal): {
40
+ output: {
41
+ answers: Answers;
42
+ };
43
+ content: string;
44
+ metadata: {
45
+ relayed: string;
46
+ withdrawn: boolean;
47
+ };
48
+ };
49
+ export {};
@@ -0,0 +1,75 @@
1
+ import { Cause, Exit, Result } from "effect";
2
+ import { withdrawnText } from "../notices.js";
3
+ /** The questions of a call, without anything the tool does not define. */
4
+ export function promptsOf(input) {
5
+ const questions = input?.questions ?? [];
6
+ return questions.map((prompt) => ({
7
+ question: prompt.question,
8
+ header: prompt.header,
9
+ options: prompt.options.map((option) => ({ label: option.label, description: option.description })),
10
+ ...(prompt.multiple ? { multiple: true } : {}),
11
+ }));
12
+ }
13
+ /** The answers in the question tool's shape, checked against the questions. */
14
+ export function normalize(asked, answers) {
15
+ if (answers.length !== asked.questions.length)
16
+ throw new Error(`answers needs one entry per question, ${asked.questions.length} in all, not ${answers.length}.`);
17
+ return answers.map((answer, index) => {
18
+ const list = typeof answer === "string" ? [answer] : [...answer];
19
+ const prompt = asked.questions[index];
20
+ if (!prompt.multiple && list.length > 1)
21
+ throw new Error(`question ${index + 1} ("${prompt.header}") takes one answer, not ${list.length}.`);
22
+ return list;
23
+ });
24
+ }
25
+ const defectTag = (cause) => {
26
+ const defect = Cause.findDefect(cause);
27
+ return Result.isSuccess(defect) ? defect.success?._tag : undefined;
28
+ };
29
+ /** OpenCode's question tool dies with this when the person dismisses the question. */
30
+ export const isDismissal = (cause) => defectTag(cause) === "QuestionTool.CancelledError";
31
+ /** How a relayed call that the top session did not answer was settled in the asking session. */
32
+ export function settledIn(exit) {
33
+ if (Exit.isSuccess(exit))
34
+ return {
35
+ by: "child",
36
+ answers: exit.value.result?.output?.answers ?? [],
37
+ };
38
+ if (isDismissal(exit.cause))
39
+ return { by: "dismissed" };
40
+ return { by: "failed", error: String(Cause.squash(exit.cause)) };
41
+ }
42
+ /**
43
+ * Orders strings by their UTF-16 code units, the default sort's order, stated. The order is never
44
+ * shown; it only has to come out the same on both sides of a comparison, which a locale-aware
45
+ * compare would not promise: it can rank two different labels as equal and leave them as listed.
46
+ */
47
+ export function byCodeUnit(a, b) {
48
+ if (a === b)
49
+ return 0;
50
+ return a < b ? -1 : 1;
51
+ }
52
+ // The labels in any order: the answers are labels, so a top session listing them differently still asks the same.
53
+ export const choices = (questions) => JSON.stringify(questions.map((prompt) => [!!prompt.multiple, prompt.options.map((option) => option.label).sort(byCodeUnit)]));
54
+ export const wording = (questions) => JSON.stringify(questions.map((prompt) => prompt.question.trim().replace(/\s+/g, " ").toLowerCase()));
55
+ /** The answers that count once a linked call's question was settled without it: none unless it was answered. */
56
+ function withdrawnAnswers(linked, how) {
57
+ const none = () => linked.questions.map(() => []);
58
+ switch (how.by) {
59
+ case "top":
60
+ return "answers" in how.outcome ? how.outcome.answers : none();
61
+ case "child":
62
+ return how.answers;
63
+ case "dismissed":
64
+ case "failed":
65
+ return none();
66
+ }
67
+ }
68
+ /** The result of a linked call withdrawn because its question was settled without it. */
69
+ export function withdrawnResult(linked, how) {
70
+ return {
71
+ output: { answers: withdrawnAnswers(linked, how) },
72
+ content: withdrawnText(linked, how),
73
+ metadata: { relayed: linked.requestID, withdrawn: true },
74
+ };
75
+ }
@@ -0,0 +1,5 @@
1
+ import type { Plugin as EffectPlugin } from "@opencode/plugin/effect";
2
+ import { Effect } from "effect";
3
+ import type { QuestionPorts } from "../hub.js";
4
+ /** Wraps OpenCode's question tool for the relay. A tool by that id that is missing is left alone. */
5
+ export declare function relayQuestions(host: Pick<EffectPlugin.Context, "tool" | "location">, ports: () => QuestionPorts | undefined): Effect.Effect<import("@opencode/plugin/effect/registration").Registration, never, import("effect/Scope").Scope>;