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
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ /** The session at the top of a lineage: where its permission requests go, since that is where the person is. */
2
+ export const topOf = (chain) => chain.at(-1).parentID;
@@ -1,16 +1,36 @@
1
1
  import { Effect } from "effect";
2
- import type { QuestionPorts } from "../hub.js";
3
- import { type Asked, type CutOff, type Outcome, type Prompt, type QuestionAnswered } from "../notices.js";
4
- import { type RosterStorage } from "../roster.js";
2
+ import type { Answers, Asked, Outcome, Prompt, QuestionPorts, Withdrawal } from "./types.js";
3
+ import { type CutOff } from "../text.js";
4
+ import { type RosterStorage } from "../../tree/index.js";
5
5
  import { type QuestionAnswerInput } from "./pure.js";
6
+ /** What answering a question did; `answered` is false when it no longer waited. */
7
+ export type QuestionAnswered = {
8
+ readonly sessionID: string;
9
+ readonly requestID: string;
10
+ readonly answered: false;
11
+ } | {
12
+ readonly sessionID: string;
13
+ readonly requestID: string;
14
+ readonly answered: true;
15
+ readonly by: "result" | "message";
16
+ readonly answers: Answers;
17
+ };
6
18
  export declare function tell(ports: QuestionPorts, asked: Asked, text: string, attributes: Record<string, string>): Promise<void>;
7
19
  /** Tells the top session that a question's call was cut off, once per question and process. */
8
20
  export declare function tellCutOff(ports: QuestionPorts, asked: Asked, cutOff: CutOff): Promise<void>;
21
+ /**
22
+ * What a linked call of the top session is withdrawn with once an answer is passed on, `withdrawal`, and
23
+ * whether one was: `linked`, set once it is withdrawn.
24
+ */
25
+ export interface Linked {
26
+ readonly withdrawal: Withdrawal;
27
+ linked: boolean;
28
+ }
9
29
  /**
10
30
  * Passes the top session's answer, or the person's dismissal, to a question: as the result of the
11
31
  * call that waits on it, or as a message when that call was cut off. Undefined when it was settled.
12
32
  */
13
- export declare const deliver: (ports: QuestionPorts, asked: Asked, outcome: Outcome) => Effect.Effect<"result" | "message" | undefined, unknown>;
33
+ export declare const deliver: (ports: QuestionPorts, asked: Asked, outcome: Outcome, given?: Linked) => Effect.Effect<"result" | "message" | undefined, unknown>;
14
34
  /**
15
35
  * Drops a stored question whose answer went out. If that fails, it is marked answered instead, so
16
36
  * it is neither listed nor answered again, nor told about after a restart.
@@ -21,6 +41,15 @@ export declare function forget(ports: QuestionPorts, asked: Asked): Promise<void
21
41
  * be the session at the top, as for permission requests. `answered` is false when it no longer waits.
22
42
  */
23
43
  export declare function answerQuestion(ports: QuestionPorts, callerID: string, input: QuestionAnswerInput): Promise<QuestionAnswered>;
44
+ /**
45
+ * Answers a question for the person, from the TUI's inbox, as courier_answer does; the top session hears it once, as
46
+ * answered by the person: its linked call is withdrawn, or else, if it was told of it, it is told settled.
47
+ */
48
+ export declare function answerForPerson(ports: QuestionPorts, input: QuestionAnswerInput): Promise<QuestionAnswered>;
49
+ /** Whether a session waits on a question of its own, open or cut off and not yet answered. */
50
+ export declare function asksQuestion(ports: {
51
+ readonly storage: RosterStorage;
52
+ }, sessionID: string): Promise<boolean>;
24
53
  /** The questions a session waits on, for courier_status; `stopped` when its call was cut off. */
