opencode-courier 0.3.0 → 0.4.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 +175 -274
- package/dist/index.js +47 -73
- package/dist/kernel/protocol.d.ts +20 -0
- package/dist/kernel/protocol.js +36 -0
- package/dist/kernel/tool.d.ts +5 -0
- package/dist/kernel/tool.js +5 -0
- package/dist/person/events.d.ts +23 -0
- package/dist/person/events.js +113 -0
- package/dist/person/form.d.ts +26 -0
- package/dist/person/form.js +64 -0
- package/dist/person/index.d.ts +17 -0
- package/dist/person/index.js +5 -0
- package/dist/{relay.d.ts → person/permission.d.ts} +27 -21
- package/dist/{relay.js → person/permission.js} +9 -5
- package/dist/person/ports.d.ts +58 -0
- package/dist/person/ports.js +2 -0
- package/dist/{question → person/question}/answer.d.ts +33 -4
- package/dist/{question → person/question}/answer.js +43 -8
- package/dist/person/question/index.d.ts +7 -0
- package/dist/{question → person/question}/index.js +3 -3
- package/dist/{question → person/question}/lifecycle.d.ts +1 -1
- package/dist/{question → person/question}/pure.d.ts +1 -2
- package/dist/{question → person/question}/pure.js +1 -1
- package/dist/{question → person/question}/relay.d.ts +1 -1
- package/dist/{question → person/question}/relay.js +14 -8
- package/dist/{question → person/question}/shared.d.ts +13 -3
- package/dist/person/question/shared.js +52 -0
- package/dist/person/question/types.d.ts +121 -0
- package/dist/person/question/types.js +1 -0
- package/dist/person/rpc-handlers.d.ts +160 -0
- package/dist/person/rpc-handlers.js +62 -0
- package/dist/person/rpc.d.ts +227 -0
- package/dist/person/rpc.js +70 -0
- package/dist/person/text.d.ts +68 -0
- package/dist/person/text.js +262 -0
- package/dist/person/tools.d.ts +20 -0
- package/dist/person/tools.js +55 -0
- package/dist/person/tui.d.ts +46 -0
- package/dist/{bounded.d.ts → platform/bounded.d.ts} +4 -0
- package/dist/{bounded.js → platform/bounded.js} +4 -0
- package/dist/platform/events.d.ts +34 -0
- package/dist/platform/events.js +121 -0
- package/dist/platform/hub.d.ts +195 -0
- package/dist/{hub.js → platform/hub.js} +27 -71
- package/dist/{version.js → platform/version.js} +1 -1
- package/dist/rpc.d.ts +2 -0
- package/dist/rpc.js +3 -0
- package/dist/scheduling/index.d.ts +6 -0
- package/dist/scheduling/index.js +5 -0
- package/dist/{later.d.ts → scheduling/later.d.ts} +7 -3
- package/dist/{later.js → scheduling/later.js} +12 -4
- package/dist/scheduling/text.d.ts +6 -0
- package/dist/scheduling/text.js +10 -0
- package/dist/scheduling/tools.d.ts +14 -0
- package/dist/scheduling/tools.js +48 -0
- package/dist/skill.d.ts +4 -0
- package/dist/skill.js +54 -1
- package/dist/{cleanup.d.ts → tree/cleanup.d.ts} +1 -27
- package/dist/{cleanup.js → tree/cleanup.js} +9 -16
- package/dist/tree/context.d.ts +15 -0
- package/dist/tree/context.js +34 -0
- package/dist/tree/domain.d.ts +101 -0
- package/dist/tree/domain.js +68 -0
- package/dist/{watch.d.ts → tree/events.d.ts} +10 -37
- package/dist/tree/events.js +204 -0
- package/dist/tree/forget.d.ts +24 -0
- package/dist/tree/forget.js +59 -0
- package/dist/tree/group.d.ts +82 -0
- package/dist/tree/group.js +203 -0
- package/dist/tree/index.d.ts +10 -0
- package/dist/tree/index.js +11 -0
- package/dist/tree/limits.d.ts +10 -0
- package/dist/{limits.js → tree/limits.js} +6 -55
- package/dist/tree/panel.d.ts +13 -0
- package/dist/tree/panel.js +75 -0
- package/dist/tree/ports.d.ts +276 -0
- package/dist/tree/ports.js +1 -0
- package/dist/tree/report.d.ts +23 -0
- package/dist/tree/report.js +33 -0
- package/dist/tree/roster.d.ts +27 -0
- package/dist/tree/roster.js +62 -0
- package/dist/tree/rpc-handlers.d.ts +201 -0
- package/dist/tree/rpc-handlers.js +20 -0
- package/dist/tree/rpc.d.ts +344 -0
- package/dist/tree/rpc.js +83 -0
- package/dist/tree/send.d.ts +14 -0
- package/dist/tree/send.js +99 -0
- package/dist/tree/spawn.d.ts +21 -0
- package/dist/tree/spawn.js +173 -0
- package/dist/tree/status.d.ts +128 -0
- package/dist/tree/status.js +62 -0
- package/dist/{stop.d.ts → tree/stop.d.ts} +1 -9
- package/dist/{stop.js → tree/stop.js} +9 -13
- package/dist/tree/store.d.ts +24 -0
- package/dist/tree/store.js +264 -0
- package/dist/{tree.d.ts → tree/subtree.d.ts} +5 -6
- package/dist/{tree.js → tree/subtree.js} +7 -5
- package/dist/tree/summary.d.ts +7 -0
- package/dist/tree/summary.js +35 -0
- package/dist/tree/text.d.ts +77 -0
- package/dist/tree/text.js +287 -0
- package/dist/tree/tools.d.ts +54 -0
- package/dist/{tools.js → tree/tools.js} +12 -142
- package/dist/tree/tui.d.ts +53 -0
- package/dist/tui.d.ts +3 -0
- package/dist/tui.js +1219 -0
- package/dist/webhook/index.d.ts +5 -0
- package/dist/webhook/index.js +3 -0
- package/dist/{webhook.d.ts → webhook/receiver.d.ts} +17 -23
- package/dist/{webhook.js → webhook/receiver.js} +58 -50
- package/dist/webhook/subscriptions.d.ts +29 -0
- package/dist/webhook/subscriptions.js +52 -0
- package/dist/webhook/text.d.ts +23 -0
- package/dist/webhook/text.js +82 -0
- package/dist/webhook/tools.d.ts +19 -0
- package/dist/webhook/tools.js +46 -0
- package/package.json +18 -7
- package/dist/courier.d.ts +0 -200
- package/dist/courier.js +0 -315
- package/dist/group.d.ts +0 -86
- package/dist/group.js +0 -247
- package/dist/hub.d.ts +0 -300
- package/dist/limits.d.ts +0 -65
- package/dist/notices.d.ts +0 -356
- package/dist/notices.js +0 -678
- package/dist/question/index.d.ts +0 -6
- package/dist/question/shared.js +0 -24
- package/dist/report.d.ts +0 -48
- package/dist/report.js +0 -88
- package/dist/roster.d.ts +0 -93
- package/dist/roster.js +0 -206
- package/dist/tools.d.ts +0 -94
- package/dist/watch.js +0 -474
- /package/dist/{question → person/question}/lifecycle.js +0 -0
- /package/dist/{question → person/question}/runtime.d.ts +0 -0
- /package/dist/{question → person/question}/runtime.js +0 -0
- /package/dist/{json.d.ts → platform/json.d.ts} +0 -0
- /package/dist/{json.js → platform/json.js} +0 -0
- /package/dist/{storage.d.ts → platform/storage.d.ts} +0 -0
- /package/dist/{storage.js → platform/storage.js} +0 -0
- /package/dist/{version.d.ts → platform/version.d.ts} +0 -0
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/** The permission, form and question notices and the result of courier_answer, snapshotted with the other texts. */
|
|
2
|
+
import { END_TURN } from "../kernel/protocol.js";
|
|
3
|
+
export function answerText(result) {
|
|
4
|
+
// A permission request is pending only in the server running the session's turn; a question is
|
|
5
|
+
// stored where every server on the data directory finds it.
|
|
6
|
+
if (!result.answered && "reply" in result)
|
|
7
|
+
return (`Nothing was passed on: no request ${result.requestID} of ${result.sessionID} is pending in this OpenCode server. ` +
|
|
8
|
+
"It was answered some other way, or the session stopped waiting, or it waits in another OpenCode server on the " +
|
|
9
|
+
"same data directory, which this one cannot reach; only the person can see which. Tell them: \"Your answer was " +
|
|
10
|
+
"not passed on. If the session still waits, its request is in another OpenCode server: answer it there, in that " +
|
|
11
|
+
`session." Do not message the session about it or answer it again from here. ${END_TURN}`);
|
|
12
|
+
if (!result.answered)
|
|
13
|
+
return (`${result.sessionID} no longer waits on question ${result.requestID}: it was answered some other way, or the ` +
|
|
14
|
+
"session stopped waiting. Nothing was passed on; tell the person their answer is not needed.");
|
|
15
|
+
if ("reply" in result)
|
|
16
|
+
return `Passed on ${result.reply} for request ${result.requestID} of ${result.sessionID}, which carries on and reports back with courier_send. ${END_TURN}`;
|
|
17
|
+
const how = result.by === "message" ? " as a message, since its question had been cut off" : "";
|
|
18
|
+
return `Passed the answers to question ${result.requestID} on to ${result.sessionID}${how}; it carries on and reports back with courier_send. ${END_TURN}`;
|
|
19
|
+
}
|
|
20
|
+
// Permission requests.
|
|
21
|
+
/**
|
|
22
|
+
* Sent with a rejection that has no reason of its own. OpenCode ends the child's turn on a bare
|
|
23
|
+
* rejection, and the child would never report back; with a message, the call fails and it carries on.
|
|
24
|
+
*/
|
|
25
|
+
export const REJECTED = "Refused by the session that started you. Do without it, or report back why you cannot.";
|
|
26
|
+
const MAX_RESOURCES = 20;
|
|
27
|
+
const MAX_RESOURCE_LENGTH = 300;
|
|
28
|
+
const clip = (text) => (text.length > MAX_RESOURCE_LENGTH ? `${text.slice(0, MAX_RESOURCE_LENGTH - 3)}...` : text);
|
|
29
|
+
function alwaysChoice(request) {
|
|
30
|
+
const save = request.save ?? [];
|
|
31
|
+
if (!save.length)
|
|
32
|
+
return [];
|
|
33
|
+
const scope = save.length === 1 && save[0] === "*" ? `every ${request.action} request` : `requests matching ${save.join(", ")}`;
|
|
34
|
+
return [`- always: allow it, and from now on ${scope} in this project`];
|
|
35
|
+
}
|
|
36
|
+
/** How a notice names where the asking session came from, to the session at the top of its lineage. */
|
|
37
|
+
export const origin = (startedBy) => startedBy ? `which ${startedBy} started with courier_spawn, a session started from yours,` : "which you started with courier_spawn,";
|
|
38
|
+
/** For a notice that a session's turn ended without it finishing. */
|
|
39
|
+
export const STAYS_QUIET = "and then it does not report back on its own: if it stays quiet, message it with courier_send to have it carry on.";
|
|
40
|
+
/**
|
|
41
|
+
* What the session at the top is told. `startedBy` names the session that started the asking one
|
|
42
|
+
* when that is not the top session itself, but one started from it.
|
|
43
|
+
*/
|
|
44
|
+
export function permissionNotice(title, request, startedBy, native = false) {
|
|
45
|
+
const resources = request.resources.slice(0, MAX_RESOURCES).map((resource) => `- ${clip(resource)}`);
|
|
46
|
+
if (request.resources.length > MAX_RESOURCES)
|
|
47
|
+
resources.push(`- and ${request.resources.length - MAX_RESOURCES} more`);
|
|
48
|
+
return [
|
|
49
|
+
`This session, "${title}", ${origin(startedBy)} is waiting for permission and does nothing until it is answered.`,
|
|
50
|
+
`It asks for: ${request.action}`,
|
|
51
|
+
...(resources.length ? ["On:", ...resources] : []),
|
|
52
|
+
...(request.message ? [`Note: ${request.message}`] : []),
|
|
53
|
+
"",
|
|
54
|
+
...(native ? NATIVE_PERMISSION : PERMISSION_ASK),
|
|
55
|
+
"- once: allow this request only",
|
|
56
|
+
...alwaysChoice(request),
|
|
57
|
+
"- reject: refuse it, with a reason if they give one; the session's call fails and it carries on",
|
|
58
|
+
`When they have chosen, call courier_answer with sessionID "${request.sessionID}", requestID "${request.id}", reply set to their choice and, with reject, message set to their reason.`,
|
|
59
|
+
].join("\n");
|
|
60
|
+
}
|
|
61
|
+
const PERMISSION_ASK = [
|
|
62
|
+
"Do not decide this yourself. Ask the person you are working with (with your question tool, if you have one), offering exactly these choices:",
|
|
63
|
+
];
|
|
64
|
+
/** For a native child's request, which OpenCode shows the person itself, in the top session's view. */
|
|
65
|
+
const NATIVE_PERMISSION = [
|
|
66
|
+
"OpenCode already shows this request to the person you are working with, as its own permission prompt in your session (and in the paired app's request dock), where they answer it; you will be told once it is answered.",
|
|
67
|
+
"Do not ask them yourself, with your question tool or in your reply, and do not decide it: at most tell them in one line that this session waits on their permission, then end your turn.",
|
|
68
|
+
"Only if they ask you to answer it for them, these are the choices:",
|
|
69
|
+
];
|
|
70
|
+
/** What the session told about a permission request is told once it is answered without courier_answer. */
|
|
71
|
+
export function permissionSettledNotice(title, requestID, reply) {
|
|
72
|
+
return [
|
|
73
|
+
`The permission request ${requestID} of this session, "${title}", has been answered (${reply}) without courier_answer, so it no longer waits on you.`,
|
|
74
|
+
"If you asked someone about it, tell them it is settled; there is nothing to pass on.",
|
|
75
|
+
...(reply === "reject"
|
|
76
|
+
? [`A refusal without a reason ends the session's turn, ${STAYS_QUIET}`]
|
|
77
|
+
: []),
|
|
78
|
+
].join("\n");
|
|
79
|
+
}
|
|
80
|
+
// Forms.
|
|
81
|
+
/** The `metadata.kind` of the forms OpenCode's web search asks its provider with. */
|
|
82
|
+
export const WEBSEARCH_FORM = "websearch.provider";
|
|
83
|
+
/** A form's `metadata.kind`, when it has one that can go in a notice's envelope. */
|
|
84
|
+
export const kindOf = (form) => {
|
|
85
|
+
const kind = form.metadata?.kind;
|
|
86
|
+
return typeof kind === "string" && /^[\w.-]{1,60}$/.test(kind) ? kind : undefined;
|
|
87
|
+
};
|
|
88
|
+
function fieldLines(field) {
|
|
89
|
+
const label = [field.title, field.description].filter(Boolean).join(": ") || field.key;
|
|
90
|
+
const options = (field.options ?? []).slice(0, MAX_RESOURCES).map((option) => ` - ${clip(option.label)}`);
|
|
91
|
+
if ((field.options?.length ?? 0) > MAX_RESOURCES)
|
|
92
|
+
options.push(` - and ${field.options.length - MAX_RESOURCES} more`);
|
|
93
|
+
let kind = ` (${field.type})`;
|
|
94
|
+
if (field.type === "external" && field.url)
|
|
95
|
+
kind = `, opens ${clip(field.url)}`;
|
|
96
|
+
else if (field.options?.length)
|
|
97
|
+
kind = "";
|
|
98
|
+
const when = field.when?.length ? " (only for some earlier answers)" : "";
|
|
99
|
+
return [`- ${clip(label)}${kind}${when}`, ...options];
|
|
100
|
+
}
|
|
101
|
+
/** The fields the person sees, as notice lines. */
|
|
102
|
+
function formLines(fields) {
|
|
103
|
+
const shown = fields.filter((field) => !field.hidden);
|
|
104
|
+
const lines = shown.slice(0, MAX_RESOURCES).flatMap(fieldLines);
|
|
105
|
+
if (shown.length > MAX_RESOURCES)
|
|
106
|
+
lines.push(`- and ${shown.length - MAX_RESOURCES} more fields`);
|
|
107
|
+
return lines;
|
|
108
|
+
}
|
|
109
|
+
const WEBSEARCH_NOTE = [
|
|
110
|
+
"This is OpenCode asking whether to search the web, and through which provider.",
|
|
111
|
+
'It waits at most a minute; then the session\'s search fails with "Web search cancelled" and it carries on without it.',
|
|
112
|
+
"The choice is kept for every session: once it is made, in any session, no session is asked again.",
|
|
113
|
+
'Besides answering the form, the person can make it by running a web search in their own session, or under OpenCode\'s "Third-party search" setting.',
|
|
114
|
+
];
|
|
115
|
+
/**
|
|
116
|
+
* Where the person answers a form: in its own session, or, for a native child, in the view of `shownIn`, the
|
|
117
|
+
* top of its native tree, where OpenCode lists the asks of the sessions under it; `top` is the session told.
|
|
118
|
+
*/
|
|
119
|
+
function formPlace(sessionID, shownIn, top) {
|
|
120
|
+
if (shownIn === sessionID)
|
|
121
|
+
return { where: `in session ${sessionID} itself`, which: "that session" };
|
|
122
|
+
const lists = "where OpenCode shows the forms of the sessions under it";
|
|
123
|
+
if (shownIn === top)
|
|
124
|
+
return { where: `in your own session, ${lists}`, which: "your session" };
|
|
125
|
+
return { where: `in session ${shownIn}, ${lists}`, which: `session ${shownIn}` };
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* What the session at the top is told about a form OpenCode shows in a session started with
|
|
129
|
+
* courier_spawn, which the plugin cannot pass on or answer: only the person can, where OpenCode shows it.
|
|
130
|
+
*/
|
|
131
|
+
export function formNotice(title, form, startedBy, shownIn = form.sessionID, top = "") {
|
|
132
|
+
const { where, which } = formPlace(form.sessionID, shownIn, top);
|
|
133
|
+
return [
|
|
134
|
+
`This session, "${title}", ${origin(startedBy)} shows a form, "${clip(form.title)}", and waits until it is answered.`,
|
|
135
|
+
...formLines(form.fields),
|
|
136
|
+
...(kindOf(form) === WEBSEARCH_FORM ? ["", ...WEBSEARCH_NOTE] : []),
|
|
137
|
+
"",
|
|
138
|
+
`Neither you nor courier_answer can answer it: only the person you are working with can, ${where}.`,
|
|
139
|
+
`Do not choose for them. Tell them ${which} shows this form and waits on them, then carry on.`,
|
|
140
|
+
].join("\n");
|
|
141
|
+
}
|
|
142
|
+
/** What the session told about a form is told once it is answered or withdrawn. */
|
|
143
|
+
export function formSettledNotice(title, formID, settled) {
|
|
144
|
+
return [
|
|
145
|
+
settled === "answered"
|
|
146
|
+
? `The form ${formID} of this session, "${title}", has been answered, so it no longer waits on it.`
|
|
147
|
+
: `The form ${formID} of this session, "${title}", has been withdrawn unanswered: dismissed, given up on or cut off, so it no longer waits on it.`,
|
|
148
|
+
"If you told someone about it, tell them it is settled; there is nothing to pass on.",
|
|
149
|
+
].join("\n");
|
|
150
|
+
}
|
|
151
|
+
// Questions.
|
|
152
|
+
/** What OpenCode's question tool tells the asking model once it has the answers. */
|
|
153
|
+
export function answeredText(questions, answers) {
|
|
154
|
+
const formatted = questions
|
|
155
|
+
.map((prompt, index) => `"${prompt.question}"="${answers[index]?.length ? answers[index].join(", ") : "Unanswered"}"`)
|
|
156
|
+
.join(", ");
|
|
157
|
+
return `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`;
|
|
158
|
+
}
|
|
159
|
+
const describeOption = (option) => option.description ? ` - ${option.label}: ${option.description}` : ` - ${option.label}`;
|
|
160
|
+
const describeQuestions = (questions) => questions.flatMap((prompt, index) => [
|
|
161
|
+
`${index + 1}. ${prompt.header}: ${prompt.question} (${prompt.multiple ? "any of" : "one of"}, or an answer of their own:)`,
|
|
162
|
+
...prompt.options.map(describeOption),
|
|
163
|
+
]);
|
|
164
|
+
const CUT_OFF = {
|
|
165
|
+
stopped: "its turn was stopped (interrupted, or ended by OpenCode after an hour without activity)",
|
|
166
|
+
restarted: "OpenCode restarted, or closed the session's project",
|
|
167
|
+
};
|
|
168
|
+
/** What the top session is told about a question, when it is asked or after its call was cut off. */
|
|
169
|
+
export function questionNotice(asked, cutOff) {
|
|
170
|
+
return [
|
|
171
|
+
cutOff
|
|
172
|
+
? `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.`
|
|
173
|
+
: `This session, "${asked.title}", ${origin(asked.startedBy)} asks the question below and waits for the answer.`,
|
|
174
|
+
...describeQuestions(asked.questions),
|
|
175
|
+
"",
|
|
176
|
+
"Do not answer it yourself. Ask the person you are working with, using your question tool with exactly these questions:",
|
|
177
|
+
JSON.stringify({ questions: asked.questions }),
|
|
178
|
+
`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).`,
|
|
179
|
+
].join("\n");
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* What the top session is told about a native child's question, which OpenCode shows the person in the
|
|
183
|
+
* top session's own view: they answer it there, so the top session does not ask them again.
|
|
184
|
+
*/
|
|
185
|
+
export function nativeQuestionNotice(asked) {
|
|
186
|
+
return [
|
|
187
|
+
`This session, "${asked.title}", ${origin(asked.startedBy)} asks the question below and waits for the answer.`,
|
|
188
|
+
...describeQuestions(asked.questions),
|
|
189
|
+
"",
|
|
190
|
+
"OpenCode shows this question to the person you are working with in your own session, and they answer it there; you will be told once it is answered.",
|
|
191
|
+
"Do not ask them yourself, with your question tool or in your reply, and do not answer it: at most tell them in one line that this session's question waits for them, then end your turn.",
|
|
192
|
+
`If they give you the 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).`,
|
|
193
|
+
].join("\n");
|
|
194
|
+
}
|
|
195
|
+
/** How a question was settled without the top session, as its settled notice puts it. */
|
|
196
|
+
function settledHow(asked, how) {
|
|
197
|
+
const where = asked.native || (how.by === "child" && how.via === "inbox") ? "by the person" : "in its own session";
|
|
198
|
+
if (how.by === "child")
|
|
199
|
+
return `answered ${where} (${answeredText(asked.questions, how.answers)})`;
|
|
200
|
+
if (how.by === "dismissed")
|
|
201
|
+
return `dismissed ${where}, without an answer`;
|
|
202
|
+
return `ended without an answer: its question call failed (${how.error})`;
|
|
203
|
+
}
|
|
204
|
+
/** What the top session is told about a question settled without it. */
|
|
205
|
+
export function questionSettledNotice(asked, how) {
|
|
206
|
+
const what = settledHow(asked, how);
|
|
207
|
+
return [
|
|
208
|
+
`The question ${asked.requestID} of this session, "${asked.title}", was ${what}, so it no longer waits on you.`,
|
|
209
|
+
"If you asked someone about it, tell them it is settled; there is nothing to pass on.",
|
|
210
|
+
...(how.by === "child"
|
|
211
|
+
? []
|
|
212
|
+
: [`That ends the session's turn, ${STAYS_QUIET}`]),
|
|
213
|
+
].join("\n");
|
|
214
|
+
}
|
|
215
|
+
/** The result of a spawned session's question call that the person dismissed through the top session. */
|
|
216
|
+
export 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.";
|
|
217
|
+
/** The message a spawned session whose question call was cut off gets, with the top session's outcome. */
|
|
218
|
+
export function cutOffAnswer(asked, outcome) {
|
|
219
|
+
return "answers" in outcome
|
|
220
|
+
? `Your question ${asked.requestID} was cut off before it was answered; here is the answer. ${answeredText(asked.questions, outcome.answers)}`
|
|
221
|
+
: `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.`;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* What a top session's question call that no waiting question was linked to adds to its result, when
|
|
225
|
+
* questions with the same choices wait: `waiting` lists them.
|
|
226
|
+
*/
|
|
227
|
+
export function unlinkedNote(waiting) {
|
|
228
|
+
const which = waiting.map((question) => `session ${question.sessionID} (requestID "${question.requestID}")`).join(", ");
|
|
229
|
+
return `If you asked this for ${which}, these answers were not passed on by themselves: pass them on with courier_answer.`;
|
|
230
|
+
}
|
|
231
|
+
/** How a linked call's question was settled without it, as its withdrawn result puts it. */
|
|
232
|
+
function withdrawnHow(linked, how) {
|
|
233
|
+
switch (how.by) {
|
|
234
|
+
case "top":
|
|
235
|
+
return "answers" in how.outcome
|
|
236
|
+
? `already answered with courier_answer (${answeredText(linked.questions, how.outcome.answers)})`
|
|
237
|
+
: "already dismissed in your session, so it carries on without the answers";
|
|
238
|
+
case "child":
|
|
239
|
+
return `answered ${how.via === "inbox" ? "by the person" : "in its own session"} (${answeredText(linked.questions, how.answers)})`;
|
|
240
|
+
case "dismissed":
|
|
241
|
+
return "dismissed in its own session, which ends its turn; message it with courier_send if it should carry on";
|
|
242
|
+
case "failed":
|
|
243
|
+
return `ended: its question call failed (${how.error})`;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/** The result of a linked call withdrawn because its question was settled without it. */
|
|
247
|
+
export function withdrawnText(linked, how) {
|
|
248
|
+
return `Session ${linked.sessionID} no longer waits on this question: it was ${withdrawnHow(linked, how)}. There is nothing to pass on; tell the person it is settled.`;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* What a linked call adds to the person's answers about passing them on: `passed` is "result",
|
|
252
|
+
* "message", undefined when it no longer waited, or the error as `error: <message>`.
|
|
253
|
+
*/
|
|
254
|
+
export function passedNote(linked, passed) {
|
|
255
|
+
if (passed === "result")
|
|
256
|
+
return `These answers were passed on to session ${linked.sessionID}, which carries on with them; do not call courier_answer for ${linked.requestID}. ${END_TURN}`;
|
|
257
|
+
if (passed === "message")
|
|
258
|
+
return `These answers were passed on to session ${linked.sessionID} as a message, since its question had been cut off; it carries on with them. Do not call courier_answer for ${linked.requestID}. ${END_TURN}`;
|
|
259
|
+
if (passed === undefined)
|
|
260
|
+
return `Session ${linked.sessionID} no longer waits on this question: it had already been answered or settled, so these answers were not passed on; tell the person so.`;
|
|
261
|
+
return `Passing these answers on to session ${linked.sessionID} failed (${passed.slice(7)}); call courier_answer with requestID "${linked.requestID}" to pass them on.`;
|
|
262
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import { type ToolEditor } from "../kernel/tool.js";
|
|
3
|
+
import { type AnswerPorts, type Waiting } from "./permission.js";
|
|
4
|
+
import { type QuestionPorts } from "./question/index.js";
|
|
5
|
+
export declare const AnswerInput: Schema.Struct<{
|
|
6
|
+
readonly sessionID: Schema.String;
|
|
7
|
+
readonly requestID: Schema.String;
|
|
8
|
+
readonly reply: Schema.optional<Schema.Literals<readonly ["once", "always", "reject"]>>;
|
|
9
|
+
readonly message: Schema.optional<Schema.String>;
|
|
10
|
+
readonly answers: Schema.optional<Schema.$Array<Schema.Union<readonly [Schema.String, Schema.$Array<Schema.String>]>>>;
|
|
11
|
+
}>;
|
|
12
|
+
/** What the person's tool runs on. */
|
|
13
|
+
export interface PersonToolPorts {
|
|
14
|
+
readonly answer: AnswerPorts;
|
|
15
|
+
readonly questions: QuestionPorts;
|
|
16
|
+
/** The permission requests sessions were told about and have not answered. */
|
|
17
|
+
readonly waiting: Waiting;
|
|
18
|
+
}
|
|
19
|
+
/** Adds `courier_answer`. */
|
|
20
|
+
export declare function addPersonTools(tools: ToolEditor, ports: PersonToolPorts): void;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import { rethrow } from "../kernel/tool.js";
|
|
3
|
+
import { answer, REPLIES } from "./permission.js";
|
|
4
|
+
import { answerQuestion, isQuestion } from "./question/index.js";
|
|
5
|
+
import { answerText } from "./text.js";
|
|
6
|
+
export const AnswerInput = Schema.Struct({
|
|
7
|
+
sessionID: Schema.String.annotate({ description: "The session you started that is waiting, as its notice names it." }),
|
|
8
|
+
requestID: Schema.String.annotate({ description: "The request id from the notice." }),
|
|
9
|
+
reply: Schema.optional(Schema.Literals(REPLIES).annotate({
|
|
10
|
+
description: "For a permission request: the choice the person made, once, always (only when the notice offers it) or reject.",
|
|
11
|
+
})),
|
|
12
|
+
message: Schema.optional(Schema.String.annotate({ description: "For a permission request, with reject: the person's reason, passed on with the refusal." })),
|
|
13
|
+
answers: Schema.optional(Schema.Array(Schema.Union([Schema.String, Schema.Array(Schema.String)])).annotate({
|
|
14
|
+
description: "For a question: one entry per question, in order, each the label the person chose or the text they gave, " +
|
|
15
|
+
"or a list of labels where the question allows several.",
|
|
16
|
+
})),
|
|
17
|
+
});
|
|
18
|
+
// One tool answers both: a question's id is the plugin's own, a permission request's OpenCode's.
|
|
19
|
+
async function answerRequest(ports, callerID, input) {
|
|
20
|
+
const { sessionID, requestID } = input;
|
|
21
|
+
if (isQuestion(requestID)) {
|
|
22
|
+
if (input.reply !== undefined || input.message !== undefined)
|
|
23
|
+
throw new Error(`${requestID} is a question: pass the person's answers in answers, not reply or message.`);
|
|
24
|
+
if (input.answers === undefined)
|
|
25
|
+
throw new Error(`${requestID} is a question: answers is required, one entry per question.`);
|
|
26
|
+
return answerQuestion(ports.questions, callerID, { sessionID, requestID, answers: input.answers });
|
|
27
|
+
}
|
|
28
|
+
if (input.answers !== undefined)
|
|
29
|
+
throw new Error(`${requestID} is a permission request: pass the person's choice in reply (once, always or reject), not answers.`);
|
|
30
|
+
if (input.reply === undefined)
|
|
31
|
+
throw new Error(`${requestID} is a permission request: reply is required, once, always or reject.`);
|
|
32
|
+
return answer(ports.answer, ports.waiting, callerID, {
|
|
33
|
+
sessionID,
|
|
34
|
+
requestID,
|
|
35
|
+
reply: input.reply,
|
|
36
|
+
...(input.message !== undefined ? { message: input.message } : {}),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/** Adds `courier_answer`. */
|
|
40
|
+
export function addPersonTools(tools, ports) {
|
|
41
|
+
tools.add({
|
|
42
|
+
name: "courier_answer",
|
|
43
|
+
options: { codemode: false },
|
|
44
|
+
description: "Pass on the answer to a permission request or a question that a session you started with courier_spawn (or " +
|
|
45
|
+
"one started from it) waits on, after a notice from it named the request. The answer is the person's, not yours: " +
|
|
46
|
+
"first ask the person you are working with, offering the choices the notice lists, then call this with what " +
|
|
47
|
+
"they chose: reply for a permission request, answers for a question. Never choose for them. The session " +
|
|
48
|
+
"carries on once it has the answer.",
|
|
49
|
+
input: AnswerInput,
|
|
50
|
+
execute: async (input, context) => {
|
|
51
|
+
const result = await answerRequest(ports, context.sessionID, input).catch(rethrow("courier_answer"));
|
|
52
|
+
return { content: answerText(result), metadata: result };
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin/tui";
|
|
2
|
+
import type { AttentionSoundName } from "@opencode/plugin/tui/context";
|
|
3
|
+
import { type AskedPermission, type AskedQuestion, type Asks, type TreeSession } from "./rpc.js";
|
|
4
|
+
type Context = Plugin.Context;
|
|
5
|
+
type FormInfo = NonNullable<ReturnType<Context["data"]["session"]["form"]["list"]>>[number];
|
|
6
|
+
type FormField = FormInfo["fields"][number];
|
|
7
|
+
type FormAnswer = Parameters<Context["data"]["session"]["form"]["reply"]>[0]["answer"];
|
|
8
|
+
/** Something a session of the tree waits on the person for; `key` is unique across kinds. */
|
|
9
|
+
export type Ask = {
|
|
10
|
+
readonly key: string;
|
|
11
|
+
readonly session: TreeSession;
|
|
12
|
+
} & ({
|
|
13
|
+
readonly kind: "permission";
|
|
14
|
+
readonly request: AskedPermission;
|
|
15
|
+
} | {
|
|
16
|
+
readonly kind: "question";
|
|
17
|
+
readonly question: AskedQuestion;
|
|
18
|
+
} | {
|
|
19
|
+
readonly kind: "form";
|
|
20
|
+
readonly form: FormInfo;
|
|
21
|
+
});
|
|
22
|
+
/** The command that opens the inbox, from its key, its slash command, the palette or the footer. */
|
|
23
|
+
export declare const INBOX_COMMAND = "courier.asks";
|
|
24
|
+
/** How long after the first of a burst of asks the inbox reads the tree again, to sound the new ones. */
|
|
25
|
+
export declare const SETTLE_MS = 250;
|
|
26
|
+
/** The asks of a tree in the order the inbox lists them: by the tree's order, each session's permissions, questions, then forms. */
|
|
27
|
+
export declare function asksOf(asks: Asks, forms: ReadonlyMap<string, ReadonlyArray<FormInfo>>): Ask[];
|
|
28
|
+
/** Where an ask comes from: the titles from the top session down to the asking one, as "root › child › grandchild". */
|
|
29
|
+
export declare function pathOf(asks: Asks, sessionID: string, topTitle: string): string;
|
|
30
|
+
/** What an ask is, in one line. */
|
|
31
|
+
export declare function askTitle(ask: Ask): string;
|
|
32
|
+
/** The line under an ask: its depth and group, and that a question's call was cut off. */
|
|
33
|
+
export declare function askFooter(ask: Ask): string;
|
|
34
|
+
/** The sound an ask arriving plays: a permission request's, or a question's for questions and forms. */
|
|
35
|
+
export declare const soundOf: (ask: Ask) => AttentionSoundName;
|
|
36
|
+
/** Whether a form field is shown, given the answers so far: hidden ones never, conditional ones when their conditions hold. */
|
|
37
|
+
export declare function shown(field: FormField, answers: FormAnswer): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Adds the asks inbox: what the open session's courier tree waits on the person for, read through the person's RPC and
|
|
40
|
+
* answered in dialogs, opened by its command; a sound for each new ask. Returns its opener and its cleanup.
|
|
41
|
+
*/
|
|
42
|
+
export declare function addAsksInbox(ctx: Context): {
|
|
43
|
+
open: (sessionID?: string | undefined) => Promise<void>;
|
|
44
|
+
cleanup: () => void;
|
|
45
|
+
};
|
|
46
|
+
export {};
|
|
@@ -6,3 +6,7 @@
|
|
|
6
6
|
export declare function addBounded<T>(set: Set<T>, value: T, max: number): boolean;
|
|
7
7
|
/** Sets an entry of a bounded map, dropping the oldest entry past `max`; an existing key keeps its place. */
|
|
8
8
|
export declare function setBounded<K, V>(map: Map<K, V>, key: K, value: V, max: number): void;
|
|
9
|
+
/** How many handled event ids, and waiting or answered requests and forms, are remembered. */
|
|
10
|
+
export declare const SEEN_MAX = 1000;
|
|
11
|
+
/** Claims a value in one of the shared sets; false when it was claimed already. */
|
|
12
|
+
export declare const claim: (set: Set<string>, value: string) => boolean;
|
|
@@ -17,3 +17,7 @@ export function setBounded(map, key, value, max) {
|
|
|
17
17
|
if (map.size > max)
|
|
18
18
|
map.delete(map.keys().next().value);
|
|
19
19
|
}
|
|
20
|
+
/** How many handled event ids, and waiting or answered requests and forms, are remembered. */
|
|
21
|
+
export const SEEN_MAX = 1_000;
|
|
22
|
+
/** Claims a value in one of the shared sets; false when it was claimed already. */
|
|
23
|
+
export const claim = (set, value) => addBounded(set, value, SEEN_MAX);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin";
|
|
2
|
+
import type { Hub } from "./hub.js";
|
|
3
|
+
/** One of OpenCode's events, as a subscription yields it. */
|
|
4
|
+
export interface HostEvent {
|
|
5
|
+
readonly type: string;
|
|
6
|
+
readonly data?: unknown;
|
|
7
|
+
}
|
|
8
|
+
/** What following OpenCode's events needs of a member's ports: the stream, and a log. */
|
|
9
|
+
export interface EventPorts {
|
|
10
|
+
readonly event: Pick<Plugin.Context["event"], "subscribe">;
|
|
11
|
+
readonly log: (message: string) => void;
|
|
12
|
+
}
|
|
13
|
+
/** What a module does with OpenCode's events, by phase; each phase runs the modules in list order. */
|
|
14
|
+
export interface EventModule<P extends EventPorts> {
|
|
15
|
+
/** For each event; the first to throw ends the phase. */
|
|
16
|
+
readonly handle?: (ports: P, event: HostEvent) => Promise<unknown> | undefined;
|
|
17
|
+
/** For each event, once the `handle` phase has ended, however it did; the first to throw ends this one too. */
|
|
18
|
+
readonly after?: (ports: P, event: HostEvent) => Promise<void> | undefined;
|
|
19
|
+
/** As a subscription starts; returns what to run at its first event, if anything. */
|
|
20
|
+
readonly followed?: () => (() => void) | undefined;
|
|
21
|
+
/** Alongside each (re)subscription; the watcher's end waits for it. */
|
|
22
|
+
readonly subscribed?: (ports: P) => Promise<void>;
|
|
23
|
+
/** Once a subscription has ended or broken. */
|
|
24
|
+
readonly left?: () => void;
|
|
25
|
+
}
|
|
26
|
+
/** How long to wait before subscribing again after the event stream ended or broke. */
|
|
27
|
+
export declare const RESUBSCRIBE_MS = 5000;
|
|
28
|
+
/** Follows OpenCode's events through `ports`, subscribing again after `retryMs` when a subscription ends, until `signal` aborts. */
|
|
29
|
+
export declare function follow<P extends EventPorts>(ports: P, modules: readonly EventModule<P>[], signal: AbortSignal, retryMs?: number): Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* Makes the hub start its subscriptions to OpenCode's events with this copy's modules, the copy loaded
|
|
32
|
+
* last; `join` starts them. The second is a standby, connected before the first's member leaves.
|
|
33
|
+
*/
|
|
34
|
+
export declare function watchFromHub<P extends EventPorts>(hub: Pick<Hub, "subscribe">, modules: readonly EventModule<P>[], retryMs?: number): void;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/** How long to wait before subscribing again after the event stream ended or broke. */
|
|
2
|
+
export const RESUBSCRIBE_MS = 5_000;
|
|
3
|
+
const pause = (ms, signal) => new Promise((resolve) => {
|
|
4
|
+
const timer = setTimeout(done, ms);
|
|
5
|
+
function done() {
|
|
6
|
+
clearTimeout(timer);
|
|
7
|
+
signal.removeEventListener("abort", done);
|
|
8
|
+
resolve();
|
|
9
|
+
}
|
|
10
|
+
signal.addEventListener("abort", done);
|
|
11
|
+
});
|
|
12
|
+
const ENDED = { done: true, value: undefined };
|
|
13
|
+
/**
|
|
14
|
+
* `events` until it ends or `signal` aborts, the stream left open; each wait hears the abort only while
|
|
15
|
+
* it waits, so a watcher that follows events for the life of the process holds nothing per event.
|
|
16
|
+
*/
|
|
17
|
+
const until = (events, signal) => ({
|
|
18
|
+
[Symbol.asyncIterator]: () => ({
|
|
19
|
+
next: () => new Promise((resolve, reject) => {
|
|
20
|
+
if (signal.aborted)
|
|
21
|
+
return resolve(ENDED);
|
|
22
|
+
const stop = () => resolve(ENDED);
|
|
23
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
24
|
+
// Once the signal has aborted, whatever the stream ends this wait with is not wanted.
|
|
25
|
+
events.next().then((next) => {
|
|
26
|
+
signal.removeEventListener("abort", stop);
|
|
27
|
+
resolve(next);
|
|
28
|
+
}, (error) => {
|
|
29
|
+
signal.removeEventListener("abort", stop);
|
|
30
|
+
reject(error);
|
|
31
|
+
});
|
|
32
|
+
}),
|
|
33
|
+
}),
|
|
34
|
+
});
|
|
35
|
+
/** One event through the `handle` phase, then, however that ended, the `after` phase. */
|
|
36
|
+
async function handle(ports, modules, event) {
|
|
37
|
+
try {
|
|
38
|
+
for (const module of modules) {
|
|
39
|
+
const handling = module.handle?.(ports, event);
|
|
40
|
+
if (handling)
|
|
41
|
+
await handling;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
for (const module of modules) {
|
|
46
|
+
const after = module.after?.(ports, event);
|
|
47
|
+
if (after)
|
|
48
|
+
await after;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Handles one event, logging a failure rather than ending the watch. */
|
|
53
|
+
function handleLogged(ports, modules, event) {
|
|
54
|
+
return handle(ports, modules, event).catch((error) => {
|
|
55
|
+
const data = event.data;
|
|
56
|
+
const sessionID = data?.sessionID ?? data?.form?.sessionID;
|
|
57
|
+
ports.log(`courier watch: could not handle ${event.type} of ${sessionID}: ${String(error)}`);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/** Runs each module's `subscribed` alongside the new subscription, kept in `alongside` until done. */
|
|
61
|
+
function subscribed(ports, modules, alongside) {
|
|
62
|
+
for (const module of modules) {
|
|
63
|
+
if (!module.subscribed)
|
|
64
|
+
continue;
|
|
65
|
+
const running = module
|
|
66
|
+
.subscribed(ports)
|
|
67
|
+
.catch((error) => ports.log(`courier watch: ${String(error)}`))
|
|
68
|
+
.finally(() => alongside.delete(running));
|
|
69
|
+
alongside.add(running);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** One subscription, its events through `modules`' phases, until its stream ends or breaks or `signal` aborts. */
|
|
73
|
+
async function subscription(ports, modules, signal, alongside) {
|
|
74
|
+
let following = false;
|
|
75
|
+
let events;
|
|
76
|
+
try {
|
|
77
|
+
events = ports.event.subscribe({ signal })[Symbol.asyncIterator]();
|
|
78
|
+
let first = modules.flatMap((module) => module.followed?.() ?? []);
|
|
79
|
+
following = true;
|
|
80
|
+
subscribed(ports, modules, alongside);
|
|
81
|
+
for await (const event of until(events, signal)) {
|
|
82
|
+
for (const run of first)
|
|
83
|
+
run();
|
|
84
|
+
first = [];
|
|
85
|
+
await handleLogged(ports, modules, event);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (!signal.aborted)
|
|
90
|
+
ports.log(`courier watch: event stream broke: ${String(error)}`);
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
if (following)
|
|
94
|
+
for (const module of modules)
|
|
95
|
+
module.left?.();
|
|
96
|
+
// Closed without waiting: closing the adapter's stream waits on OpenCode, which may not answer
|
|
97
|
+
// while a location closes, and the leave waiting for this watcher must not wait for that too.
|
|
98
|
+
void events?.return?.().catch(() => { });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Follows OpenCode's events through `ports`, subscribing again after `retryMs` when a subscription ends, until `signal` aborts. */
|
|
102
|
+
export async function follow(ports, modules, signal, retryMs = RESUBSCRIBE_MS) {
|
|
103
|
+
const alongside = new Set();
|
|
104
|
+
while (!signal.aborted) {
|
|
105
|
+
await subscription(ports, modules, signal, alongside);
|
|
106
|
+
if (!signal.aborted)
|
|
107
|
+
await pause(retryMs, signal);
|
|
108
|
+
}
|
|
109
|
+
await Promise.allSettled(alongside);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Makes the hub start its subscriptions to OpenCode's events with this copy's modules, the copy loaded
|
|
113
|
+
* last; `join` starts them. The second is a standby, connected before the first's member leaves.
|
|
114
|
+
*/
|
|
115
|
+
export function watchFromHub(hub, modules, retryMs = RESUBSCRIBE_MS) {
|
|
116
|
+
hub.subscribe = (member) => {
|
|
117
|
+
const stop = new AbortController();
|
|
118
|
+
// A member of any copy runs them: every hub-v2 member's `watch` ports are the same shape.
|
|
119
|
+
return { member, stop, done: follow(member.watch, modules, stop.signal, retryMs) };
|
|
120
|
+
};
|
|
121
|
+
}
|