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.
Files changed (141) hide show
  1. package/README.md +175 -274
  2. package/dist/index.js +47 -73
  3. package/dist/kernel/protocol.d.ts +20 -0
  4. package/dist/kernel/protocol.js +36 -0
  5. package/dist/kernel/tool.d.ts +5 -0
  6. package/dist/kernel/tool.js +5 -0
  7. package/dist/person/events.d.ts +23 -0
  8. package/dist/person/events.js +113 -0
  9. package/dist/person/form.d.ts +26 -0
  10. package/dist/person/form.js +64 -0
  11. package/dist/person/index.d.ts +17 -0
  12. package/dist/person/index.js +5 -0
  13. package/dist/{relay.d.ts → person/permission.d.ts} +27 -21
  14. package/dist/{relay.js → person/permission.js} +9 -5
  15. package/dist/person/ports.d.ts +58 -0
  16. package/dist/person/ports.js +2 -0
  17. package/dist/{question → person/question}/answer.d.ts +33 -4
  18. package/dist/{question → person/question}/answer.js +43 -8
  19. package/dist/person/question/index.d.ts +7 -0
  20. package/dist/{question → person/question}/index.js +3 -3
  21. package/dist/{question → person/question}/lifecycle.d.ts +1 -1
  22. package/dist/{question → person/question}/pure.d.ts +1 -2
  23. package/dist/{question → person/question}/pure.js +1 -1
  24. package/dist/{question → person/question}/relay.d.ts +1 -1
  25. package/dist/{question → person/question}/relay.js +14 -8
  26. package/dist/{question → person/question}/shared.d.ts +13 -3
  27. package/dist/person/question/shared.js +52 -0
  28. package/dist/person/question/types.d.ts +121 -0
  29. package/dist/person/question/types.js +1 -0
  30. package/dist/person/rpc-handlers.d.ts +160 -0
  31. package/dist/person/rpc-handlers.js +62 -0
  32. package/dist/person/rpc.d.ts +227 -0
  33. package/dist/person/rpc.js +70 -0
  34. package/dist/person/text.d.ts +68 -0
  35. package/dist/person/text.js +262 -0
  36. package/dist/person/tools.d.ts +20 -0
  37. package/dist/person/tools.js +55 -0
  38. package/dist/person/tui.d.ts +46 -0
  39. package/dist/{bounded.d.ts → platform/bounded.d.ts} +4 -0
  40. package/dist/{bounded.js → platform/bounded.js} +4 -0
  41. package/dist/platform/events.d.ts +34 -0
  42. package/dist/platform/events.js +121 -0
  43. package/dist/platform/hub.d.ts +195 -0
  44. package/dist/{hub.js → platform/hub.js} +27 -71
  45. package/dist/{version.js → platform/version.js} +1 -1
  46. package/dist/rpc.d.ts +2 -0
  47. package/dist/rpc.js +3 -0
  48. package/dist/scheduling/index.d.ts +6 -0
  49. package/dist/scheduling/index.js +5 -0
  50. package/dist/{later.d.ts → scheduling/later.d.ts} +7 -3
  51. package/dist/{later.js → scheduling/later.js} +12 -4
  52. package/dist/scheduling/text.d.ts +6 -0
  53. package/dist/scheduling/text.js +10 -0
  54. package/dist/scheduling/tools.d.ts +14 -0
  55. package/dist/scheduling/tools.js +48 -0
  56. package/dist/skill.d.ts +4 -0
  57. package/dist/skill.js +54 -1
  58. package/dist/{cleanup.d.ts → tree/cleanup.d.ts} +1 -27
  59. package/dist/{cleanup.js → tree/cleanup.js} +9 -16
  60. package/dist/tree/context.d.ts +15 -0
  61. package/dist/tree/context.js +34 -0
  62. package/dist/tree/domain.d.ts +101 -0
  63. package/dist/tree/domain.js +68 -0
  64. package/dist/{watch.d.ts → tree/events.d.ts} +10 -37
  65. package/dist/tree/events.js +204 -0
  66. package/dist/tree/forget.d.ts +24 -0
  67. package/dist/tree/forget.js +59 -0
  68. package/dist/tree/group.d.ts +82 -0
  69. package/dist/tree/group.js +203 -0
  70. package/dist/tree/index.d.ts +10 -0
  71. package/dist/tree/index.js +11 -0
  72. package/dist/tree/limits.d.ts +10 -0
  73. package/dist/{limits.js → tree/limits.js} +6 -55
  74. package/dist/tree/panel.d.ts +13 -0
  75. package/dist/tree/panel.js +75 -0
  76. package/dist/tree/ports.d.ts +276 -0
  77. package/dist/tree/ports.js +1 -0
  78. package/dist/tree/report.d.ts +23 -0
  79. package/dist/tree/report.js +33 -0
  80. package/dist/tree/roster.d.ts +27 -0
  81. package/dist/tree/roster.js +62 -0
  82. package/dist/tree/rpc-handlers.d.ts +201 -0
  83. package/dist/tree/rpc-handlers.js +20 -0
  84. package/dist/tree/rpc.d.ts +344 -0
  85. package/dist/tree/rpc.js +83 -0
  86. package/dist/tree/send.d.ts +14 -0
  87. package/dist/tree/send.js +99 -0
  88. package/dist/tree/spawn.d.ts +21 -0
  89. package/dist/tree/spawn.js +173 -0
  90. package/dist/tree/status.d.ts +128 -0
  91. package/dist/tree/status.js +62 -0
  92. package/dist/{stop.d.ts → tree/stop.d.ts} +1 -9
  93. package/dist/{stop.js → tree/stop.js} +9 -13
  94. package/dist/tree/store.d.ts +24 -0
  95. package/dist/tree/store.js +264 -0
  96. package/dist/{tree.d.ts → tree/subtree.d.ts} +5 -6
  97. package/dist/{tree.js → tree/subtree.js} +7 -5
  98. package/dist/tree/summary.d.ts +7 -0
  99. package/dist/tree/summary.js +35 -0
  100. package/dist/tree/text.d.ts +77 -0
  101. package/dist/tree/text.js +287 -0
  102. package/dist/tree/tools.d.ts +54 -0
  103. package/dist/{tools.js → tree/tools.js} +12 -142
  104. package/dist/tree/tui.d.ts +53 -0
  105. package/dist/tui.d.ts +3 -0
  106. package/dist/tui.js +1219 -0
  107. package/dist/webhook/index.d.ts +5 -0
  108. package/dist/webhook/index.js +3 -0
  109. package/dist/{webhook.d.ts → webhook/receiver.d.ts} +17 -23
  110. package/dist/{webhook.js → webhook/receiver.js} +58 -50
  111. package/dist/webhook/subscriptions.d.ts +29 -0
  112. package/dist/webhook/subscriptions.js +52 -0
  113. package/dist/webhook/text.d.ts +23 -0
  114. package/dist/webhook/text.js +82 -0
  115. package/dist/webhook/tools.d.ts +19 -0
  116. package/dist/webhook/tools.js +46 -0
  117. package/package.json +18 -7
  118. package/dist/courier.d.ts +0 -200
  119. package/dist/courier.js +0 -315
  120. package/dist/group.d.ts +0 -86
  121. package/dist/group.js +0 -247
  122. package/dist/hub.d.ts +0 -300
  123. package/dist/limits.d.ts +0 -65
  124. package/dist/notices.d.ts +0 -356
  125. package/dist/notices.js +0 -678
  126. package/dist/question/index.d.ts +0 -6
  127. package/dist/question/shared.js +0 -24
  128. package/dist/report.d.ts +0 -48
  129. package/dist/report.js +0 -88
  130. package/dist/roster.d.ts +0 -93
  131. package/dist/roster.js +0 -206
  132. package/dist/tools.d.ts +0 -94
  133. package/dist/watch.js +0 -474
  134. /package/dist/{question → person/question}/lifecycle.js +0 -0
  135. /package/dist/{question → person/question}/runtime.d.ts +0 -0
  136. /package/dist/{question → person/question}/runtime.js +0 -0
  137. /package/dist/{json.d.ts → platform/json.d.ts} +0 -0
  138. /package/dist/{json.js → platform/json.js} +0 -0
  139. /package/dist/{storage.d.ts → platform/storage.d.ts} +0 -0
  140. /package/dist/{storage.js → platform/storage.js} +0 -0
  141. /package/dist/{version.d.ts → platform/version.d.ts} +0 -0
