opencode-courier 0.2.3 → 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.
Files changed (143) hide show
  1. package/README.md +198 -193
  2. package/dist/index.d.ts +4 -8
  3. package/dist/index.js +66 -85
  4. package/dist/kernel/protocol.d.ts +20 -0
  5. package/dist/kernel/protocol.js +36 -0
  6. package/dist/kernel/tool.d.ts +5 -0
  7. package/dist/kernel/tool.js +5 -0
  8. package/dist/person/events.d.ts +23 -0
  9. package/dist/person/events.js +113 -0
  10. package/dist/person/form.d.ts +26 -0
  11. package/dist/person/form.js +64 -0
  12. package/dist/person/index.d.ts +17 -0
  13. package/dist/person/index.js +5 -0
  14. package/dist/{relay.d.ts → person/permission.d.ts} +29 -25
  15. package/dist/{relay.js → person/permission.js} +11 -9
  16. package/dist/person/ports.d.ts +58 -0
  17. package/dist/person/ports.js +2 -0
  18. package/dist/{question → person/question}/answer.d.ts +33 -4
  19. package/dist/{question → person/question}/answer.js +49 -17
  20. package/dist/person/question/index.d.ts +7 -0
  21. package/dist/{question → person/question}/index.js +3 -3
  22. package/dist/person/question/lifecycle.d.ts +43 -0
  23. package/dist/{question → person/question}/lifecycle.js +10 -19
  24. package/dist/{question → person/question}/pure.d.ts +4 -6
  25. package/dist/{question → person/question}/pure.js +3 -4
  26. package/dist/{question → person/question}/relay.d.ts +1 -1
  27. package/dist/{question → person/question}/relay.js +27 -35
  28. package/dist/{question → person/question}/runtime.d.ts +2 -3
  29. package/dist/{question → person/question}/runtime.js +2 -3
  30. package/dist/{question → person/question}/shared.d.ts +13 -3
  31. package/dist/person/question/shared.js +52 -0
  32. package/dist/person/question/types.d.ts +121 -0
  33. package/dist/person/question/types.js +1 -0
  34. package/dist/person/rpc-handlers.d.ts +160 -0
  35. package/dist/person/rpc-handlers.js +62 -0
  36. package/dist/person/rpc.d.ts +227 -0
  37. package/dist/person/rpc.js +70 -0
  38. package/dist/person/text.d.ts +68 -0
  39. package/dist/{notices.js → person/text.js} +53 -190
  40. package/dist/person/tools.d.ts +20 -0
  41. package/dist/person/tools.js +55 -0
  42. package/dist/person/tui.d.ts +46 -0
  43. package/dist/platform/bounded.d.ts +12 -0
  44. package/dist/{bounded.js → platform/bounded.js} +6 -4
  45. package/dist/platform/events.d.ts +34 -0
  46. package/dist/platform/events.js +121 -0
  47. package/dist/platform/hub.d.ts +195 -0
  48. package/dist/{hub.js → platform/hub.js} +78 -119
  49. package/dist/platform/json.d.ts +8 -0
  50. package/dist/platform/json.js +15 -0
  51. package/dist/platform/version.d.ts +16 -0
  52. package/dist/{version.js → platform/version.js} +5 -11
  53. package/dist/rpc.d.ts +2 -0
  54. package/dist/rpc.js +3 -0
  55. package/dist/scheduling/index.d.ts +6 -0
  56. package/dist/scheduling/index.js +5 -0
  57. package/dist/{later.d.ts → scheduling/later.d.ts} +13 -9
  58. package/dist/{later.js → scheduling/later.js} +18 -9
  59. package/dist/scheduling/text.d.ts +6 -0
  60. package/dist/scheduling/text.js +10 -0
  61. package/dist/scheduling/tools.d.ts +14 -0
  62. package/dist/scheduling/tools.js +48 -0
  63. package/dist/skill.d.ts +17 -0
  64. package/dist/skill.js +62 -0
  65. package/dist/{cleanup.d.ts → tree/cleanup.d.ts} +5 -26
  66. package/dist/{cleanup.js → tree/cleanup.js} +20 -15
  67. package/dist/tree/context.d.ts +15 -0
  68. package/dist/tree/context.js +34 -0
  69. package/dist/tree/domain.d.ts +101 -0
  70. package/dist/tree/domain.js +68 -0
  71. package/dist/tree/events.d.ts +71 -0
  72. package/dist/tree/events.js +204 -0
  73. package/dist/tree/forget.d.ts +24 -0
  74. package/dist/tree/forget.js +59 -0
  75. package/dist/tree/group.d.ts +82 -0
  76. package/dist/tree/group.js +203 -0
  77. package/dist/tree/index.d.ts +10 -0
  78. package/dist/tree/index.js +11 -0
  79. package/dist/tree/limits.d.ts +10 -0
  80. package/dist/tree/limits.js +60 -0
  81. package/dist/tree/panel.d.ts +13 -0
  82. package/dist/tree/panel.js +75 -0
  83. package/dist/tree/ports.d.ts +276 -0
  84. package/dist/tree/ports.js +1 -0
  85. package/dist/tree/report.d.ts +23 -0
  86. package/dist/tree/report.js +33 -0
  87. package/dist/tree/roster.d.ts +27 -0
  88. package/dist/tree/roster.js +62 -0
  89. package/dist/tree/rpc-handlers.d.ts +201 -0
  90. package/dist/tree/rpc-handlers.js +20 -0
  91. package/dist/tree/rpc.d.ts +344 -0
  92. package/dist/tree/rpc.js +83 -0
  93. package/dist/tree/send.d.ts +14 -0
  94. package/dist/tree/send.js +99 -0
  95. package/dist/tree/spawn.d.ts +21 -0
  96. package/dist/tree/spawn.js +173 -0
  97. package/dist/tree/status.d.ts +128 -0
  98. package/dist/tree/status.js +62 -0
  99. package/dist/tree/stop.d.ts +33 -0
  100. package/dist/tree/stop.js +116 -0
  101. package/dist/tree/store.d.ts +24 -0
  102. package/dist/tree/store.js +264 -0
  103. package/dist/tree/subtree.d.ts +101 -0
  104. package/dist/tree/subtree.js +60 -0
  105. package/dist/tree/summary.d.ts +7 -0
  106. package/dist/tree/summary.js +35 -0
  107. package/dist/tree/text.d.ts +77 -0
  108. package/dist/tree/text.js +287 -0
  109. package/dist/tree/tools.d.ts +54 -0
  110. package/dist/tree/tools.js +167 -0
  111. package/dist/tree/tui.d.ts +53 -0
  112. package/dist/tui.d.ts +3 -0
  113. package/dist/tui.js +1219 -0
  114. package/dist/webhook/index.d.ts +5 -0
  115. package/dist/webhook/index.js +3 -0
  116. package/dist/{webhook.d.ts → webhook/receiver.d.ts} +25 -36
  117. package/dist/{webhook.js → webhook/receiver.js} +66 -67
  118. package/dist/webhook/subscriptions.d.ts +29 -0
  119. package/dist/webhook/subscriptions.js +52 -0
  120. package/dist/webhook/text.d.ts +23 -0
  121. package/dist/webhook/text.js +82 -0
  122. package/dist/webhook/tools.d.ts +19 -0
  123. package/dist/webhook/tools.js +46 -0
  124. package/package.json +18 -7
  125. package/dist/bounded.d.ts +0 -10
  126. package/dist/courier.d.ts +0 -95
  127. package/dist/courier.js +0 -131
  128. package/dist/hub.d.ts +0 -325
  129. package/dist/json.d.ts +0 -4
  130. package/dist/json.js +0 -4
  131. package/dist/notices.d.ts +0 -246
  132. package/dist/question/index.d.ts +0 -6
  133. package/dist/question/lifecycle.d.ts +0 -52
  134. package/dist/question/shared.js +0 -24
  135. package/dist/roster.d.ts +0 -81
  136. package/dist/roster.js +0 -175
  137. package/dist/tools.d.ts +0 -74
  138. package/dist/tools.js +0 -228
  139. package/dist/version.d.ts +0 -22
  140. package/dist/watch.d.ts +0 -61
  141. package/dist/watch.js +0 -289
  142. /package/dist/{storage.d.ts → platform/storage.d.ts} +0 -0
  143. /package/dist/{storage.js → platform/storage.js} +0 -0
