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
package/dist/index.js
CHANGED
|
@@ -3,70 +3,41 @@ import { Plugin as EffectPlugin } from "@opencode/plugin/effect";
|
|
|
3
3
|
import { fromPromise } from "@opencode/plugin/promise/adapter";
|
|
4
4
|
import { Effect } from "effect";
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import { joinRelay, noticeCutOff, pendingQuestions, relayQuestions } from "./
|
|
11
|
-
import { pruneExpired } from "./roster.js";
|
|
6
|
+
import { watchFromHub } from "./platform/events.js";
|
|
7
|
+
import { hub, openInProcess, permissions as domains, sharedState } from "./platform/hub.js";
|
|
8
|
+
import { builtVersions, versionNotice } from "./platform/version.js";
|
|
9
|
+
import { addSchedulingTools, cancelScheduled, dueJob, isScheduled } from "./scheduling/index.js";
|
|
10
|
+
import { addPersonTools, asksPermission, asksQuestion, joinRelay, noticeCutOff, pendingOf, pendingQuestions, personEvents, portsAt, registerPersonRpc, relayQuestions, } from "./person/index.js";
|
|
12
11
|
import { addSkill } from "./skill.js";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
import { addTreeTools, deliverReleased, dirtyOf, headOf, inspectWorktree, pruneExpired, readLimits, readNativeChildren, registerTreeRpc, shapeContext, together, treeEvents, waitsOn, } from "./tree/index.js";
|
|
13
|
+
import { addWebhookTools, joinReceiver, readConfig, receiving, subscribedSince, } from "./webhook/index.js";
|
|
14
|
+
/** The spawns under way in the process, outside the hub, so spawns in other locations, copies and hub versions count them. */
|
|
15
|
+
const gate = sharedState("spawning", () => ({ reserved: new Set(), turn: Promise.resolve() }));
|
|
16
|
+
/** The type of each item enqueued for a spawned session, outside the hub like the gate. */
|
|
17
|
+
const inbox = sharedState("inbox", () => new Map());
|
|
17
18
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
19
|
+
* What each tick of the scheduler does, in order, through the first loaded member, of whichever copy:
|
|
20
|
+
* due `courier_later` messages, then released join groups. Every hub-v2 member's `later` is its LaterPorts.
|
|
20
21
|
*/
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
server: (receivers.closing ?? Promise.resolve())
|
|
29
|
-
.then(() => listen(config, () => instances.values().next().value))
|
|
30
|
-
.then((server) => {
|
|
31
|
-
log(`courier webhook: listening on http://${config.host}:${server.address().port}`);
|
|
32
|
-
return server;
|
|
33
|
-
}, (error) => {
|
|
34
|
-
log(`courier webhook: cannot listen on ${config.host}:${config.port}: ${String(error)}`);
|
|
35
|
-
if (receivers.current === receiver)
|
|
36
|
-
receivers.current = undefined;
|
|
37
|
-
return undefined;
|
|
38
|
-
}),
|
|
39
|
-
};
|
|
40
|
-
receivers.current = receiver;
|
|
41
|
-
return receiver;
|
|
42
|
-
}
|
|
43
|
-
function joinReceiver(config, ports) {
|
|
44
|
-
const receiver = receivers.current ?? startReceiver(config, ports.log);
|
|
45
|
-
if (!sameSettings(receiver.config, config))
|
|
46
|
-
ports.log(`courier webhook: already running on ${receiver.config.host}:${receiver.config.port} with other settings ` +
|
|
47
|
-
"(port, host, secret or maxBytes); this location's are ignored. Set the webhook option once, in the global config.");
|
|
48
|
-
receiver.instances.add(ports);
|
|
49
|
-
return async () => {
|
|
50
|
-
receiver.instances.delete(ports);
|
|
51
|
-
if (receiver.instances.size > 0 || receivers.current !== receiver)
|
|
52
|
-
return;
|
|
53
|
-
receivers.current = undefined;
|
|
54
|
-
const closing = receiver.server.then((server) => new Promise((resolve) => {
|
|
55
|
-
if (!server)
|
|
56
|
-
return resolve();
|
|
57
|
-
server.closeAllConnections();
|
|
58
|
-
server.close(() => resolve());
|
|
59
|
-
}));
|
|
60
|
-
receivers.closing = closing;
|
|
61
|
-
await closing;
|
|
62
|
-
if (receivers.closing === closing)
|
|
63
|
-
receivers.closing = undefined;
|
|
64
|
-
};
|
|
65
|
-
}
|
|
22
|
+
const jobs = [
|
|
23
|
+
dueJob,
|
|
24
|
+
(owner, claimed) => deliverReleased(owner.later, claimed),
|
|
25
|
+
];
|
|
26
|
+
const { join, track, nudge } = openInProcess(jobs);
|
|
27
|
+
/** The permission domains of the loaded instances: every member's is its location's, whichever copy joined it. */
|
|
28
|
+
const permissions = domains;
|
|
66
29
|
// One set of handled events and of told permission requests for every instance in the process, since
|
|
67
30
|
// several subscriptions and copies see the same events. The scheduler's claim set is the hub's own.
|
|
68
31
|
const { watched } = hub;
|
|
69
|
-
const watchState = {
|
|
32
|
+
const watchState = { seen: watched.seen, inbox };
|
|
33
|
+
const personState = { ...watched, forms: hub.forms };
|
|
34
|
+
/**
|
|
35
|
+
* What a session waits on outside the tree, for the silent-turn rule, cheapest first: a request of its
|
|
36
|
+
* own to the person, then a scheduled message or a webhook it subscribed to since its last prompt.
|
|
37
|
+
*/
|
|
38
|
+
const outside = waitsOn(asksPermission, asksQuestion, together(isScheduled, subscribedSince));
|
|
39
|
+
/** What the hub's subscriptions do with each event of OpenCode's, module by module, in this order. */
|
|
40
|
+
const eventModules = [treeEvents(watchState, outside), personEvents(personState)];
|
|
70
41
|
/**
|
|
71
42
|
* The courier tools and webhook receiver, as a promise plugin joining the hub that runs the scheduler
|
|
72
43
|
* and the watcher; its member carries the location by which the question relay finds its ports.
|
|
@@ -95,11 +66,13 @@ export const courier = () => Plugin.define({
|
|
|
95
66
|
log,
|
|
96
67
|
};
|
|
97
68
|
const { limits, problems } = readLimits(ctx.options);
|
|
98
|
-
|
|
69
|
+
const { nativeChildren, problems: nativeProblems } = readNativeChildren(ctx.options);
|
|
70
|
+
for (const problem of [...problems, ...nativeProblems])
|
|
99
71
|
log(`courier: ${problem}`);
|
|
100
72
|
const roles = new Map();
|
|
101
73
|
const ports = {
|
|
102
74
|
limits,
|
|
75
|
+
nativeChildren,
|
|
103
76
|
gate,
|
|
104
77
|
roles,
|
|
105
78
|
nudge,
|
|
@@ -138,19 +111,20 @@ export const courier = () => Plugin.define({
|
|
|
138
111
|
catch (error) {
|
|
139
112
|
later.log(`courier webhook: not started: ${error instanceof Error ? error.message : String(error)}`);
|
|
140
113
|
}
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
waiting: watched.waiting,
|
|
150
|
-
receiving: async () => (await receivers.current?.server) !== undefined,
|
|
151
|
-
};
|
|
152
|
-
await ctx.tool.transform((tools) => addTools(tools, toolPorts));
|
|
114
|
+
const stopPorts = { courier: ports, cancelScheduled: (sessionID) => cancelScheduled(later, sessionID), cleanup: cleanupPorts };
|
|
115
|
+
// The tools in a fixed order: the tree's, then the person's, the scheduled messages' and the webhooks'.
|
|
116
|
+
await ctx.tool.transform((tools) => {
|
|
117
|
+
addTreeTools(tools, { courier: ports, cleanup: cleanupPorts, stop: stopPorts });
|
|
118
|
+
addPersonTools(tools, { answer: answerPorts, questions: questionPorts, waiting: watched.waiting });
|
|
119
|
+
addSchedulingTools(tools, later);
|
|
120
|
+
addWebhookTools(tools, { hooks, receiving });
|
|
121
|
+
});
|
|
153
122
|
await ctx.skill.transform(addSkill);
|
|
123
|
+
// What the TUI part reads and its stop; a host that cannot register it still gets the rest, the tools included.
|
|
124
|
+
await Promise.resolve()
|
|
125
|
+
.then(() => registerTreeRpc(ctx.rpc, stopPorts))
|
|
126
|
+
.then(() => registerPersonRpc(ctx.rpc, { questions: questionPorts, permissions, maxTotal: limits.maxTotal }))
|
|
127
|
+
.catch((error) => log(`courier rpc: not registered, the TUI part shows nothing and cannot stop: ${String(error)}`));
|
|
154
128
|
await ctx.session.hook("context", (event) => shapeContext({ storage: ctx.storage, limits, roles, log }, event));
|
|
155
129
|
const pruned = pruneExpired(ctx.storage, Date.now()).catch((error) => log(`courier roster prune: ${String(error)}`));
|
|
156
130
|
const leaveRelay = joinRelay(questionPorts);
|
|
@@ -164,8 +138,8 @@ export const courier = () => Plugin.define({
|
|
|
164
138
|
log: later.log,
|
|
165
139
|
nudge,
|
|
166
140
|
};
|
|
167
|
-
// The hub's subscriptions to OpenCode's events, one and a standby, start with this copy's
|
|
168
|
-
watchFromHub(hub,
|
|
141
|
+
// The hub's subscriptions to OpenCode's events, one and a standby, start with this copy's modules.
|
|
142
|
+
watchFromHub(hub, eventModules);
|
|
169
143
|
const member = {
|
|
170
144
|
directory: ctx.location.directory,
|
|
171
145
|
location: ctx.location,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every module of the plugin shares of the protocol with the models: the envelope a message arrives in,
|
|
3
|
+
* the phrase that ends a turn, the statuses of a report, the split line and the skill's id, and how outside
|
|
4
|
+
* text is clipped and defused.
|
|
5
|
+
*/
|
|
6
|
+
/** Closes a tool result after which the caller most likely has nothing left to do. */
|
|
7
|
+
export declare const END_TURN = "If nothing else is left to do now, end your turn by replying without calling more tools.";
|
|
8
|
+
/** The statuses a report carries: the task is done, partly done, waits on the parent, or failed. */
|
|
9
|
+
export declare const STATUSES: readonly ["done", "partial", "blocked", "failed"];
|
|
10
|
+
export type Status = (typeof STATUSES)[number];
|
|
11
|
+
export declare const isStatus: (value: unknown) => value is Status;
|
|
12
|
+
/** The one line a session states its split decision in, so a transcript tells a decision from one never made. */
|
|
13
|
+
export declare const SPLIT_LINE = "split: no, because \u2026; considered: <the parts you weighed> or split: <n> parts: \u2026 (one phrase per part)";
|
|
14
|
+
export declare const SKILL_ID = "courier-orchestrate";
|
|
15
|
+
export declare function envelope(from: string, message: string, attributes?: Record<string, string>): string;
|
|
16
|
+
export declare function clipText(text: string, max?: number): string;
|
|
17
|
+
/** Defuses `<courier` and `</courier>` in outside text, so it cannot close the envelope or forge another. */
|
|
18
|
+
export declare function defuse(text: string): string;
|
|
19
|
+
/** A readable message for a failed courier call; OpenCode's own errors can carry an empty message. */
|
|
20
|
+
export declare function describeFailure(tool: string, error: unknown): Error;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every module of the plugin shares of the protocol with the models: the envelope a message arrives in,
|
|
3
|
+
* the phrase that ends a turn, the statuses of a report, the split line and the skill's id, and how outside
|
|
4
|
+
* text is clipped and defused.
|
|
5
|
+
*/
|
|
6
|
+
/** Closes a tool result after which the caller most likely has nothing left to do. */
|
|
7
|
+
export const END_TURN = "If nothing else is left to do now, end your turn by replying without calling more tools.";
|
|
8
|
+
/** The statuses a report carries: the task is done, partly done, waits on the parent, or failed. */
|
|
9
|
+
export const STATUSES = ["done", "partial", "blocked", "failed"];
|
|
10
|
+
export const isStatus = (value) => typeof value === "string" && STATUSES.includes(value);
|
|
11
|
+
/** The one line a session states its split decision in, so a transcript tells a decision from one never made. */
|
|
12
|
+
export const SPLIT_LINE = "split: no, because …; considered: <the parts you weighed> or split: <n> parts: … (one phrase per part)";
|
|
13
|
+
export const SKILL_ID = "courier-orchestrate";
|
|
14
|
+
export function envelope(from, message, attributes = {}) {
|
|
15
|
+
const extra = Object.entries(attributes)
|
|
16
|
+
.map(([name, value]) => ` ${name}="${value}"`)
|
|
17
|
+
.join("");
|
|
18
|
+
return `<courier from="${from}"${extra}>\n${message}\n</courier>`;
|
|
19
|
+
}
|
|
20
|
+
/** Longest piece of free text (a review body, a generic payload) copied into a delivered message. */
|
|
21
|
+
const MAX_TEXT = 1500;
|
|
22
|
+
export function clipText(text, max = MAX_TEXT) {
|
|
23
|
+
return text.length > max ? `${text.slice(0, max)}… [${text.length - max} more characters]` : text;
|
|
24
|
+
}
|
|
25
|
+
/** Defuses `<courier` and `</courier>` in outside text, so it cannot close the envelope or forge another. */
|
|
26
|
+
export function defuse(text) {
|
|
27
|
+
return text.replace(/<(\/?)(courier)/gi, "<$1$2");
|
|
28
|
+
}
|
|
29
|
+
/** A readable message for a failed courier call; OpenCode's own errors can carry an empty message. */
|
|
30
|
+
export function describeFailure(tool, error) {
|
|
31
|
+
const tagged = error;
|
|
32
|
+
const message = (typeof tagged?.message === "string" && tagged.message) ||
|
|
33
|
+
[tagged?._tag, tagged?.sessionID].filter((item) => typeof item === "string").join(" ") ||
|
|
34
|
+
String(error);
|
|
35
|
+
return new Error(`${tool} failed: ${message}`);
|
|
36
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin";
|
|
2
|
+
/** The editor OpenCode hands a tool transform. */
|
|
3
|
+
export type ToolEditor = Parameters<Parameters<Plugin.Context["tool"]["transform"]>[0]>[0];
|
|
4
|
+
/** A `.catch` handler that rethrows a failure of `tool` as the error the model reads. */
|
|
5
|
+
export declare const rethrow: (tool: string) => (error: unknown) => never;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { EventModule } from "../platform/events.js";
|
|
2
|
+
import { type PermissionAsked, type PermissionReplied } from "./permission.js";
|
|
3
|
+
import { type PersonPorts, type PersonState } from "./ports.js";
|
|
4
|
+
/**
|
|
5
|
+
* Tells the session at the top, the parent of a child or the session a chain of children started
|
|
6
|
+
* from, what a waiting child asks and which answers there are, waking it. Returns the sessions told.
|
|
7
|
+
*/
|
|
8
|
+
export declare function reportAsked(ports: PersonPorts, state: PersonState, event: PermissionAsked): Promise<string[]>;
|
|
9
|
+
/**
|
|
10
|
+
* Tells the session told about a request that it was answered some other way, so it does not pass on
|
|
11
|
+
* a stale question; requests answered through courier_answer no longer wait and are skipped.
|
|
12
|
+
*/
|
|
13
|
+
export declare function reportReplied(ports: PersonPorts, state: PersonState, event: PermissionReplied): Promise<string[]>;
|
|
14
|
+
/**
|
|
15
|
+
* Relays the requests that spawned sessions already wait on, in every loaded location, which the
|
|
16
|
+
* event stream does not repeat: those asked while it was down, before the watcher (re)subscribed.
|
|
17
|
+
*/
|
|
18
|
+
export declare function relayPending(ports: PersonPorts, state: PersonState): Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* Tells the top session of a spawned child's pending permission or form, relays the requests already
|
|
21
|
+
* pending on every (re)subscribe, and tells the question relay when events are followed and when not.
|
|
22
|
+
*/
|
|
23
|
+
export declare function personEvents(state: PersonState): EventModule<PersonPorts>;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { envelope } from "../kernel/protocol.js";
|
|
2
|
+
import { claim } from "../platform/bounded.js";
|
|
3
|
+
import { allEntries, lineage, nativeRootOf } from "../tree/index.js";
|
|
4
|
+
import { reportForm, reportFormSettled } from "./form.js";
|
|
5
|
+
import { listEverywhere } from "./permission.js";
|
|
6
|
+
import { topOf } from "./ports.js";
|
|
7
|
+
import { eventsFollowed, eventsLeft, formShown, formsMayHaveBeenMissed, locationClosing } from "./question/index.js";
|
|
8
|
+
import { permissionNotice, permissionSettledNotice } from "./text.js";
|
|
9
|
+
/**
|
|
10
|
+
* Tells the session at the top, the parent of a child or the session a chain of children started
|
|
11
|
+
* from, what a waiting child asks and which answers there are, waking it. Returns the sessions told.
|
|
12
|
+
*/
|
|
13
|
+
export async function reportAsked(ports, state, event) {
|
|
14
|
+
if (!claim(state.seen, event.id))
|
|
15
|
+
return [];
|
|
16
|
+
const request = event.data;
|
|
17
|
+
const chain = await lineage(ports.storage, request.sessionID);
|
|
18
|
+
// Claimed only now, after the lookup: a request answered meanwhile is in `answered`, and one
|
|
19
|
+
// already relayed, from the event or from the pending list, is in `waiting`.
|
|
20
|
+
if (!chain.length || state.answered.has(request.id) || !claim(state.waiting, request.id))
|
|
21
|
+
return [];
|
|
22
|
+
const startedBy = chain.length > 1 ? chain[0].parentID : undefined;
|
|
23
|
+
// A native child's request shows as OpenCode's own prompt in its native root's view: when that is the top's, it is not asked again.
|
|
24
|
+
const native = nativeRootOf(chain) === topOf(chain);
|
|
25
|
+
await ports.session.synthetic({
|
|
26
|
+
sessionID: topOf(chain),
|
|
27
|
+
text: envelope(request.sessionID, permissionNotice(chain[0].title, request, startedBy, native), {
|
|
28
|
+
asks: "permission",
|
|
29
|
+
request: request.id,
|
|
30
|
+
...(native ? { shown: "native" } : {}),
|
|
31
|
+
}),
|
|
32
|
+
description: `Session ${request.sessionID} asks for permission`,
|
|
33
|
+
metadata: { source: "courier", from: request.sessionID, asks: "permission", requestID: request.id, ...(native ? { shown: "native" } : {}) },
|
|
34
|
+
delivery: "steer",
|
|
35
|
+
});
|
|
36
|
+
return [topOf(chain)];
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Tells the session told about a request that it was answered some other way, so it does not pass on
|
|
40
|
+
* a stale question; requests answered through courier_answer no longer wait and are skipped.
|
|
41
|
+
*/
|
|
42
|
+
export async function reportReplied(ports, state, event) {
|
|
43
|
+
if (!claim(state.seen, event.id))
|
|
44
|
+
return [];
|
|
45
|
+
const { sessionID, requestID, reply } = event.data;
|
|
46
|
+
if (!state.waiting.delete(requestID)) {
|
|
47
|
+
claim(state.answered, requestID);
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
const chain = await lineage(ports.storage, sessionID);
|
|
51
|
+
if (!chain.length)
|
|
52
|
+
return [];
|
|
53
|
+
await ports.session.synthetic({
|
|
54
|
+
sessionID: topOf(chain),
|
|
55
|
+
text: envelope(sessionID, permissionSettledNotice(chain[0].title, requestID, reply), { answered: reply, request: requestID }),
|
|
56
|
+
description: `Session ${sessionID} no longer asks for permission`,
|
|
57
|
+
metadata: { source: "courier", from: sessionID, answered: reply, requestID },
|
|
58
|
+
delivery: "steer",
|
|
59
|
+
});
|
|
60
|
+
return [topOf(chain)];
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Relays the requests that spawned sessions already wait on, in every loaded location, which the
|
|
64
|
+
* event stream does not repeat: those asked while it was down, before the watcher (re)subscribed.
|
|
65
|
+
*/
|
|
66
|
+
export async function relayPending(ports, state) {
|
|
67
|
+
const sessions = new Set((await allEntries(ports.storage)).map((entry) => entry.sessionID));
|
|
68
|
+
const domains = [...ports.permissions()];
|
|
69
|
+
// Each request is claimed before its notice goes out, so relaying them all at once tells each
|
|
70
|
+
// once, however many locations list it; a location that cannot be read lists none.
|
|
71
|
+
const relay = async (sessionID) => {
|
|
72
|
+
const listed = (await listEverywhere(domains, sessionID)).flatMap((found) => found.requests);
|
|
73
|
+
await Promise.all(listed.map((request) => reportAsked(ports, state, { id: `pending:${request.id}`, data: request })));
|
|
74
|
+
};
|
|
75
|
+
await Promise.all([...sessions].map(relay));
|
|
76
|
+
}
|
|
77
|
+
/** The events of what a spawned session asks the person: permission requests, forms, and locations closing on them. */
|
|
78
|
+
function routePerson(ports, state, event) {
|
|
79
|
+
if (event.type === "permission.asked")
|
|
80
|
+
return reportAsked(ports, state, event);
|
|
81
|
+
if (event.type === "permission.replied")
|
|
82
|
+
return reportReplied(ports, state, event);
|
|
83
|
+
if (event.type === "form.created") {
|
|
84
|
+
// Not claimed: the hub's other subscription, or another copy's watcher, may resolve the same
|
|
85
|
+
// waiting call, which is harmless.
|
|
86
|
+
formShown(event);
|
|
87
|
+
return reportForm(ports, state, event);
|
|
88
|
+
}
|
|
89
|
+
if (event.type === "form.replied" || event.type === "form.cancelled")
|
|
90
|
+
return reportFormSettled(ports, state, event);
|
|
91
|
+
// Not claimed either: a location closing withdraws its forms, which must not pass for dismissals.
|
|
92
|
+
// The event's location is optional; without one, the shutdown counts for every location.
|
|
93
|
+
if (event.type === "location.shutdown") {
|
|
94
|
+
const directory = event.location?.directory;
|
|
95
|
+
locationClosing(ports.now(), typeof directory === "string" ? directory : undefined);
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Tells the top session of a spawned child's pending permission or form, relays the requests already
|
|
101
|
+
* pending on every (re)subscribe, and tells the question relay when events are followed and when not.
|
|
102
|
+
*/
|
|
103
|
+
export function personEvents(state) {
|
|
104
|
+
return {
|
|
105
|
+
handle: (ports, event) => routePerson(ports, state, event),
|
|
106
|
+
// Question forms shown while no instance followed the events were not seen: released now,
|
|
107
|
+
// and again on the first event, by when the stream is surely connected.
|
|
108
|
+
followed: () => (eventsFollowed() ? formsMayHaveBeenMissed : undefined),
|
|
109
|
+
// Alongside the new subscription; a request both relays see is relayed once.
|
|
110
|
+
subscribed: (ports) => relayPending(ports, state).catch((error) => ports.log(`courier watch: could not relay pending requests: ${String(error)}`)),
|
|
111
|
+
left: eventsLeft,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type Form, type PersonPorts, type PersonState } from "./ports.js";
|
|
2
|
+
/** The `metadata.kind` of the forms OpenCode's question tool asks with; the question relay passes those on. */
|
|
3
|
+
export declare const QUESTION_FORM = "question";
|
|
4
|
+
/** The part of OpenCode's `form.created` event the notice is made from. */
|
|
5
|
+
export interface FormCreated {
|
|
6
|
+
readonly id: string;
|
|
7
|
+
readonly data: {
|
|
8
|
+
readonly form: Form;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
/** OpenCode's `form.replied` or `form.cancelled` event: the form is no longer shown. */
|
|
12
|
+
export interface FormSettled {
|
|
13
|
+
readonly id: string;
|
|
14
|
+
readonly type: string;
|
|
15
|
+
readonly data: {
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly sessionID: string;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Tells the top session about a form OpenCode shows in a spawned session, such as web search asking
|
|
22
|
+
* for its provider, which only the person can answer; question forms are left to the question relay.
|
|
23
|
+
*/
|
|
24
|
+
export declare function reportForm(ports: PersonPorts, state: PersonState, event: FormCreated): Promise<string[]>;
|
|
25
|
+
/** Tells the session told about a form that it was answered or withdrawn, so it does not send the person to a form that is gone. */
|
|
26
|
+
export declare function reportFormSettled(ports: PersonPorts, state: PersonState, event: FormSettled): Promise<string[]>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { envelope } from "../kernel/protocol.js";
|
|
2
|
+
import { claim, SEEN_MAX, setBounded } from "../platform/bounded.js";
|
|
3
|
+
import { lineage, nativeRootOf } from "../tree/index.js";
|
|
4
|
+
import { topOf } from "./ports.js";
|
|
5
|
+
import { formNotice, formSettledNotice, kindOf } from "./text.js";
|
|
6
|
+
/** The `metadata.kind` of the forms OpenCode's question tool asks with; the question relay passes those on. */
|
|
7
|
+
export const QUESTION_FORM = "question";
|
|
8
|
+
/**
|
|
9
|
+
* Tells the top session about a form OpenCode shows in a spawned session, such as web search asking
|
|
10
|
+
* for its provider, which only the person can answer; question forms are left to the question relay.
|
|
11
|
+
*/
|
|
12
|
+
export async function reportForm(ports, state, event) {
|
|
13
|
+
if (!claim(state.seen, event.id))
|
|
14
|
+
return [];
|
|
15
|
+
const form = event.data.form;
|
|
16
|
+
if (kindOf(form) === QUESTION_FORM)
|
|
17
|
+
return [];
|
|
18
|
+
const chain = await lineage(ports.storage, form.sessionID);
|
|
19
|
+
// As with a permission request, claimed after the lookup, so a form settled meanwhile is not told.
|
|
20
|
+
if (!chain.length || state.forms.settled.has(form.id) || state.forms.told.has(form.id))
|
|
21
|
+
return [];
|
|
22
|
+
const startedBy = chain.length > 1 ? chain[0].parentID : undefined;
|
|
23
|
+
const kind = kindOf(form);
|
|
24
|
+
const telling = ports.session.synthetic({
|
|
25
|
+
sessionID: topOf(chain),
|
|
26
|
+
text: envelope(form.sessionID, formNotice(chain[0].title, form, startedBy, nativeRootOf(chain), topOf(chain)), {
|
|
27
|
+
asks: "form",
|
|
28
|
+
form: form.id,
|
|
29
|
+
...(kind ? { kind } : {}),
|
|
30
|
+
}),
|
|
31
|
+
description: `Session ${form.sessionID} shows a form`,
|
|
32
|
+
metadata: { source: "courier", from: form.sessionID, asks: "form", formID: form.id, ...(kind ? { kind } : {}) },
|
|
33
|
+
delivery: "steer",
|
|
34
|
+
});
|
|
35
|
+
setBounded(state.forms.told, form.id, telling, SEEN_MAX);
|
|
36
|
+
await telling;
|
|
37
|
+
return [topOf(chain)];
|
|
38
|
+
}
|
|
39
|
+
/** Tells the session told about a form that it was answered or withdrawn, so it does not send the person to a form that is gone. */
|
|
40
|
+
export async function reportFormSettled(ports, state, event) {
|
|
41
|
+
if (!claim(state.seen, event.id))
|
|
42
|
+
return [];
|
|
43
|
+
const { id, sessionID } = event.data;
|
|
44
|
+
const telling = state.forms.told.get(id);
|
|
45
|
+
if (!telling) {
|
|
46
|
+
claim(state.forms.settled, id);
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
state.forms.told.delete(id);
|
|
50
|
+
const settled = event.type === "form.replied" ? "answered" : "cancelled";
|
|
51
|
+
// After the notice that the form is shown, which another instance may still be sending, so the
|
|
52
|
+
// settling never arrives first; and not at all when that notice did not go out.
|
|
53
|
+
const [chain, told] = await Promise.all([lineage(ports.storage, sessionID), telling.then(() => true, () => false)]);
|
|
54
|
+
if (!chain.length || !told)
|
|
55
|
+
return [];
|
|
56
|
+
await ports.session.synthetic({
|
|
57
|
+
sessionID: topOf(chain),
|
|
58
|
+
text: envelope(sessionID, formSettledNotice(chain[0].title, id, settled), { settled, form: id }),
|
|
59
|
+
description: `Session ${sessionID} no longer shows a form`,
|
|
60
|
+
metadata: { source: "courier", from: sessionID, settled, formID: id },
|
|
61
|
+
delivery: "steer",
|
|
62
|
+
});
|
|
63
|
+
return [topOf(chain)];
|
|
64
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { PendingPrompt, WatchPorts } from "../tree/index.js";
|
|
2
|
+
import type { PersonPorts } from "./ports.js";
|
|
3
|
+
import type { Prompt } from "./question/index.js";
|
|
4
|
+
export { personEvents } from "./events.js";
|
|
5
|
+
export { asksPermission, pendingOf } from "./permission.js";
|
|
6
|
+
export type { AnswerPorts, Permissions, Waiting } from "./permission.js";
|
|
7
|
+
export type { PersonPorts, PersonState } from "./ports.js";
|
|
8
|
+
export { registerPersonRpc } from "./rpc-handlers.js";
|
|
9
|
+
export { asksQuestion, joinRelay, noticeCutOff, pendingQuestions, portsAt, relayQuestions } from "./question/index.js";
|
|
10
|
+
export type { QuestionPorts } from "./question/index.js";
|
|
11
|
+
export { addPersonTools } from "./tools.js";
|
|
12
|
+
export type { PersonToolPorts } from "./tools.js";
|
|
13
|
+
type Satisfies<T extends U, U> = T;
|
|
14
|
+
/** The tree declares the question shape its `Pending` needs; this fails to compile once `Prompt` and it differ. */
|
|
15
|
+
export type PromptMatchesPending = [Satisfies<Prompt, PendingPrompt>, Satisfies<PendingPrompt, Prompt>];
|
|
16
|
+
/** The watcher's ports are the ones the person events run on; this fails to compile once `WatchPorts` stops satisfying `PersonPorts`. */
|
|
17
|
+
export type WatchPortsArePersonPorts = Satisfies<WatchPorts, PersonPorts>;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { personEvents } from "./events.js";
|
|
2
|
+
export { asksPermission, pendingOf } from "./permission.js";
|
|
3
|
+
export { registerPersonRpc } from "./rpc-handlers.js";
|
|
4
|
+
export { asksQuestion, joinRelay, noticeCutOff, pendingQuestions, portsAt, relayQuestions } from "./question/index.js";
|
|
5
|
+
export { addPersonTools } from "./tools.js";
|
|
@@ -1,7 +1,27 @@
|
|
|
1
1
|
import type { Plugin } from "@opencode/plugin";
|
|
2
|
-
import { type Pending } from "
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
import { type Pending, type RosterStorage } from "../tree/index.js";
|
|
3
|
+
/** The answers to a permission request, as OpenCode's own prompt offers them. */
|
|
4
|
+
export declare const REPLIES: readonly ["once", "always", "reject"];
|
|
5
|
+
export type Reply = (typeof REPLIES)[number];
|
|
6
|
+
/** A permission request, as OpenCode's `permission.asked` event carries it. */
|
|
7
|
+
export interface PermissionRequest {
|
|
8
|
+
readonly id: string;
|
|
9
|
+
readonly sessionID: string;
|
|
10
|
+
readonly action: string;
|
|
11
|
+
readonly resources: ReadonlyArray<string>;
|
|
12
|
+
readonly save?: ReadonlyArray<string>;
|
|
13
|
+
readonly message?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* What answering a permission request did; `answered` is false when no location loaded here held the
|
|
17
|
+
* request: answered already, no longer waited for, or waiting in another server on the same data dir.
|
|
18
|
+
*/
|
|
19
|
+
export interface PermissionAnswered {
|
|
20
|
+
readonly sessionID: string;
|
|
21
|
+
readonly requestID: string;
|
|
22
|
+
readonly reply: Reply;
|
|
23
|
+
readonly answered: boolean;
|
|
24
|
+
}
|
|
5
25
|
type Context = Plugin.Context;
|
|
6
26
|
/** One location's pending permission requests. OpenCode keeps them per location, so an isolated child's are in its worktree's. */
|
|
7
27
|
export type Permissions = Pick<Context["permission"], "list" | "reply">;
|
|
@@ -44,6 +64,10 @@ export declare function listEverywhere<D extends Pick<Permissions, "list">>(perm
|
|
|
44
64
|
domain: D;
|
|
45
65
|
requests: import("@opencode/client").PermissionRequest[];
|
|
46
66
|
})[]>;
|
|
67
|
+
/** Whether a session waits on a permission request of its own, in any loaded location. */
|
|
68
|
+
export declare function asksPermission(ports: {
|
|
69
|
+
readonly permissions: () => Iterable<Pick<Permissions, "list">>;
|
|
70
|
+
}, sessionID: string): Promise<boolean>;
|
|
47
71
|
/**
|
|
48
72
|
* Answers a permission request of a session started, directly or through others, from the caller,
|
|
49
73
|
* which must be the top session; `answered` is false when no location loaded here holds the request.
|
|
@@ -51,22 +75,4 @@ export declare function listEverywhere<D extends Pick<Permissions, "list">>(perm
|
|
|
51
75
|
export declare function answer(ports: AnswerPorts, waiting: Waiting, callerID: string, input: AnswerInput): Promise<PermissionAnswered>;
|
|
52
76
|
/** The requests a session waits on, in whichever location holds them, for courier_status. */
|
|
53
77
|
export declare function pendingOf(permissions: Iterable<Permissions>, sessionID: string): Promise<Pending[]>;
|
|
54
|
-
/** The `metadata.kind` of the forms OpenCode's question tool asks with; the question relay passes those on. */
|
|
55
|
-
export declare const QUESTION_FORM = "question";
|
|
56
|
-
/** The part of OpenCode's `form.created` event the notice is made from. */
|
|
57
|
-
export interface FormCreated {
|
|
58
|
-
readonly id: string;
|
|
59
|
-
readonly data: {
|
|
60
|
-
readonly form: Form;
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
/** OpenCode's `form.replied` or `form.cancelled` event: the form is no longer shown. */
|
|
64
|
-
export interface FormSettled {
|
|
65
|
-
readonly id: string;
|
|
66
|
-
readonly type: string;
|
|
67
|
-
readonly data: {
|
|
68
|
-
readonly id: string;
|
|
69
|
-
readonly sessionID: string;
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
78
|
export {};
|
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
import { describeFailure } from "
|
|
2
|
-
import { REJECTED
|
|
3
|
-
import { answeringTop } from "
|
|
1
|
+
import { describeFailure } from "../kernel/protocol.js";
|
|
2
|
+
import { REJECTED } from "./text.js";
|
|
3
|
+
import { answeringTop } from "../tree/index.js";
|
|
4
|
+
/** The answers to a permission request, as OpenCode's own prompt offers them. */
|
|
5
|
+
export const REPLIES = ["once", "always", "reject"];
|
|
4
6
|
/** A session's pending requests in every location, with the domain holding each; a location that cannot be read gives its error. */
|
|
5
7
|
export function listEverywhere(permissions, sessionID) {
|
|
6
8
|
return Promise.all([...permissions].map((domain) => domain.list({ sessionID }).then((requests) => ({ domain, requests: requests.filter((request) => request.sessionID === sessionID) }), (error) => ({ domain, requests: [], error }))));
|
|
7
9
|
}
|
|
10
|
+
/** Whether a session waits on a permission request of its own, in any loaded location. */
|
|
11
|
+
export async function asksPermission(ports, sessionID) {
|
|
12
|
+
return (await listEverywhere([...ports.permissions()], sessionID)).some((found) => found.requests.length > 0);
|
|
13
|
+
}
|
|
8
14
|
/** The location holding a pending request, and the request; undefined when none does, and an error when one could not be read. */
|
|
9
15
|
async function locate(ports, sessionID, requestID) {
|
|
10
16
|
const listed = await listEverywhere(ports.permissions(), sessionID);
|
|
@@ -64,5 +70,3 @@ export async function pendingOf(permissions, sessionID) {
|
|
|
64
70
|
});
|
|
65
71
|
return [...found.values()];
|
|
66
72
|
}
|
|
67
|
-
/** The `metadata.kind` of the forms OpenCode's question tool asks with; the question relay passes those on. */
|
|
68
|
-
export const QUESTION_FORM = "question";
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode/plugin";
|
|
2
|
+
import type { FormsTold } from "../platform/hub.js";
|
|
3
|
+
import type { RosterEntry, RosterStorage } from "../tree/index.js";
|
|
4
|
+
import type { Waiting } from "./permission.js";
|
|
5
|
+
export type { PermissionAnswered, PermissionRequest, Reply } from "./permission.js";
|
|
6
|
+
export type { Asked, Elsewhere, Outcome, Prompt, QuestionAnswered, Withdrawal } from "./question/index.js";
|
|
7
|
+
type Context = Plugin.Context;
|
|
8
|
+
/** What the person events need of a member's `watch` ports, which are a `WatchPorts` at runtime. */
|
|
9
|
+
export interface PersonPorts {
|
|
10
|
+
readonly storage: RosterStorage;
|
|
11
|
+
readonly session: Pick<Context["session"], "synthetic">;
|
|
12
|
+
readonly event: Pick<Context["event"], "subscribe">;
|
|
13
|
+
/**
|
|
14
|
+
* The permission domains of every loaded location, whose pending requests are relayed when the
|
|
15
|
+
* watcher (re)subscribes: one watcher serves them all, and an isolated child's are in its worktree's.
|
|
16
|
+
*/
|
|
17
|
+
readonly permissions: () => Iterable<Pick<Context["permission"], "list">>;
|
|
18
|
+
/** The clock a location shutdown is recorded by: the question relay's, which judges it. */
|
|
19
|
+
readonly now: () => number;
|
|
20
|
+
readonly log: (message: string) => void;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* What every plugin instance in the process shares of the person events: the handled event ids, shared
|
|
24
|
+
* with the tree's, and the requests and forms the top sessions were told about or that were answered first.
|
|
25
|
+
*/
|
|
26
|
+
export interface PersonState {
|
|
27
|
+
readonly seen: Set<string>;
|
|
28
|
+
readonly waiting: Waiting;
|
|
29
|
+
readonly answered: Set<string>;
|
|
30
|
+
readonly forms: FormsTold;
|
|
31
|
+
}
|
|
32
|
+
/** The session at the top of a lineage: where its permission requests go, since that is where the person is. */
|
|
33
|
+
export declare const topOf: (chain: RosterEntry[]) => string;
|
|
34
|
+
/** One field of an OpenCode form, as much of it as a notice shows. */
|
|
35
|
+
export interface FormField {
|
|
36
|
+
readonly key: string;
|
|
37
|
+
readonly type: string;
|
|
38
|
+
readonly title?: string;
|
|
39
|
+
readonly description?: string;
|
|
40
|
+
readonly options?: ReadonlyArray<{
|
|
41
|
+
readonly value: string;
|
|
42
|
+
readonly label: string;
|
|
43
|
+
readonly description?: string;
|
|
44
|
+
}>;
|
|
45
|
+
readonly url?: string;
|
|
46
|
+
/** Not shown to the person. */
|
|
47
|
+
readonly hidden?: boolean;
|
|
48
|
+
/** Conditions on earlier answers; the field is shown only when they hold. */
|
|
49
|
+
readonly when?: ReadonlyArray<unknown>;
|
|
50
|
+
}
|
|
51
|
+
/** An OpenCode form, as its `form.created` event carries it. */
|
|
52
|
+
export interface Form {
|
|
53
|
+
readonly id: string;
|
|
54
|
+
readonly sessionID: string;
|
|
55
|
+
readonly title: string;
|
|
56
|
+
readonly metadata?: Readonly<Record<string, unknown>>;
|
|
57
|
+
readonly fields: ReadonlyArray<FormField>;
|
|
58
|
+
}
|