@@ -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,7 +55,8 @@ 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
62
  // One answer at a time, and once, per question in this process, since storage cannot be claimed
@@ -70,7 +72,7 @@ export const deliver = (ports, asked, outcome) => Effect.suspend(() => {
70
72
  }
71
73
  if (shared.answered.has(id))
72
74
  return undefined;
73
- const passing = passOn(ports, asked, outcome);
75
+ const passing = passOn(ports, asked, outcome, linked);
74
76
  shared.passing.set(id, passing);
75
77
  // Held while it is passed on; one that hangs on a notice that never returns gives way after a
76
78
  // while, so the question can be answered again, and the child may then be told twice.
@@ -82,7 +84,14 @@ export const deliver = (ports, asked, outcome) => Effect.suspend(() => {
82
84
  }));
83
85
  return Effect.flatMap(turn, (passing) => passing ? Effect.tryPromise({ try: () => passing, catch: (error) => error }) : Effect.succeed(undefined));
84
86
  });
85
- 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) {
86
95
  const id = asked.requestID;
87
96
  let known = shared.questions.get(id);
88
97
  // Taken only if the call ends with it: one already ending some other way, its turn being
@@ -91,7 +100,7 @@ async function passOn(ports, asked, outcome) {
91
100
  claim(shared.answered, id);
92
101
  shared.questions.delete(id);
93
102
  // A call of the top session linked to it, asking the person, is withdrawn: it is settled.
94
- known.link?.({ by: "top", outcome });
103
+ withdraw(known, linked);
95
104
  return "result";
96
105
  }
97
106
  // Its call just ended: whether it was cut off and stays or was settled and is dropped is known once
@@ -114,7 +123,7 @@ async function passOn(ports, asked, outcome) {
114
123
  // linked call, if any, goes then too, not before the answer is out.
115
124
  claim(shared.answered, id);
116
125
  shared.questions.delete(id);
117
- known.link?.({ by: "top", outcome });
126
+ withdraw(known, linked);
118
127
  await forget(ports, asked);
119
128
  return "message";
120
129
  }
@@ -155,6 +164,32 @@ export async function answerQuestion(ports, callerID, input) {
155
164
  const by = await run(deliver(ports, asked, { answers }));
156
165
  return by ? { sessionID, requestID, answered: true, by, answers } : { sessionID, requestID, answered: false };
157
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
+ }
158
193
  /** The questions a session waits on, for courier_status; `stopped` when its call was cut off. */
159
194
  export async function pendingQuestions(storage, sessionID) {
160
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";
@@ -1,5 +1,5 @@
1
1
  import { Effect } from "effect";
2
- import type { QuestionPorts } from "../hub.js";
2
+ import type { QuestionPorts } from "./types.js";
3
3
  /**
4
4
  * For tests: when the location at `directory` was last reported shutting down, if at all; a shutdown
5
5
  * reported without a location counts for every directory, and with none given, any location's counts.
@@ -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"];
@@ -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 ?? [];
@@ -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";
@@ -24,7 +24,10 @@ async function relay(ports, question) {
24
24
  shared.questions.set(question.requestID, question);
25
25
  try {
26
26
  await ports.storage.set(keyOf(question.requestID), storedOf(question));
27
- 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 });
28
31
  return true;
29
32
  }
30
33
  catch (error) {
@@ -110,14 +113,17 @@ function asking(ports, directory, original) {
110
113
  const acceptance = () => (acceptancePromise ??= Effect.runPromise(Deferred.await(accepted)));
111
114
  // Handed one outcome only: a second, while the first still ends the call, is not taken.
112
115
  let handed = false;
116
+ const top = chain.at(-1).parentID;
113
117
  const question = {
114
118
  requestID: current.newID(),
115
119
  sessionID: context.sessionID,
116
- top: chain.at(-1).parentID,
120
+ top,
117
121
  title: chain[0].title,
118
122
  ...(chain.length > 1 ? { startedBy: chain[0].parentID } : {}),
119
123
  questions: promptsOf(input),
120
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 } : {}),
121
127
  directory,
122
128
  call: (outcome) => {
123
129
  if (handed)
@@ -169,6 +175,8 @@ function asking(ports, directory, original) {
169
175
  };
170
176
  });
171
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);
172
180
  /**
173
181
  * The waiting question a top session's call asks again, the oldest if several: the same question with
174
182
  * the same choices, in any order. Choices alone would let an unrelated yes-or-no answer a child's.
@@ -176,9 +184,7 @@ function asking(ports, directory, original) {
176
184
  function linkFor(sessionID, questions) {
177
185
  const asked = { choices: choices(questions), wording: wording(questions) };
178
186
  return [...shared.questions.values()]
179
- .filter((question) => question.top === sessionID &&
180
- !question.link &&
181
- !isPassing(question.requestID) &&
187
+ .filter((question) => linkable(question, sessionID) &&
182
188
  choices(question.questions) === asked.choices &&
183
189
  wording(question.questions) === asked.wording)
184
190
  .sort((a, b) => a.askedAt - b.askedAt)[0];
@@ -191,7 +197,7 @@ function unlinkedResult(result, sessionID, questions) {
191
197
  if (typeof result.content !== "string")
192
198
  return result;
193
199
  const asked = choices(questions);
194
- 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);
195
201
  if (!similar.length)
196
202
  return result;
197
203
  return { ...result, content: `${result.content}\n${unlinkedNote(similar)}` };
@@ -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;
@@ -0,0 +1,52 @@
1
+ import { addBounded } from "../../platform/bounded.js";
2
+ import { hub, memberAt, sharedState } from "../../platform/hub.js";
3
+ /** OpenCode's own question tool, which the relay wraps. */
4
+ export const QUESTION_TOOL = "question";
5
+ export const PREFIX = "question/";
6
+ /** At most this many questions are kept in storage; the oldest go first. */
7
+ export const MAX_STORED = 100;
8
+ export const storedOf = ({ directory: _directory, call: _call, link: _link, relaying: _relaying, settling: _settling, ...asked }) => asked;
9
+ export const isPassing = (requestID) => shared.passing.has(requestID) || shared.answered.has(requestID);
10
+ /**
11
+ * The relay's state in `registry`, the hub's `questions`, with the fields it lacks made: field by
12
+ * field, since a copy before the hub may have made it without the fields added later.
13
+ */
14
+ export function questionState(registry) {
15
+ const state = sharedState("questions", () => ({}), registry);
16
+ state.questions ??= new Map();
17
+ state.noticed ??= new Set();
18
+ state.shown ??= new Map();
19
+ state.passing ??= new Map();
20
+ state.answered ??= new Set();
21
+ state.loaded ??= new Set();
22
+ state.following ??= 0;
23
+ state.followed ??= false;
24
+ state.shutdowns ??= new Map();
25
+ state.closingWaiters ??= new Set();
26
+ return state;
27
+ }
28
+ // Shared by every plugin instance in the process, since a child and the session it asks can be in
29
+ // different locations.
30
+ export const shared = questionState();
31
+ /** A member's question ports: every hub-v2 member's `questions` are its instance's, whichever copy joined it. */
32
+ const portsOf = (member) => member?.questions;
33
+ /**
34
+ * The ports of an instance still loaded, if any: a member of this copy's hub, or else one that a
35
+ * copy of another hub version, or a release before the hub, keeps in `loaded`.
36
+ */
37
+ export const anyLoaded = () => portsOf(hub.members.values().next().value) ?? shared.loaded.values().next().value;
38
+ /**
39
+ * The question ports of the member that just joined at `location`, while it is loaded: resolved once,
40
+ * so a later instance at the same location does not take its place while this one is still loaded.
41
+ */
42
+ export function portsAt(location) {
43
+ const own = memberAt(location);
44
+ return () => (own && hub.members.has(own) ? portsOf(own) : undefined);
45
+ }
46
+ export const keyOf = (requestID) => `${PREFIX}${requestID}`;
47
+ /** Whether a request id is a question's rather than a permission request's. */
48
+ export const isQuestion = (requestID) => requestID.startsWith("question_");
49
+ /** How many noticed and answered questions are remembered. */
50
+ const REMEMBERED = 1_000;
51
+ /** Adds to one of the shared bounded sets. */
52
+ export const claim = (set, value) => addBounded(set, value, REMEMBERED);
@@ -0,0 +1,121 @@
1
+ import type { Plugin } from "@opencode/plugin";
2
+ import type { Storage } from "../../platform/storage.js";
3
+ type Context = Plugin.Context;
4
+ /** One question of OpenCode's question tool, as the asking model wrote it. */
5
+ export interface Prompt {
6
+ readonly question: string;
7
+ readonly header: string;
8
+ readonly options: ReadonlyArray<{
9
+ readonly label: string;
10
+ readonly description: string;
11
+ }>;
12
+ readonly multiple?: boolean;
13
+ }
14
+ /** The answers to a question call, as the question tool returns them: one list per question. */
15
+ export type Answers = string[][];
16
+ /** A question a spawned session asked with its question tool, stored until it is answered. */
17
+ export interface Asked {
18
+ readonly requestID: string;
19
+ readonly sessionID: string;
20
+ /** The session told, at the top of the sessions started with courier_spawn; it alone answers. */
21
+ readonly top: string;
22
+ readonly title: string;
23
+ /** The session that started the asking one, when that is not the top session itself. */
24
+ readonly startedBy?: string;
25
+ readonly questions: ReadonlyArray<Prompt>;
26
+ readonly askedAt: number;
27
+ /** A native child's, whose form OpenCode shows the person in the top session's own view; not linked while shown. */
28
+ readonly native?: true;
29
+ }
30
+ /**
31
+ * How a question was settled without the top session: in the asking session (or, `via` "inbox", by the
32
+ * person in the TUI's asks inbox), or not at all.
33
+ */
34
+ export type Elsewhere = {
35
+ readonly by: "child";
36
+ readonly answers: Answers;
37
+ readonly via?: "inbox";
38
+ } | {
39
+ readonly by: "dismissed";
40
+ } | {
41
+ readonly by: "failed";
42
+ readonly error: string;
43
+ };
44
+ /** What the top session's answer does to a call that waits: answers it, or dismisses it. */
45
+ export type Outcome = {
46
+ readonly answers: Answers;
47
+ } | {
48
+ readonly dismissed: true;
49
+ };
50
+ /**
51
+ * Why a linked call is withdrawn: its question was settled elsewhere, or the top session's outcome
52
+ * reached it another way (courier_answer, or a dismissal through an earlier call linked to it).
53
+ */
54
+ export type Withdrawal = Elsewhere | {
55
+ readonly by: "top";
56
+ readonly outcome: Outcome;
57
+ };
58
+ /**
59
+ * A question this process knows: its call waits (`call`), or was cut off and is answered by message.
60
+ * `link` withdraws the top session's call passing the answer on, once settled without that call.
61
+ */
62
+ export interface Question extends Asked {
63
+ /** The directory of the location the call runs in, whose shutdown withdraws its form. */
64
+ directory?: string;
65
+ /** Hands the waiting call the top session's outcome; true once the call has ended with it. */
66
+ call?: (outcome: Outcome) => Promise<boolean>;
67
+ link?: (how: Withdrawal) => void;
68
+ /** Storing the question and telling the top session, while that is under way. */
69
+ relaying?: Promise<boolean>;
70
+ /** Working out what became of it once its call ended without the top session's answer. */
71
+ settling?: Promise<void>;
72
+ }
73
+ /**
74
+ * The relay's timings, carried by its ports: the closing grace before a cut-off question is told, the
75
+ * wait for an answer still being passed on, the wait for a notice to go out, and the dismissal hold.
76
+ */
77
+ export interface QuestionTiming {
78
+ readonly closingGraceMs: number;
79
+ readonly passingWaitMs: number;
80
+ readonly relayWaitMs: number;
81
+ readonly dismissalGraceMs: number;
82
+ }
83
+ export interface QuestionPorts {
84
+ readonly storage: Storage;
85
+ readonly session: Pick<Context["session"], "synthetic">;
86
+ /** The directory of the instance's location. */
87
+ readonly directory: string;
88
+ /** The clock, in epoch milliseconds, as OpenCode's location shutdowns are recorded. */
89
+ readonly now: () => number;
90
+ readonly timing: QuestionTiming;
91
+ readonly newID: () => string;
92
+ readonly log: (message: string) => void;
93
+ }
94
+ /**
95
+ * The question relay's state, shared by every instance, as a child and the session it asks can be in
96
+ * different locations: open questions, cut-off ones already told, and calls waiting for their form.
97
+ */
98
+ export interface QuestionState {
99
+ readonly questions: Map<string, Question>;
100
+ readonly noticed: Set<string>;
101
+ readonly shown: Map<string, () => void>;
102
+ /** The questions whose answer is being passed on, each until that is done, and those whose answer went out. */
103
+ readonly passing: Map<string, Promise<unknown>>;
104
+ readonly answered: Set<string>;
105
+ /**
106
+ * The ports of every loaded instance of every copy. A release before the hub picks one here to tell
107
+ * a cut-off question through, so every copy fills it; this copy reads it only for another copy.
108
+ */
109
+ readonly loaded: Set<QuestionPorts>;
110
+ /** How many instances follow OpenCode's events now, and whether any has since the process started. */
111
+ following: number;
112
+ followed: boolean;
113
+ /**
114
+ * When OpenCode last reported each location shutting down (`location.shutdown`), by directory
115
+ * in epoch milliseconds by the clock of the watcher that saw it; under `""` when the event named no location.
116
+ */
117
+ readonly shutdowns: Map<string, number>;
118
+ /** Woken when a location shuts down or an instance unloads: dismissals held to see whether one follows. */
119
+ readonly closingWaiters: Set<() => void>;
120
+ }
121
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,160 @@
1
+ import type { Plugin } from "@opencode/plugin";
2
+ import { type Permissions } from "./permission.js";
3
+ import { type QuestionPorts } from "./question/index.js";
4
+ import { type Answered, type Asks } from "./rpc.js";
5
+ /** What the person's RPC reads and answers through: the relay's ports, which carry the storage, and every location's permissions. */
6
+ export interface PersonRpcPorts {
7
+ readonly questions: QuestionPorts;
8
+ readonly permissions: () => Iterable<Permissions>;
9
+ /** At most this many sessions of a tree are read, as courier_tree reads them. */
10
+ readonly maxTotal: number;
11
+ }
12
+ /**
13
+ * The permission requests and questions waiting in the courier tree a session is in, from the roster: read from the
14
+ * top of its lineage down, so each session of the tree sees the same inbox.
15
+ */
16
+ export declare function asks(ports: PersonRpcPorts, sessionID: string): Promise<Asks>;
17
+ /** Passes the person's answers to a question on, as courier_answer does; a refusal, such as answers that do not fit, comes back as `problem`. */
18
+ export declare function answer(ports: PersonRpcPorts, input: {
19
+ sessionID: string;
20
+ requestID: string;
21
+ answers: ReadonlyArray<ReadonlyArray<string>>;
22
+ }): Promise<Answered>;
23
+ /** Serves the person's RPC at the plugin's location; disposed with the plugin. */
24
+ export declare function registerPersonRpc(rpc: Pick<Plugin.Context["rpc"], "register">, ports: PersonRpcPorts): Promise<import("@opencode/plugin/promise/rpc").RpcRegistration<{
25
+ readonly id: "courier-person";
26
+ readonly methods: {
27
+ readonly asks: {
28
+ readonly input: import("effect/StandardSchema").StandardSchemaV1<{
29
+ readonly sessionID: string;
30
+ }, {
31
+ readonly sessionID: string;
32
+ }> & import("effect/Schema").Struct<{
33
+ readonly sessionID: import("effect/Schema").String;
34
+ }>;
35
+ readonly output: import("effect/StandardSchema").StandardSchemaV1<{
36
+ readonly questions: readonly {
37
+ readonly questions: readonly {
38
+ readonly options: readonly {
39
+ readonly label: string;
40
+ readonly description: string;
41
+ }[];
42
+ readonly question: string;
43
+ readonly header: string;
44
+ readonly multiple?: boolean | undefined;
45
+ }[];
46
+ readonly sessionID: string;
47
+ readonly requestID: string;
48
+ readonly stopped?: boolean | undefined;
49
+ }[];
50
+ readonly permissions: readonly {
51
+ readonly sessionID: string;
52
+ readonly resources: readonly string[];
53
+ readonly action: string;
54
+ readonly requestID: string;
55
+ readonly save?: readonly string[] | undefined;
56
+ }[];
57
+ readonly top: string;
58
+ readonly sessions: readonly {
59
+ readonly sessionID: string;
60
+ readonly title: string;
61
+ readonly parentID: string;
62
+ readonly depth: number;
63
+ readonly group?: string | undefined;
64
+ }[];
65
+ readonly truncated?: number | undefined;
66
+ }, {
67
+ readonly questions: readonly {
68
+ readonly questions: readonly {
69
+ readonly options: readonly {
70
+ readonly label: string;
71
+ readonly description: string;
72
+ }[];
73
+ readonly question: string;
74
+ readonly header: string;
75
+ readonly multiple?: boolean | undefined;
76
+ }[];
77
+ readonly sessionID: string;
78
+ readonly requestID: string;
79
+ readonly stopped?: boolean | undefined;
80
+ }[];
81
+ readonly permissions: readonly {
82
+ readonly sessionID: string;
83
+ readonly resources: readonly string[];
84
+ readonly action: string;
85
+ readonly requestID: string;
86
+ readonly save?: readonly string[] | undefined;
87
+ }[];
88
+ readonly top: string;
89
+ readonly sessions: readonly {
90
+ readonly sessionID: string;
91
+ readonly title: string;
92
+ readonly parentID: string;
93
+ readonly depth: number;
94
+ readonly group?: string | undefined;
95
+ }[];
96
+ readonly truncated?: number | undefined;
97
+ }> & import("effect/Schema").Struct<{
98
+ readonly top: import("effect/Schema").String;
99
+ readonly sessions: import("effect/Schema").$Array<import("effect/Schema").Struct<{
100
+ readonly sessionID: import("effect/Schema").String;
101
+ readonly title: import("effect/Schema").String;
102
+ readonly parentID: import("effect/Schema").String;
103
+ readonly depth: import("effect/Schema").Int;
104
+ readonly group: import("effect/Schema").optionalKey<import("effect/Schema").String>;
105
+ }>>;
106
+ readonly permissions: import("effect/Schema").$Array<import("effect/Schema").Struct<{
107
+ readonly sessionID: import("effect/Schema").String;
108
+ readonly requestID: import("effect/Schema").String;
109
+ readonly action: import("effect/Schema").String;
110
+ readonly resources: import("effect/Schema").$Array<import("effect/Schema").String>;
111
+ readonly save: import("effect/Schema").optionalKey<import("effect/Schema").$Array<import("effect/Schema").String>>;
112
+ }>>;
113
+ readonly questions: import("effect/Schema").$Array<import("effect/Schema").Struct<{
114
+ readonly sessionID: import("effect/Schema").String;
115
+ readonly requestID: import("effect/Schema").String;
116
+ readonly questions: import("effect/Schema").$Array<import("effect/Schema").Struct<{
117
+ readonly question: import("effect/Schema").String;
118
+ readonly header: import("effect/Schema").String;
119
+ readonly options: import("effect/Schema").$Array<import("effect/Schema").Struct<{
120
+ readonly label: import("effect/Schema").String;
121
+ readonly description: import("effect/Schema").String;
122
+ }>>;
123
+ readonly multiple: import("effect/Schema").optionalKey<import("effect/Schema").Boolean>;
124
+ }>>;
125
+ readonly stopped: import("effect/Schema").optionalKey<import("effect/Schema").Boolean>;
126
+ }>>;
127
+ readonly truncated: import("effect/Schema").optionalKey<import("effect/Schema").Int>;
128
+ }>;
129
+ };
130
+ readonly answer: {
131
+ readonly input: import("effect/StandardSchema").StandardSchemaV1<{
132
+ readonly sessionID: string;
133
+ readonly requestID: string;
134
+ readonly answers: readonly (readonly string[])[];
135
+ }, {
136
+ readonly sessionID: string;
137
+ readonly requestID: string;
138
+ readonly answers: readonly (readonly string[])[];
139
+ }> & import("effect/Schema").Struct<{
140
+ readonly sessionID: import("effect/Schema").String;
141
+ readonly requestID: import("effect/Schema").String;
142
+ readonly answers: import("effect/Schema").$Array<import("effect/Schema").$Array<import("effect/Schema").String>>;
143
+ }>;
144
+ readonly output: import("effect/StandardSchema").StandardSchemaV1<{
145
+ readonly answered: boolean;
146
+ readonly by?: "message" | "result" | undefined;
147
+ readonly problem?: string | undefined;
148
+ }, {
149
+ readonly answered: boolean;
150
+ readonly by?: "message" | "result" | undefined;
151
+ readonly problem?: string | undefined;
152
+ }> & import("effect/Schema").Struct<{
153
+ readonly answered: import("effect/Schema").Boolean;
154
+ readonly by: import("effect/Schema").optionalKey<import("effect/Schema").Literals<readonly ["result", "message"]>>;
155
+ readonly problem: import("effect/Schema").optionalKey<import("effect/Schema").String>;
156
+ }>;
157
+ };
158
+ };
159
+ readonly events: {};
160
+ }>>;