package/dist/index.js CHANGED
@@ -3,77 +3,44 @@ 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 { headOf, inspectWorktree } from "./cleanup.js";
7
- import { hub, join, permissions, portsAt, track } from "./hub.js";
8
- import { pendingOf } from "./relay.js";
9
- import { joinRelay, noticeCutOff, pendingQuestions, relayQuestions } from "./question/index.js";
10
- import { pruneExpired } from "./roster.js";
11
- import { addTools } from "./tools.js";
12
- import { watchFromHub } from "./watch.js";
13
- import { builtVersions, versionNotice } from "./version.js";
14
- import { listen, readConfig } from "./webhook.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";
11
+ import { addSkill } from "./skill.js";
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());
15
18
  /**
16
- * The webhook receiver, one per process like the claim set: every instance configured with a
17
- * `webhook` option joins `instances`, the first one starts the server, and the last one to unload
18
- * stops it. Requests are served with any live instance's ports; sessions and storage are shared.
19
- * A receiver that could not listen is dropped, so the next instance to load tries again, and a new
20
- * one waits for the previous one to finish closing, as on a plugin reload.
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.
21
21
  */
22
- const receivers = hub.receivers;
23
- const sameSettings = (a, b) => a.port === b.port && a.host === b.host && a.secret === b.secret && a.maxBytes === b.maxBytes;
24
- function startReceiver(config, log) {
25
- const instances = new Set();
26
- const receiver = {
27
- config,
28
- instances,
29
- server: (receivers.closing ?? Promise.resolve())
30
- .then(() => listen(config, () => instances.values().next().value))
31
- .then((server) => {
32
- log(`courier webhook: listening on http://${config.host}:${server.address().port}`);
33
- return server;
34
- }, (error) => {
35
- log(`courier webhook: cannot listen on ${config.host}:${config.port}: ${String(error)}`);
36
- if (receivers.current === receiver)
37
- receivers.current = undefined;
38
- return undefined;
39
- }),
40
- };
41
- receivers.current = receiver;
42
- return receiver;
43
- }
44
- function joinReceiver(config, ports) {
45
- const receiver = receivers.current ?? startReceiver(config, ports.log);
46
- if (!sameSettings(receiver.config, config))
47
- ports.log(`courier webhook: already running on ${receiver.config.host}:${receiver.config.port} with other settings ` +
48
- "(port, host, secret or maxBytes); this location's are ignored. Set the webhook option once, in the global config.");
49
- receiver.instances.add(ports);
50
- return async () => {
51
- receiver.instances.delete(ports);
52
- if (receiver.instances.size > 0 || receivers.current !== receiver)
53
- return;
54
- receivers.current = undefined;
55
- const closing = receiver.server.then((server) => new Promise((resolve) => {
56
- if (!server)
57
- return resolve();
58
- server.closeAllConnections();
59
- server.close(() => resolve());
60
- }));
61
- receivers.closing = closing;
62
- await closing;
63
- if (receivers.closing === closing)
64
- receivers.closing = undefined;
65
- };
66
- }
67
- // One set of handled events for every instance in the process, since the hub's two subscriptions,
68
- // and a copy of another hub version or one from before the hub, are sent the same events, and of
69
- // the permission requests sessions were told about. The scheduler's claim set is the hub's
70
- // own business: the hub runs the scheduler once for its members.
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;
29
+ // One set of handled events and of told permission requests for every instance in the process, since
30
+ // several subscriptions and copies see the same events. The scheduler's claim set is the hub's own.
71
31
  const { watched } = hub;