25
54
  export declare function pendingQuestions(storage: RosterStorage, sessionID: string): Promise<{
26
55
  type: "question";
@@ -1,7 +1,8 @@
1
1
  import { Deferred, Effect, Semaphore } from "effect";
2
- import { cutOffAnswer, envelope, questionNotice } from "../notices.js";
3
- import { allEntries, answeringTop, lineageIn, RETENTION_MS } from "../roster.js";
4
- import { scanAll } from "../storage.js";
2
+ import { envelope } from "../../kernel/protocol.js";
3
+ import { cutOffAnswer, questionNotice, questionSettledNotice } from "../text.js";
4
+ import { allEntries, answeringTop, lineageIn, RETENTION_MS } from "../../tree/index.js";
5
+ import { scanAll } from "../../platform/storage.js";
5
6
  import { within, run } from "./runtime.js";
6
7
  import { normalize } from "./pure.js";
7
8
  import { claim, isPassing, keyOf, MAX_STORED, PREFIX, shared, storedOf } from "./shared.js";
@@ -54,12 +55,12 @@ const holding = (id, ms, effect) => Effect.acquireUseRelease(Effect.sync(() => {
54
55
  * Passes the top session's answer, or the person's dismissal, to a question: as the result of the
55
56
  * call that waits on it, or as a message when that call was cut off. Undefined when it was settled.
56
57
  */
57
- export const deliver = (ports, asked, outcome) => Effect.suspend(() => {
58
+ export const deliver = (ports, asked, outcome, given) => Effect.suspend(() => {
59
+ const linked = given ?? { withdrawal: { by: "top", outcome }, linked: false };
58
60
  const id = asked.requestID;
59
61
  const wait = ports.timing.passingWaitMs;
60
- // One answer at a time, and once, per question in this process: storage cannot be claimed
61
- // atomically. A second answer waits for the first, and is passed on if the first was not.
62
- // While one is under way, nobody is told that the question was cut off, nor links to it.
62
+ // One answer at a time, and once, per question in this process, since storage cannot be claimed
63
+ // atomically: a second waits, going only if the first did not; meanwhile no cut-off notice or link.
63
64
  const since = ports.now();
64
65
  const left = () => wait - (ports.now() - since);
65
66
  const turn = holding(id, wait, Effect.gen(function* () {
@@ -71,11 +72,10 @@ export const deliver = (ports, asked, outcome) => Effect.suspend(() => {
71
72
  }
72
73
  if (shared.answered.has(id))
73
74
  return undefined;
74
- const passing = passOn(ports, asked, outcome);
75
+ const passing = passOn(ports, asked, outcome, linked);
75
76
  shared.passing.set(id, passing);
76
- // Held while it is passed on. One that hangs, on a notice that never returns, gives way
77
- // after a while, so the question can be answered again; should it still go through, the
78
- // child is told twice.
77
+ // Held while it is passed on; one that hangs on a notice that never returns gives way after a
78
+ // while, so the question can be answered again, and the child may then be told twice.
79
79
  yield* within(passing, wait).pipe(Effect.ensuring(Effect.sync(() => {
80
80
  if (shared.passing.get(id) === passing)
81
81
  shared.passing.delete(id);
@@ -84,7 +84,14 @@ export const deliver = (ports, asked, outcome) => Effect.suspend(() => {
84
84
  }));
85
85
  return Effect.flatMap(turn, (passing) => passing ? Effect.tryPromise({ try: () => passing, catch: (error) => error }) : Effect.succeed(undefined));
86
86
  });
87
- async function passOn(ports, asked, outcome) {
87
+ /** Withdraws the top session's call linked to a question, if any, now that it is settled. */
88
+ function withdraw(known, linked) {
89
+ if (!known.link)
90
+ return;
91
+ linked.linked = true;
92
+ known.link(linked.withdrawal);
93
+ }
94
+ async function passOn(ports, asked, outcome, linked) {
88
95
  const id = asked.requestID;
89
96
  let known = shared.questions.get(id);
90
97
  // Taken only if the call ends with it: one already ending some other way, its turn being
@@ -93,12 +100,11 @@ async function passOn(ports, asked, outcome) {
93
100
  claim(shared.answered, id);
94
101
  shared.questions.delete(id);
95
102
  // A call of the top session linked to it, asking the person, is withdrawn: it is settled.
96
- known.link?.({ by: "top", outcome });
103
+ withdraw(known, linked);
97
104
  return "result";
98
105
  }
99
- // Its call has just ended: whether it was cut off, and so stays registered, or was settled, and
100
- // so is dropped, is known once settle is through. The registry decides, not storage, which a
101
- // question whose record could not be written is missing from.
106
+ // Its call just ended: whether it was cut off and stays or was settled and is dropped is known once
107
+ // settle is through, from the registry, not storage, which lacks a question whose write failed.
102
108
  if (known) {
103
109
  await known.settling;
104
110
  if (shared.questions.get(id) !== known)
@@ -117,7 +123,7 @@ async function passOn(ports, asked, outcome) {
117
123
  // linked call, if any, goes then too, not before the answer is out.
118
124
  claim(shared.answered, id);
119
125
  shared.questions.delete(id);
120
- known.link?.({ by: "top", outcome });
126
+ withdraw(known, linked);
121
127
  await forget(ports, asked);
122
128
  return "message";
123
129
  }
@@ -158,6 +164,32 @@ export async function answerQuestion(ports, callerID, input) {
158
164
  const by = await run(deliver(ports, asked, { answers }));
159
165
  return by ? { sessionID, requestID, answered: true, by, answers } : { sessionID, requestID, answered: false };
160
166
  }
167
+ /**
168
+ * Answers a question for the person, from the TUI's inbox, as courier_answer does; the top session hears it once, as
169
+ * answered by the person: its linked call is withdrawn, or else, if it was told of it, it is told settled.
170
+ */
171
+ export async function answerForPerson(ports, input) {
172
+ const { sessionID, requestID } = input;
173
+ const known = shared.questions.get(requestID);
174
+ const asked = known ?? (await ports.storage.get(keyOf(requestID)));
175
+ if (asked?.sessionID !== sessionID || asked.answered)
176
+ return { sessionID, requestID, answered: false };
177
+ const answers = normalize(asked, input.answers);
178
+ const settled = { by: "child", answers, via: "inbox" };
179
+ const linked = { withdrawal: settled, linked: false };
180
+ const by = await run(deliver(ports, asked, { answers }, linked));
181
+ if (!by)
182
+ return { sessionID, requestID, answered: false };
183
+ // A question whose notice did not go out is not settled with one either; a stored one was told when cut off.
184
+ const told = !known?.relaying || (await run(within(known.relaying, ports.timing.relayWaitMs))) === true;
185
+ if (!linked.linked && told)
186
+ await tell(ports, asked, questionSettledNotice(asked, settled), { answered: "elsewhere", request: requestID }).catch((error) => ports.log(`courier question: could not tell ${asked.top} that ${requestID} is settled: ${String(error)}`));
187
+ return { sessionID, requestID, answered: true, by, answers };
188
+ }
189
+ /** Whether a session waits on a question of its own, open or cut off and not yet answered. */
190
+ export async function asksQuestion(ports, sessionID) {
191
+ return (await pendingQuestions(ports.storage, sessionID)).length > 0;
192
+ }
161
193
  /** The questions a session waits on, for courier_status; `stopped` when its call was cut off. */
162
194
  export async function pendingQuestions(storage, sessionID) {
163
195
  const found = new Map();
@@ -0,0 +1,7 @@
1
+ export { answerForPerson, answerQuestion, asksQuestion, noticeCutOff, pendingQuestions } from "./answer.js";
2
+ export { eventsFollowed, eventsLeft, formShown, formsMayHaveBeenMissed, joinRelay, locationClosing, shutdownReportedAt } from "./lifecycle.js";
3
+ export { byCodeUnit, type QuestionAnswerInput } from "./pure.js";
4
+ export { relayQuestions } from "./relay.js";
5
+ export { isQuestion, MAX_STORED, portsAt, QUESTION_TOOL } from "./shared.js";
6
+ export type { Answers, Asked, Elsewhere, Outcome, Prompt, QuestionPorts, QuestionTiming, Withdrawal } from "./types.js";
7
+ export type { QuestionAnswered } from "./answer.js";
@@ -1,7 +1,7 @@
1
1
  // The question relay: what the rest of the plugin uses of it. The relay's state is kept in the hub
2
- // (`../hub.ts`), shared by every plugin instance in the process.
3
- export { answerQuestion, noticeCutOff, pendingQuestions } from "./answer.js";
2
+ // (`../../platform/hub.ts`), under a process-wide key of its own; `types.ts` types it.
3
+ export { answerForPerson, answerQuestion, asksQuestion, noticeCutOff, pendingQuestions } from "./answer.js";
4
4
  export { eventsFollowed, eventsLeft, formShown, formsMayHaveBeenMissed, joinRelay, locationClosing, shutdownReportedAt } from "./lifecycle.js";
5
5
  export { byCodeUnit } from "./pure.js";
6
6
  export { relayQuestions } from "./relay.js";
7
- export { isQuestion, MAX_STORED, QUESTION_TOOL } from "./shared.js";
7
+ export { isQuestion, MAX_STORED, portsAt, QUESTION_TOOL } from "./shared.js";
@@ -0,0 +1,43 @@
1
+ import { Effect } from "effect";
2
+ import type { QuestionPorts } from "./types.js";
3
+ /**
4
+ * For tests: when the location at `directory` was last reported shutting down, if at all; a shutdown
5
+ * reported without a location counts for every directory, and with none given, any location's counts.
6
+ */
7
+ export declare const shutdownReportedAt: (directory?: string) => number | undefined;
8
+ /**
9
+ * Called for `location.shutdown`: the location at `directory` is closing, which withdraws its open
10
+ * forms as if dismissed; with no location it counts for all. `at` is the watcher's clock, epoch ms.
11
+ */
12
+ export declare function locationClosing(at: number, directory?: string): void;
13
+ /**
14
+ * Whether a dismissal just seen at `directory` was the location closing rather than the person: it
15
+ * shut down within the grace, before or after, or this instance unloads in it; unknown matches any.
16
+ */
17
+ export declare const closingSoon: (ports: QuestionPorts, loaded: () => boolean, directory: string | undefined) => Effect.Effect<boolean>;
18
+ /**
19
+ * Adds an instance's ports to `loaded`, which other copies tell cut-off questions through, until the
20
+ * returned function is called. A location loading again forgets the shutdown recorded for it alone.
21
+ */
22
+ export declare function joinRelay(ports: QuestionPorts): () => void;
23
+ /** Called for OpenCode's `form.created`: a question call's form is shown, so its question can be relayed. */
24
+ export declare function formShown(event: {
25
+ readonly data: {
26
+ readonly form: {
27
+ readonly sessionID: string;
28
+ readonly metadata?: unknown;
29
+ };
30
+ };
31
+ }): void;
32
+ /**
33
+ * Called when an event stream may have missed forms shown: every call still waiting for its form is
34
+ * taken as shown; one still waiting for its permission check is relayed early, the lesser harm.
35
+ */
36
+ export declare function formsMayHaveBeenMissed(): void;
37
+ /**
38
+ * Called when an instance starts following OpenCode's events: a shown form is missed only while none
39
+ * follows, so waiting calls are released; true tells the caller to release again on its first event.
40
+ */
41
+ export declare function eventsFollowed(): boolean;
42
+ /** Called when an instance's event stream ended or broke. */
43
+ export declare function eventsLeft(): void;
@@ -12,10 +12,8 @@ const shutdownAt = (directory) => directory === undefined
12
12
  */
13
13
  export const shutdownReportedAt = (directory) => shutdownAt(directory) || undefined;
14
14
  /**
15
- * Called for OpenCode's `location.shutdown`: the location at `directory` is closing, which
16
- * withdraws its open forms as if the person had dismissed them (before unloading the plugin
17
- * there, since OpenCode 2.0.22). The event's location is optional in the schema; without it,
18
- * the shutdown counts for every location. `at` is when, by the watcher's clock (epoch milliseconds).
15
+ * Called for `location.shutdown`: the location at `directory` is closing, which withdraws its open
16
+ * forms as if dismissed; with no location it counts for all. `at` is the watcher's clock, epoch ms.
19
17
  */
20
18
  export function locationClosing(at, directory) {
21
19
  shared.shutdowns.set(directory ?? ANYWHERE, at);
@@ -23,10 +21,8 @@ export function locationClosing(at, directory) {
23
21
  wake();
24
22
  }
25
23
  /**
26
- * Whether a dismissal just seen in the location at `directory` was that location closing rather
27
- * than the person: true when it shut down within the dismissal grace before, or does so, or this
28
- * instance unloads, within the grace from now, by the clock of `ports`. A shutdown reported without a location counts for every
29
- * location, and with the directory unknown, any location's shutdown counts.
24
+ * Whether a dismissal just seen at `directory` was the location closing rather than the person: it
25
+ * shut down within the grace, before or after, or this instance unloads in it; unknown matches any.
30
26
  */
31
27
  export const closingSoon = (ports, loaded, directory) => Effect.suspend(() => {
32
28
  const ms = ports.timing.dismissalGraceMs;
@@ -47,11 +43,8 @@ export const closingSoon = (ports, loaded, directory) => Effect.suspend(() => {
47
43
  }), () => (Deferred.isDoneUnsafe(closed) ? Effect.succeed(true) : Effect.raceFirst(Deferred.await(closed), Effect.as(Effect.sleep(ms), false))), () => Effect.sync(() => shared.closingWaiters.delete(wake)));
48
44
  });
49
45
  /**
50
- * Adds a plugin instance's ports to `loaded`, which copies of other versions read to tell a cut-off
51
- * question through it, until the returned function is called. A location loading again forgets the
52
- * shutdown recorded for it, so a dismissal there is not mistaken for that shutdown. One recorded for
53
- * every location is left alone, since a dismissal held in another location may still need it; it
54
- * ages out with the grace.
46
+ * Adds an instance's ports to `loaded`, which other copies tell cut-off questions through, until the
47
+ * returned function is called. A location loading again forgets the shutdown recorded for it alone.
55
48
  */
56
49
  export function joinRelay(ports) {
57
50
  shared.loaded.add(ports);
@@ -72,9 +65,8 @@ export function formShown(event) {
72
65
  shared.shown.delete(key);
73
66
  }
74
67
  /**
75
- * Called when an event stream may have missed forms being shown: every call still waiting for its
76
- * form is taken as shown. One still waiting for its permission check is relayed early, which is the
77
- * lesser harm.
68
+ * Called when an event stream may have missed forms shown: every call still waiting for its form is
69
+ * taken as shown; one still waiting for its permission check is relayed early, the lesser harm.
78
70
  */
79
71
  export function formsMayHaveBeenMissed() {
80
72
  for (const shown of shared.shown.values())
@@ -82,9 +74,8 @@ export function formsMayHaveBeenMissed() {
82
74
  shared.shown.clear();
83
75
  }
84
76
  /**
85
- * Called when an instance starts following OpenCode's events. Every instance is sent every event,
86
- * so a form shown is missed only while none follows them; then the calls waiting are released, and
87
- * true is returned, for the caller to release them again on its first event, once it is connected.
77
+ * Called when an instance starts following OpenCode's events: a shown form is missed only while none
78
+ * follows, so waiting calls are released; true tells the caller to release again on its first event.
88
79
  */
89
80
  export function eventsFollowed() {
90
81
  const missed = shared.following === 0 && shared.followed;
@@ -1,7 +1,6 @@
1
1
  import type { Plugin as EffectPlugin } from "@opencode/plugin/effect";
2
2
  import { Cause, Exit, type Effect } from "effect";
3
- import type { Question } from "../hub.js";
4
- import { type Answers, type Asked, type Elsewhere, type Prompt, type Withdrawal } from "../notices.js";
3
+ import type { Answers, Asked, Elsewhere, Prompt, Question, Withdrawal } from "./types.js";
5
4
  type ToolEditor = Parameters<Parameters<EffectPlugin.Context["tool"]["transform"]>[0]>[0];
6
5
  type HostTool = Parameters<Parameters<ToolEditor["update"]>[1]>[0];
7
6
  export type Execute = HostTool["execute"];
@@ -28,11 +27,10 @@ export type CallExit = Exit.Exit<{
28
27
  /** How a relayed call that the top session did not answer was settled in the asking session. */
29
28
  export declare function settledIn(exit: CallExit): Elsewhere;
30
29
  /**
31
- * Orders strings by their UTF-16 code units, the default sort's order, stated. The order is never
32
- * shown; it only has to come out the same on both sides of a comparison, which a locale-aware
33
- * compare would not promise: it can rank two different labels as equal and leave them as listed.
30
+ * Orders strings by UTF-16 code units, the default sort's order, stated: it only has to come out the
31
+ * same on both sides of a comparison; a locale-aware compare may rank two different labels as equal.
34
32
  */
35
- export declare function byCodeUnit(a: string, b: string): 1 | 0 | -1;
33
+ export declare function byCodeUnit(a: string, b: string): 0 | 1 | -1;
36
34
  export declare const choices: (questions: ReadonlyArray<Prompt>) => string;
37
35
  export declare const wording: (questions: ReadonlyArray<Prompt>) => string;
38
36
  /** The result of a linked call withdrawn because its question was settled without it. */
@@ -1,5 +1,5 @@
1
1
  import { Cause, Exit, Result } from "effect";
2
- import { withdrawnText } from "../notices.js";
2
+ import { withdrawnText } from "../text.js";
3
3
  /** The questions of a call, without anything the tool does not define. */
4
4
  export function promptsOf(input) {
5
5
  const questions = input?.questions ?? [];
@@ -40,9 +40,8 @@ export function settledIn(exit) {
40
40
  return { by: "failed", error: String(Cause.squash(exit.cause)) };
41
41
  }
42
42
  /**
43
- * Orders strings by their UTF-16 code units, the default sort's order, stated. The order is never
44
- * shown; it only has to come out the same on both sides of a comparison, which a locale-aware
45
- * compare would not promise: it can rank two different labels as equal and leave them as listed.
43
+ * Orders strings by UTF-16 code units, the default sort's order, stated: it only has to come out the
44
+ * same on both sides of a comparison; a locale-aware compare may rank two different labels as equal.
46
45
  */
47
46
  export function byCodeUnit(a, b) {
48
47
  if (a === b)
@@ -1,5 +1,5 @@
1
1
  import type { Plugin as EffectPlugin } from "@opencode/plugin/effect";
2
2
  import { Effect } from "effect";
3
- import type { QuestionPorts } from "../hub.js";
3
+ import type { QuestionPorts } from "./types.js";
4
4
  /** Wraps OpenCode's question tool for the relay. A tool by that id that is missing is left alone. */
5
5
  export declare function relayQuestions(host: Pick<EffectPlugin.Context, "tool" | "location">, ports: () => QuestionPorts | undefined): Effect.Effect<import("@opencode/plugin/effect/registration").Registration, never, import("effect/Scope").Scope>;
@@ -1,6 +1,6 @@
1
1
  import { Deferred, Effect, Exit, Fiber } from "effect";
2
- import { answeredText, DISMISSED, passedNote, questionNotice, questionSettledNotice, unlinkedNote, } from "../notices.js";
3
- import { lineage } from "../roster.js";
2
+ import { answeredText, DISMISSED, nativeQuestionNotice, passedNote, questionNotice, questionSettledNotice, unlinkedNote, } from "../text.js";
3
+ import { lineage, nativeRootOf } from "../../tree/index.js";
4
4
  import { deliver, forget, tell, tellCutOff } from "./answer.js";
5
5
  import { closingSoon } from "./lifecycle.js";
6
6
  import { choices, isDismissal, normalize, promptsOf, settledIn, withdrawnResult, wording, } from "./pure.js";
@@ -8,8 +8,7 @@ import { background, run, within } from "./runtime.js";
8
8
  import { anyLoaded, isPassing, keyOf, QUESTION_TOOL, shared, storedOf } from "./shared.js";
9
9
  /**
10
10
  * Tells the top session about a question its closing location cut off, after the closing grace of
11
- * `ports` (the instance whose call was cut off), through an instance still loaded then, if any (any will do: a notice reaches a session in any location).
12
- * Without one, the next load tells it.
11
+ * `ports`, through any instance still loaded then; without one, the next load tells it.
13
12
  */
14
13
  function tellLater(ports, question) {
15
14
  const tellNow = Effect.suspend(() => {
@@ -25,7 +24,10 @@ async function relay(ports, question) {
25
24
  shared.questions.set(question.requestID, question);
26
25
  try {
27
26
  await ports.storage.set(keyOf(question.requestID), storedOf(question));
28
- await tell(ports, question, questionNotice(question), { asks: "question", request: question.requestID });
27
+ if (question.native)
28
+ await tell(ports, question, nativeQuestionNotice(question), { asks: "question", request: question.requestID, shown: "native" });
29
+ else
30
+ await tell(ports, question, questionNotice(question), { asks: "question", request: question.requestID });
29
31
  return true;
30
32
  }
31
33
  catch (error) {
@@ -44,9 +46,8 @@ const settle = (ports, question, exit, loaded) => Effect.gen(function* () {
44
46
  // A dismissal is held from now, alongside the wait below, so the grace is not spent waiting.
45
47
  const dismissed = Exit.isFailure(exit) && isDismissal(exit.cause);
46
48
  const held = dismissed && !unloaded ? yield* Effect.forkChild(closingSoon(ports, loaded, question.directory), { startImmediately: true }) : undefined;
47
- // Telling the top session first, so what follows does not overtake the notice or come before it
48
- // is stored; for a while only, so a notice that never returns does not hold everything else up.
49
- // `told` is false when the top session was not told, or not yet.
49
+ // The top session is told first, for a while only, so what follows neither overtakes the notice
50
+ // nor waits forever on one that never returns; `told` is false when it was not told, or not yet.
50
51
  const told = (yield* within(question.relaying, ports.timing.relayWaitMs)) === true;
51
52
  // Answered by the top session.
52
53
  if (Exit.isSuccess(exit) && exit.value.by === "top") {
@@ -58,15 +59,8 @@ const settle = (ports, question, exit, loaded) => Effect.gen(function* () {
58
59
  if (shared.questions.get(question.requestID) !== question) {
59
60
  return yield* Effect.promise(() => forget(ports, question));
60
61
  }
61
- // Cut off: the turn was stopped, or OpenCode is closing the location, which withdraws every open
62
- // form there as if the person had dismissed it (on a server shutdown, say), before unloading the
63
- // plugin there (since 2.0.22) or after (the beta). So a dismissal is held for a moment, to see
64
- // whether the location's shutdown or this instance's unload follows it. The question stays,
65
- // answered by message from now on; a top session already asking the person passes their answer
66
- // on that way. A closing location is not told now: the next load tells it, in this process or
67
- // after a restart, or an instance still loaded does, a little later, when the process is still
68
- // running then. The unload is looked at again here: it may have come during the wait above, after
69
- // a held dismissal had already been taken for the person's.
62
+ // Cut off: the turn was stopped, or the closing location withdrew its forms as if dismissed, so a
63
+ // dismissal is held for a following shutdown or unload; the question stays, answered by message.
70
64
  const closing = unloaded || !loaded() || (held !== undefined && (yield* Fiber.join(held)));
71
65
  if (Exit.isFailure(exit) && (closing || (!dismissed && Exit.hasInterrupts(exit))))
72
66
  return closing ? tellLater(ports, question) : yield* Effect.promise(() => stopped(ports, question));
@@ -103,9 +97,8 @@ function startSettling(ports, question, exit, loaded) {
103
97
  });
104
98
  }
105
99
  /**
106
- * The question call of a session started with courier_spawn: once its form is shown, the session at
107
- * the top is told, and whichever answer comes first ends the call, the other side's question being
108
- * withdrawn. Other sessions' calls run unchanged.
100
+ * A spawned session's question call: once its form is shown the top session is told, and whichever
101
+ * answer comes first ends the call, the other side's question withdrawn. Other calls run unchanged.
109
102
  */
110
103
  function asking(ports, directory, original) {
111
104
  return (input, context) => Effect.gen(function* () {
@@ -120,14 +113,17 @@ function asking(ports, directory, original) {
120
113
  const acceptance = () => (acceptancePromise ??= Effect.runPromise(Deferred.await(accepted)));
121
114
  // Handed one outcome only: a second, while the first still ends the call, is not taken.
122
115
  let handed = false;
116
+ const top = chain.at(-1).parentID;
123
117
  const question = {
124
118
  requestID: current.newID(),
125
119
  sessionID: context.sessionID,
126
- top: chain.at(-1).parentID,
120
+ top,
127
121
  title: chain[0].title,
128
122
  ...(chain.length > 1 ? { startedBy: chain[0].parentID } : {}),
129
123
  questions: promptsOf(input),
130
124
  askedAt: current.now(),
125
+ // OpenCode shows a native child's form in its native root's view: when that is the top's, the person answers there.
126
+ ...(nativeRootOf(chain) === top ? { native: true } : {}),
131
127
  directory,
132
128
  call: (outcome) => {
133
129
  if (handed)
@@ -179,40 +175,36 @@ function asking(ports, directory, original) {
179
175
  };
180
176
  });
181
177
  }
178
+ /** Whether the top session's question call may be linked to a question: not a native child's while its own form is shown. */
179
+ const linkable = (question, sessionID) => question.top === sessionID && !question.link && !isPassing(question.requestID) && !(question.native && question.call);
182
180
  /**
183
- * The waiting question a top session's call asks again, the oldest if several: the same questions
184
- * with the same choices, in any order. Matching choices alone are not enough, or an unrelated
185
- * yes-or-no question of the top session would answer a child's.
181
+ * The waiting question a top session's call asks again, the oldest if several: the same question with
182
+ * the same choices, in any order. Choices alone would let an unrelated yes-or-no answer a child's.
186
183
  */
187
184
  function linkFor(sessionID, questions) {
188
185
  const asked = { choices: choices(questions), wording: wording(questions) };
189
186
  return [...shared.questions.values()]
190
- .filter((question) => question.top === sessionID &&
191
- !question.link &&
192
- !isPassing(question.requestID) &&
187
+ .filter((question) => linkable(question, sessionID) &&
193
188
  choices(question.questions) === asked.choices &&
194
189
  wording(question.questions) === asked.wording)
195
190
  .sort((a, b) => a.askedAt - b.askedAt)[0];
196
191
  }
197
192
  /**
198
- * The result of a top session's question call that no waiting question was linked to: when one
199
- * waits with the same choices, reworded or asked before that session asked, it says how to pass
200
- * the answers on.
193
+ * The result of a top session's question call that no waiting question was linked to: when one waits
194
+ * with the same choices, reworded or asked earlier, it says how to pass the answers on.
201
195
  */
202
196
  function unlinkedResult(result, sessionID, questions) {
203
197
  if (typeof result.content !== "string")
204
198
  return result;
205
199
  const asked = choices(questions);
206
- const similar = [...shared.questions.values()].filter((question) => question.top === sessionID && !question.link && !isPassing(question.requestID) && choices(question.questions) === asked);
200
+ const similar = [...shared.questions.values()].filter((question) => linkable(question, sessionID) && choices(question.questions) === asked);
207
201
  if (!similar.length)
208
202
  return result;
209
203
  return { ...result, content: `${result.content}\n${unlinkedNote(similar)}` };
210
204
  }
211
205
  /**
212
- * After a linked call failed, its location still loaded: a dismissal by the person, unless the
213
- * location is closing (see settle), dismisses the question they were asked for too, or is logged
214
- * when the question was answered meanwhile; a call stopped while the asking call was cut off as
215
- * well tells the top session, which nobody has told yet.
206
+ * After a linked call failed with its location still loaded: a dismissal by the person dismisses the
207
+ * child's question too, or is logged if answered meanwhile; a stop while cut off tells the top.
216
208
  */
217
209
  const linkedCallFailed = (ports, linked, cause, loaded, directory) => Effect.gen(function* () {
218
210
  if (isDismissal(cause)) {
@@ -1,8 +1,7 @@
1
1
  import { Clock, Effect } from "effect";
2
2
  /**
3
- * Runs an effect of the relay in a fiber of its own, by the relay's clock, which is the clock of
4
- * its ports (`ports.now`): the promise settles as the effect ends, and is rejected if it fails or
5
- * is interrupted. Interrupting what waits on the promise does not stop the fiber.
3
+ * Runs a relay effect in its own fiber by the relay's clock (`ports.now`): the promise settles as the
4
+ * effect ends, rejecting on failure or interruption; interrupting the waiter does not stop the fiber.
6
5
  */
7
6
  export declare function run<A, E>(effect: Effect.Effect<A, E>): Promise<A>;
8
7
  /**
@@ -38,9 +38,8 @@ let scope = Scope.makeUnsafe();
38
38
  */
39
39
  const start = (effect) => Effect.runFork(Effect.provideService(effect, Clock.Clock, clock)).pipe(Fiber.runIn(scope));
40
40
  /**
41
- * Runs an effect of the relay in a fiber of its own, by the relay's clock, which is the clock of
42
- * its ports (`ports.now`): the promise settles as the effect ends, and is rejected if it fails or
43
- * is interrupted. Interrupting what waits on the promise does not stop the fiber.
41
+ * Runs a relay effect in its own fiber by the relay's clock (`ports.now`): the promise settles as the
42
+ * effect ends, rejecting on failure or interruption; interrupting the waiter does not stop the fiber.
44
43
  */
45
44
  export function run(effect) {
46
45
  return Effect.runPromise(Fiber.join(start(effect)));
@@ -1,5 +1,5 @@
1
- import { type Question, type QuestionPorts } from "../hub.js";
2
- import type { Asked } from "../notices.js";
1
+ import type { Registry } from "../../platform/storage.js";
2
+ import type { Asked, Question, QuestionPorts, QuestionState } from "./types.js";
3
3
  /** OpenCode's own question tool, which the relay wraps. */
4
4
  export declare const QUESTION_TOOL = "question";
5
5
  export declare const PREFIX = "question/";
@@ -11,12 +11,22 @@ export type Stored = Asked & {
11
11
  };
12
12
  export declare const storedOf: ({ directory: _directory, call: _call, link: _link, relaying: _relaying, settling: _settling, ...asked }: Question) => Asked;
13
13
  export declare const isPassing: (requestID: string) => boolean;
14
- export declare const shared: import("../hub.js").QuestionState;
14
+ /**
15
+ * The relay's state in `registry`, the hub's `questions`, with the fields it lacks made: field by
16
+ * field, since a copy before the hub may have made it without the fields added later.
17
+ */
18
+ export declare function questionState(registry?: Registry): QuestionState;
19
+ export declare const shared: QuestionState;
15
20
  /**
16
21
  * The ports of an instance still loaded, if any: a member of this copy's hub, or else one that a
17
22
  * copy of another hub version, or a release before the hub, keeps in `loaded`.
18
23
  */
19
24
  export declare const anyLoaded: () => QuestionPorts | undefined;
25
+ /**
26
+ * The question ports of the member that just joined at `location`, while it is loaded: resolved once,
27
+ * so a later instance at the same location does not take its place while this one is still loaded.
28
+ */
29
+ export declare function portsAt(location: object): () => QuestionPorts | undefined;
20
30
  export declare const keyOf: (requestID: string) => string;
21
31
  /** Whether a request id is a question's rather than a permission request's. */
22
32
  export declare const isQuestion: (requestID: string) => boolean;