opencode-courier 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +247 -108
- package/dist/bounded.d.ts +8 -0
- package/dist/bounded.js +19 -0
- package/dist/cleanup.d.ts +21 -21
- package/dist/cleanup.js +61 -30
- package/dist/courier.d.ts +116 -15
- package/dist/courier.js +239 -74
- package/dist/group.d.ts +86 -0
- package/dist/group.js +247 -0
- package/dist/hub.d.ts +300 -0
- package/dist/hub.js +343 -0
- package/dist/index.d.ts +5 -13
- package/dist/index.js +82 -313
- package/dist/json.d.ts +8 -0
- package/dist/json.js +15 -0
- package/dist/later.d.ts +7 -7
- package/dist/later.js +8 -7
- package/dist/limits.d.ts +65 -0
- package/dist/limits.js +109 -0
- package/dist/notices.d.ts +356 -0
- package/dist/notices.js +678 -0
- package/dist/question/answer.d.ts +35 -0
- package/dist/question/answer.js +214 -0
- package/dist/question/index.d.ts +6 -0
- package/dist/question/index.js +7 -0
- package/dist/question/lifecycle.d.ts +43 -0
- package/dist/question/lifecycle.js +91 -0
- package/dist/question/pure.d.ts +48 -0
- package/dist/question/pure.js +74 -0
- package/dist/question/relay.d.ts +5 -0
- package/dist/question/relay.js +269 -0
- package/dist/question/runtime.d.ts +20 -0
- package/dist/question/runtime.js +74 -0
- package/dist/question/shared.d.ts +24 -0
- package/dist/question/shared.js +24 -0
- package/dist/relay.d.ts +32 -36
- package/dist/relay.js +6 -56
- package/dist/report.d.ts +48 -0
- package/dist/report.js +88 -0
- package/dist/roster.d.ts +48 -8
- package/dist/roster.js +140 -17
- package/dist/skill.d.ts +13 -0
- package/dist/skill.js +9 -0
- package/dist/stop.d.ts +41 -0
- package/dist/stop.js +120 -0
- package/dist/storage.d.ts +8 -1
- package/dist/storage.js +8 -5
- package/dist/tools.d.ts +94 -0
- package/dist/tools.js +297 -0
- package/dist/tree.d.ts +102 -0
- package/dist/tree.js +58 -0
- package/dist/version.d.ts +4 -10
- package/dist/version.js +4 -10
- package/dist/watch.d.ts +70 -42
- package/dist/watch.js +346 -51
- package/dist/webhook.d.ts +11 -28
- package/dist/webhook.js +46 -146
- package/package.json +16 -5
- package/dist/question.d.ts +0 -158
- package/dist/question.js +0 -768
|
@@ -0,0 +1,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,214 @@
|
|
|
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, since storage cannot be claimed
|
|
61
|
+
// atomically: a second waits, going only if the first did not; meanwhile no cut-off notice or link.
|
|
62
|
+
const since = ports.now();
|
|
63
|
+
const left = () => wait - (ports.now() - since);
|
|
64
|
+
const turn = holding(id, wait, Effect.gen(function* () {
|
|
65
|
+
// An answer that another copy of the plugin, loaded beside this one, passes on.
|
|
66
|
+
for (let under = shared.passing.get(id); under; under = shared.passing.get(id)) {
|
|
67
|
+
if (left() <= 0)
|
|
68
|
+
return yield* Effect.fail(busy(id));
|
|
69
|
+
yield* within(under, left());
|
|
70
|
+
}
|
|
71
|
+
if (shared.answered.has(id))
|
|
72
|
+
return undefined;
|
|
73
|
+
const passing = passOn(ports, asked, outcome);
|
|
74
|
+
shared.passing.set(id, passing);
|
|
75
|
+
// Held while it is passed on; one that hangs on a notice that never returns gives way after a
|
|
76
|
+
// while, so the question can be answered again, and the child may then be told twice.
|
|
77
|
+
yield* within(passing, wait).pipe(Effect.ensuring(Effect.sync(() => {
|
|
78
|
+
if (shared.passing.get(id) === passing)
|
|
79
|
+
shared.passing.delete(id);
|
|
80
|
+
})));
|
|
81
|
+
return passing;
|
|
82
|
+
}));
|
|
83
|
+
return Effect.flatMap(turn, (passing) => passing ? Effect.tryPromise({ try: () => passing, catch: (error) => error }) : Effect.succeed(undefined));
|
|
84
|
+
});
|
|
85
|
+
async function passOn(ports, asked, outcome) {
|
|
86
|
+
const id = asked.requestID;
|
|
87
|
+
let known = shared.questions.get(id);
|
|
88
|
+
// Taken only if the call ends with it: one already ending some other way, its turn being
|
|
89
|
+
// stopped or the person answering in its session, does not.
|
|
90
|
+
if (known?.call && (await known.call(outcome))) {
|
|
91
|
+
claim(shared.answered, id);
|
|
92
|
+
shared.questions.delete(id);
|
|
93
|
+
// A call of the top session linked to it, asking the person, is withdrawn: it is settled.
|
|
94
|
+
known.link?.({ by: "top", outcome });
|
|
95
|
+
return "result";
|
|
96
|
+
}
|
|
97
|
+
// Its call just ended: whether it was cut off and stays or was settled and is dropped is known once
|
|
98
|
+
// settle is through, from the registry, not storage, which lacks a question whose write failed.
|
|
99
|
+
if (known) {
|
|
100
|
+
await known.settling;
|
|
101
|
+
if (shared.questions.get(id) !== known)
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
// Not known to this process: stored by one before it, and not yet loaded here.
|
|
106
|
+
const stored = (await ports.storage.get(keyOf(id)));
|
|
107
|
+
if (!stored || stored.answered)
|
|
108
|
+
return undefined;
|
|
109
|
+
known = { ...stored };
|
|
110
|
+
shared.questions.set(id, known);
|
|
111
|
+
}
|
|
112
|
+
await sendAnswer(ports, asked, outcome);
|
|
113
|
+
// Dropped only once the message is out, so a failed send leaves it to be answered again; the
|
|
114
|
+
// linked call, if any, goes then too, not before the answer is out.
|
|
115
|
+
claim(shared.answered, id);
|
|
116
|
+
shared.questions.delete(id);
|
|
117
|
+
known.link?.({ by: "top", outcome });
|
|
118
|
+
await forget(ports, asked);
|
|
119
|
+
return "message";
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Drops a stored question whose answer went out. If that fails, it is marked answered instead, so
|
|
123
|
+
* it is neither listed nor answered again, nor told about after a restart.
|
|
124
|
+
*/
|
|
125
|
+
export async function forget(ports, asked) {
|
|
126
|
+
try {
|
|
127
|
+
await ports.storage.remove(keyOf(asked.requestID));
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
ports.log(`courier question: could not forget ${asked.requestID}: ${String(error)}`);
|
|
131
|
+
const marked = { ...storedOf(asked), answered: true };
|
|
132
|
+
await ports.storage.set(keyOf(asked.requestID), marked).catch(() => undefined);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
async function sendAnswer(ports, asked, outcome) {
|
|
136
|
+
await ports.session.synthetic({
|
|
137
|
+
sessionID: asked.sessionID,
|
|
138
|
+
text: envelope(asked.top, cutOffAnswer(asked, outcome), { answers: asked.requestID, ...("answers" in outcome ? {} : { dismissed: "true" }) }),
|
|
139
|
+
description: `Answer from ${asked.top}`,
|
|
140
|
+
metadata: { source: "courier", from: asked.top, answers: asked.requestID },
|
|
141
|
+
delivery: "steer",
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Answers a question of a session started, directly or through others, from the caller, which must
|
|
146
|
+
* be the session at the top, as for permission requests. `answered` is false when it no longer waits.
|
|
147
|
+
*/
|
|
148
|
+
export async function answerQuestion(ports, callerID, input) {
|
|
149
|
+
const { sessionID, requestID } = input;
|
|
150
|
+
await answeringTop(ports.storage, sessionID, callerID, "questions");
|
|
151
|
+
const asked = shared.questions.get(requestID) ?? (await ports.storage.get(keyOf(requestID)));
|
|
152
|
+
if (asked?.sessionID !== sessionID || asked.answered)
|
|
153
|
+
return { sessionID, requestID, answered: false };
|
|
154
|
+
const answers = normalize(asked, input.answers);
|
|
155
|
+
const by = await run(deliver(ports, asked, { answers }));
|
|
156
|
+
return by ? { sessionID, requestID, answered: true, by, answers } : { sessionID, requestID, answered: false };
|
|
157
|
+
}
|
|
158
|
+
/** The questions a session waits on, for courier_status; `stopped` when its call was cut off. */
|
|
159
|
+
export async function pendingQuestions(storage, sessionID) {
|
|
160
|
+
const found = new Map();
|
|
161
|
+
const add = (asked, stopped) => found.set(asked.requestID, {
|
|
162
|
+
type: "question",
|
|
163
|
+
requestID: asked.requestID,
|
|
164
|
+
questions: [...asked.questions],
|
|
165
|
+
...(stopped ? { stopped: true } : {}),
|
|
166
|
+
});
|
|
167
|
+
for (const question of shared.questions.values())
|
|
168
|
+
if (question.sessionID === sessionID && !isPassing(question.requestID))
|
|
169
|
+
add(question, !question.call);
|
|
170
|
+
for (const asked of await scanAll(storage, PREFIX))
|
|
171
|
+
if (asked.sessionID === sessionID && !asked.answered && !isPassing(asked.requestID) && !found.has(asked.requestID))
|
|
172
|
+
add(asked, true);
|
|
173
|
+
return [...found.values()];
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* On loading: drops stored questions that are too old, whose session is no longer on a roster, or
|
|
177
|
+
* beyond the newest `MAX_STORED`, and tells the top session about the rest, which a restart cut off.
|
|
178
|
+
*/
|
|
179
|
+
export async function noticeCutOff(ports) {
|
|
180
|
+
const stored = (await scanAll(ports.storage, PREFIX)).sort((a, b) => b.askedAt - a.askedAt);
|
|
181
|
+
if (!stored.length)
|
|
182
|
+
return;
|
|
183
|
+
const roster = await allEntries(ports.storage);
|
|
184
|
+
const now = ports.now();
|
|
185
|
+
let kept = 0;
|
|
186
|
+
// Which to keep is decided here, newest first; the drops and the notices, one per question, then
|
|
187
|
+
// go out together.
|
|
188
|
+
const work = [];
|
|
189
|
+
for (const asked of stored) {
|
|
190
|
+
const known = shared.questions.get(asked.requestID);
|
|
191
|
+
// Its call waits, or its top session is asking the person: nothing to tell.
|
|
192
|
+
if (known?.call || known?.link || known?.settling || shared.passing.has(asked.requestID)) {
|
|
193
|
+
kept++;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const current = !asked.answered &&
|
|
197
|
+
!shared.answered.has(asked.requestID) &&
|
|
198
|
+
kept < MAX_STORED &&
|
|
199
|
+
now - asked.askedAt <= RETENTION_MS &&
|
|
200
|
+
lineageIn(roster, asked.sessionID).length > 0;
|
|
201
|
+
if (!current) {
|
|
202
|
+
shared.questions.delete(asked.requestID);
|
|
203
|
+
work.push(ports.storage
|
|
204
|
+
.remove(keyOf(asked.requestID))
|
|
205
|
+
.catch((error) => ports.log(`courier question: could not drop ${asked.requestID}: ${String(error)}`)));
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (!known)
|
|
209
|
+
shared.questions.set(asked.requestID, { ...asked });
|
|
210
|
+
kept++;
|
|
211
|
+
work.push(tellCutOff(ports, known ?? asked, "restarted").catch((error) => ports.log(`courier question: could not tell ${asked.top} about ${asked.requestID}: ${String(error)}`)));
|
|
212
|
+
}
|
|
213
|
+
await Promise.all(work);
|
|
214
|
+
}
|
|
@@ -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,43 @@
|
|
|
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 `location.shutdown`: the location at `directory` is closing, which withdraws its open
|
|
10
|
+
* forms as if dismissed; with no location it counts for all. `at` is the watcher's clock, epoch ms.
|
|
11
|
+
*/
|
|
12
|
+
export declare function locationClosing(at: number, directory?: string): void;
|
|
13
|
+
/**
|
|
14
|
+
* Whether a dismissal just seen at `directory` was the location closing rather than the person: it
|
|
15
|
+
* shut down within the grace, before or after, or this instance unloads in it; unknown matches any.
|
|
16
|
+
*/
|
|
17
|
+
export declare const closingSoon: (ports: QuestionPorts, loaded: () => boolean, directory: string | undefined) => Effect.Effect<boolean>;
|
|
18
|
+
/**
|
|
19
|
+
* Adds an instance's ports to `loaded`, which other copies tell cut-off questions through, until the
|
|
20
|
+
* returned function is called. A location loading again forgets the shutdown recorded for it alone.
|
|
21
|
+
*/
|
|
22
|
+
export declare function joinRelay(ports: QuestionPorts): () => void;
|
|
23
|
+
/** Called for OpenCode's `form.created`: a question call's form is shown, so its question can be relayed. */
|
|
24
|
+
export declare function formShown(event: {
|
|
25
|
+
readonly data: {
|
|
26
|
+
readonly form: {
|
|
27
|
+
readonly sessionID: string;
|
|
28
|
+
readonly metadata?: unknown;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
}): void;
|
|
32
|
+
/**
|
|
33
|
+
* Called when an event stream may have missed forms shown: every call still waiting for its form is
|
|
34
|
+
* taken as shown; one still waiting for its permission check is relayed early, the lesser harm.
|
|
35
|
+
*/
|
|
36
|
+
export declare function formsMayHaveBeenMissed(): void;
|
|
37
|
+
/**
|
|
38
|
+
* Called when an instance starts following OpenCode's events: a shown form is missed only while none
|
|
39
|
+
* follows, so waiting calls are released; true tells the caller to release again on its first event.
|
|
40
|
+
*/
|
|
41
|
+
export declare function eventsFollowed(): boolean;
|
|
42
|
+
/** Called when an instance's event stream ended or broke. */
|
|
43
|
+
export declare function eventsLeft(): void;
|
|
@@ -0,0 +1,91 @@
|
|
|
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 `location.shutdown`: the location at `directory` is closing, which withdraws its open
|
|
16
|
+
* forms as if dismissed; with no location it counts for all. `at` is the watcher's clock, epoch ms.
|
|
17
|
+
*/
|
|
18
|
+
export function locationClosing(at, directory) {
|
|
19
|
+
shared.shutdowns.set(directory ?? ANYWHERE, at);
|
|
20
|
+
for (const wake of shared.closingWaiters)
|
|
21
|
+
wake();
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Whether a dismissal just seen at `directory` was the location closing rather than the person: it
|
|
25
|
+
* shut down within the grace, before or after, or this instance unloads in it; unknown matches any.
|
|
26
|
+
*/
|
|
27
|
+
export const closingSoon = (ports, loaded, directory) => Effect.suspend(() => {
|
|
28
|
+
const ms = ports.timing.dismissalGraceMs;
|
|
29
|
+
const closing = () => !loaded() || ports.now() - shutdownAt(directory) <= ms;
|
|
30
|
+
if (closing())
|
|
31
|
+
return Effect.succeed(true);
|
|
32
|
+
const closed = Deferred.makeUnsafe();
|
|
33
|
+
// Woken by any shutdown or unload: only one that answers the question ends the wait.
|
|
34
|
+
const wake = () => {
|
|
35
|
+
if (closing())
|
|
36
|
+
Deferred.doneUnsafe(closed, Exit.succeed(true));
|
|
37
|
+
};
|
|
38
|
+
return Effect.acquireUseRelease(
|
|
39
|
+
// Looked at as the waiter is added, so a shutdown cannot come in between unseen.
|
|
40
|
+
Effect.sync(() => {
|
|
41
|
+
shared.closingWaiters.add(wake);
|
|
42
|
+
wake();
|
|
43
|
+
}), () => (Deferred.isDoneUnsafe(closed) ? Effect.succeed(true) : Effect.raceFirst(Deferred.await(closed), Effect.as(Effect.sleep(ms), false))), () => Effect.sync(() => shared.closingWaiters.delete(wake)));
|
|
44
|
+
});
|
|
45
|
+
/**
|
|
46
|
+
* Adds an instance's ports to `loaded`, which other copies tell cut-off questions through, until the
|
|
47
|
+
* returned function is called. A location loading again forgets the shutdown recorded for it alone.
|
|
48
|
+
*/
|
|
49
|
+
export function joinRelay(ports) {
|
|
50
|
+
shared.loaded.add(ports);
|
|
51
|
+
shared.shutdowns.delete(ports.directory);
|
|
52
|
+
return () => {
|
|
53
|
+
shared.loaded.delete(ports);
|
|
54
|
+
for (const wake of shared.closingWaiters)
|
|
55
|
+
wake();
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** Called for OpenCode's `form.created`: a question call's form is shown, so its question can be relayed. */
|
|
59
|
+
export function formShown(event) {
|
|
60
|
+
const call = event.data.form.metadata?.tool?.id;
|
|
61
|
+
if (typeof call !== "string")
|
|
62
|
+
return;
|
|
63
|
+
const key = `${event.data.form.sessionID} ${call}`;
|
|
64
|
+
shared.shown.get(key)?.();
|
|
65
|
+
shared.shown.delete(key);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Called when an event stream may have missed forms shown: every call still waiting for its form is
|
|
69
|
+
* taken as shown; one still waiting for its permission check is relayed early, the lesser harm.
|
|
70
|
+
*/
|
|
71
|
+
export function formsMayHaveBeenMissed() {
|
|
72
|
+
for (const shown of shared.shown.values())
|
|
73
|
+
shown();
|
|
74
|
+
shared.shown.clear();
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Called when an instance starts following OpenCode's events: a shown form is missed only while none
|
|
78
|
+
* follows, so waiting calls are released; true tells the caller to release again on its first event.
|
|
79
|
+
*/
|
|
80
|
+
export function eventsFollowed() {
|
|
81
|
+
const missed = shared.following === 0 && shared.followed;
|
|
82
|
+
shared.following++;
|
|
83
|
+
shared.followed = true;
|
|
84
|
+
if (missed)
|
|
85
|
+
formsMayHaveBeenMissed();
|
|
86
|
+
return missed;
|
|
87
|
+
}
|
|
88
|
+
/** Called when an instance's event stream ended or broke. */
|
|
89
|
+
export function eventsLeft() {
|
|
90
|
+
shared.following = Math.max(0, shared.following - 1);
|
|
91
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
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 UTF-16 code units, the default sort's order, stated: it only has to come out the
|
|
32
|
+
* same on both sides of a comparison; a locale-aware compare may rank two different labels as equal.
|
|
33
|
+
*/
|
|
34
|
+
export declare function byCodeUnit(a: string, b: string): 0 | 1 | -1;
|
|
35
|
+
export declare const choices: (questions: ReadonlyArray<Prompt>) => string;
|
|
36
|
+
export declare const wording: (questions: ReadonlyArray<Prompt>) => string;
|
|
37
|
+
/** The result of a linked call withdrawn because its question was settled without it. */
|
|
38
|
+
export declare function withdrawnResult(linked: Question, how: Withdrawal): {
|
|
39
|
+
output: {
|
|
40
|
+
answers: Answers;
|
|
41
|
+
};
|
|
42
|
+
content: string;
|
|
43
|
+
metadata: {
|
|
44
|
+
relayed: string;
|
|
45
|
+
withdrawn: boolean;
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
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 UTF-16 code units, the default sort's order, stated: it only has to come out the
|
|
44
|
+
* same on both sides of a comparison; a locale-aware compare may rank two different labels as equal.
|
|
45
|
+
*/
|
|
46
|
+
export function byCodeUnit(a, b) {
|
|
47
|
+
if (a === b)
|
|
48
|
+
return 0;
|
|
49
|
+
return a < b ? -1 : 1;
|
|
50
|
+
}
|
|
51
|
+
// The labels in any order: the answers are labels, so a top session listing them differently still asks the same.
|
|
52
|
+
export const choices = (questions) => JSON.stringify(questions.map((prompt) => [!!prompt.multiple, prompt.options.map((option) => option.label).sort(byCodeUnit)]));
|
|
53
|
+
export const wording = (questions) => JSON.stringify(questions.map((prompt) => prompt.question.trim().replace(/\s+/g, " ").toLowerCase()));
|
|
54
|
+
/** The answers that count once a linked call's question was settled without it: none unless it was answered. */
|
|
55
|
+
function withdrawnAnswers(linked, how) {
|
|
56
|
+
const none = () => linked.questions.map(() => []);
|
|
57
|
+
switch (how.by) {
|
|
58
|
+
case "top":
|
|
59
|
+
return "answers" in how.outcome ? how.outcome.answers : none();
|
|
60
|
+
case "child":
|
|
61
|
+
return how.answers;
|
|
62
|
+
case "dismissed":
|
|
63
|
+
case "failed":
|
|
64
|
+
return none();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** The result of a linked call withdrawn because its question was settled without it. */
|
|
68
|
+
export function withdrawnResult(linked, how) {
|
|
69
|
+
return {
|
|
70
|
+
output: { answers: withdrawnAnswers(linked, how) },
|
|
71
|
+
content: withdrawnText(linked, how),
|
|
72
|
+
metadata: { relayed: linked.requestID, withdrawn: true },
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -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>;
|