72
- const watchState = { ...watched, forms: hub.forms };
32
+ const watchState = { seen: watched.seen, inbox };
33
+ const personState = { ...watched, forms: hub.forms };
73
34
  /**
74
- * The courier tools and webhook receiver, as a promise plugin, which joins the hub that runs the
75
- * scheduler and the event watcher. Its member of the hub carries the instance's location, by which
76
- * the question relay, the Effect half of the same instance, finds its ports.
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)];
41
+ /**
42
+ * The courier tools and webhook receiver, as a promise plugin joining the hub that runs the scheduler
43
+ * and the watcher; its member carries the location by which the question relay finds its ports.
77
44
  */
78
45
  export const courier = () => Plugin.define({
79
46
  id: "courier",
@@ -98,7 +65,18 @@ export const courier = () => Plugin.define({
98
65
  newID: () => `question_${randomUUID()}`,
99
66
  log,
100
67
  };
68
+ const { limits, problems } = readLimits(ctx.options);
69
+ const { nativeChildren, problems: nativeProblems } = readNativeChildren(ctx.options);
70
+ for (const problem of [...problems, ...nativeProblems])
71
+ log(`courier: ${problem}`);
72
+ const roles = new Map();
101
73
  const ports = {
74
+ limits,
75
+ nativeChildren,
76
+ gate,
77
+ roles,
78
+ nudge,
79
+ log,
102
80
  session: ctx.session,
103
81
  agent: ctx.agent,
104
82
  worktree: ctx.worktree,
@@ -107,6 +85,7 @@ export const courier = () => Plugin.define({
107
85
  projectID: ctx.location.project.id,
108
86
  now: Date.now,
109
87
  head: headOf,
88
+ dirty: dirtyOf,
110
89
  pending: async (sessionID) => {
111
90
  const [requests, questions] = await Promise.all([
112
91
  pendingOf(permissions(), sessionID),
@@ -132,17 +111,21 @@ export const courier = () => Plugin.define({
132
111
  catch (error) {
133
112
  later.log(`courier webhook: not started: ${error instanceof Error ? error.message : String(error)}`);
134
113
  }
135
- const toolPorts = {
136
- courier: ports,
137
- cleanup: cleanupPorts,
138
- later,
139
- answer: answerPorts,
140
- questions: questionPorts,
141
- hooks,
142
- waiting: watched.waiting,
143
- receiving: async () => (await receivers.current?.server) !== undefined,
144
- };
145
- 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
+ });
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)}`));
128
+ await ctx.session.hook("context", (event) => shapeContext({ storage: ctx.storage, limits, roles, log }, event));
146
129
  const pruned = pruneExpired(ctx.storage, Date.now()).catch((error) => log(`courier roster prune: ${String(error)}`));
147
130
  const leaveRelay = joinRelay(questionPorts);
148
131
  const noticed = noticeCutOff(questionPorts).catch((error) => questionPorts.log(`courier question: stored questions: ${String(error)}`));
@@ -153,9 +136,10 @@ export const courier = () => Plugin.define({
153
136
  permissions,
154
137
  now: questionPorts.now,
155
138
  log: later.log,
139
+ nudge,
156
140
  };
157
- // The hub's subscriptions to OpenCode's events, one and a standby, start with this copy's watcher.
158
- watchFromHub(hub, watchState);
141
+ // The hub's subscriptions to OpenCode's events, one and a standby, start with this copy's modules.
142
+ watchFromHub(hub, eventModules);
159
143
  const member = {
160
144
  directory: ctx.location.directory,
161
145
  location: ctx.location,
@@ -181,11 +165,8 @@ export const courier = () => Plugin.define({
181
165
  },
182
166
  });
183
167
  /**
184
- * The plugin OpenCode loads. It is an Effect plugin, because the question relay wraps OpenCode's own
185
- * question tool, whose execute the promise API hands out only as a promise that cannot be
186
- * interrupted, so a child's question could not be withdrawn once its parent answered. Everything
187
- * else is the promise plugin above, run through the plugin package's own adapter, as OpenCode runs
188
- * a promise plugin.
168
+ * The plugin OpenCode loads: an Effect plugin, since the question relay wraps OpenCode's question tool
169
+ * and only the Effect API makes its call interruptible; the rest is the promise plugin, adapted.
189
170
  */
190
171
  export default EffectPlugin.define({
191
172
  id: "courier",
@@ -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, "&lt;$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,5 @@
1
+ import { describeFailure } from "./protocol.js";
2
+ /** A `.catch` handler that rethrows a failure of `tool` as the error the model reads. */
3
+ export const rethrow = (tool) => (error) => {
4
+ throw describeFailure(tool, error);
5
+ };
@@ -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 "./courier.js";
3
- import { type Form, type PermissionAnswered, type PermissionRequest, type Reply } from "./notices.js";
4
- import { type RosterStorage } from "./roster.js";
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,31 +64,15 @@ 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
- * Answers a permission request of a session started, directly or through others, from the
49
- * caller, which must be the session at the top: requests go there, to the person, so a session
50
- * started with courier_spawn cannot approve what its own children ask. `answered` is false when
51
- * no location loaded in this server holds the request, which may then wait in another server.
72
+ * Answers a permission request of a session started, directly or through others, from the caller,
73
+ * which must be the top session; `answered` is false when no location loaded here holds the request.
52
74
  */
53
75
  export declare function answer(ports: AnswerPorts, waiting: Waiting, callerID: string, input: AnswerInput): Promise<PermissionAnswered>;
54
76
  /** The requests a session waits on, in whichever location holds them, for courier_status. */
55
77
  export declare function pendingOf(permissions: Iterable<Permissions>, sessionID: string): Promise<Pending[]>;
56
- /** The `metadata.kind` of the forms OpenCode's question tool asks with; the question relay passes those on. */
57
- export declare const QUESTION_FORM = "question";
58
- /** The part of OpenCode's `form.created` event the notice is made from. */
59
- export interface FormCreated {
60
- readonly id: string;
61
- readonly data: {
62
- readonly form: Form;
63
- };
64
- }
65
- /** OpenCode's `form.replied` or `form.cancelled` event: the form is no longer shown. */
66
- export interface FormSettled {
67
- readonly id: string;
68
- readonly type: string;
69
- readonly data: {
70
- readonly id: string;
71
- readonly sessionID: string;
72
- };
73
- }
74
78
  export {};
@@ -1,10 +1,16 @@
1
- import { describeFailure } from "./courier.js";
2
- import { REJECTED, REPLIES } from "./notices.js";
3
- import { answeringTop } from "./roster.js";
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);
@@ -19,10 +25,8 @@ async function locate(ports, sessionID, requestID) {
19
25
  return undefined;
20
26
  }
21
27
  /**
22
- * Answers a permission request of a session started, directly or through others, from the
23
- * caller, which must be the session at the top: requests go there, to the person, so a session
24
- * started with courier_spawn cannot approve what its own children ask. `answered` is false when
25
- * no location loaded in this server holds the request, which may then wait in another server.
28
+ * Answers a permission request of a session started, directly or through others, from the caller,
29
+ * which must be the top session; `answered` is false when no location loaded here holds the request.
26
30
  */
27
31
  export async function answer(ports, waiting, callerID, input) {
28
32
  const reply = input.reply;
@@ -66,5 +70,3 @@ export async function pendingOf(permissions, sessionID) {
66
70
  });
67
71
  return [...found.values()];
68
72
  }
69
- /** The `metadata.kind` of the forms OpenCode's question tool asks with; the question relay passes those on. */
70
- export const QUESTION_FORM = "question";