@fitchmultz/pi-ask-question 0.6.5 → 0.6.6

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.
@@ -0,0 +1,205 @@
1
+ /** Publishes prompt state and processes cooperative remote answers on the shared bus. */
2
+
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import type { QuestionObserver, Target } from "./question-observer.ts";
5
+ import {
6
+ parseRemoteAnswerRequest,
7
+ validateRemoteAnswer,
8
+ type RemoteAnswerRequest,
9
+ type ValidatedRemoteAnswer,
10
+ type WaitingPromptState,
11
+ } from "./remote-answers.ts";
12
+ import { MAX_REMOTE_REQUESTS, type NormalizedQuestion, type QuestionTimeout } from "./types.ts";
13
+
14
+ /** The form surface the channel reads and drives. */
15
+ export type ChannelHost = {
16
+ readonly finished: boolean;
17
+ readonly editing: boolean;
18
+ current: () => NormalizedQuestion | undefined;
19
+ allAnswered: () => boolean;
20
+ applyRemoteAnswer: (question: NormalizedQuestion, validated: ValidatedRemoteAnswer) => void;
21
+ abort: () => void;
22
+ finish: (cancelled: boolean) => void;
23
+ };
24
+
25
+ /** Wiring the channel needs from the registering ask flow. */
26
+ export type PromptChannelDeps = {
27
+ pi: ExtensionAPI;
28
+ observer: QuestionObserver;
29
+ timeout: QuestionTimeout;
30
+ signal: AbortSignal | undefined;
31
+ promptId: string;
32
+ };
33
+
34
+ /** Bus channel for one prompt: state publication and remote answer handling. */
35
+ export class PromptChannel {
36
+ private readonly deps: PromptChannelDeps;
37
+ private readonly host: ChannelHost;
38
+ private readonly unwatch: () => void;
39
+ private readonly unsubscribeAnswer: () => void;
40
+ private readonly acceptedRequests = new Set<string>();
41
+ private revision = 0;
42
+ private publishQueued = false;
43
+ private closed = false;
44
+ private announced = false;
45
+
46
+ constructor(deps: PromptChannelDeps, host: ChannelHost) {
47
+ this.deps = deps;
48
+ this.host = host;
49
+ this.unwatch = deps.observer.watch(() => this.waitingState());
50
+ this.unsubscribeAnswer = deps.pi.events.on("pi-ask-question:answer", (data) => {
51
+ this.handleRemoteAnswer(data);
52
+ });
53
+ }
54
+
55
+ /** Advertise only after the native host has installed the form component. */
56
+ announce(): void {
57
+ this.announced = true;
58
+ if (this.deps.signal?.aborted === true) {
59
+ this.host.abort();
60
+ } else {
61
+ this.publishState();
62
+ }
63
+ }
64
+
65
+ /** Stop watching and emit the closed state once. */
66
+ release(): void {
67
+ this.unwatch();
68
+ this.unsubscribeAnswer();
69
+ this.publishState();
70
+ }
71
+
72
+ stateChanged(): void {
73
+ this.revision += 1;
74
+ if (this.publishQueued) {
75
+ return;
76
+ }
77
+ this.publishQueued = true;
78
+ queueMicrotask(() => {
79
+ this.publishQueued = false;
80
+ this.publishState();
81
+ });
82
+ }
83
+
84
+ publishState(): void {
85
+ const waiting = this.waitingState();
86
+ if (waiting === undefined) {
87
+ if (!this.closed) {
88
+ this.deps.pi.events.emit("pi-ask-question:state", {
89
+ status: "closed",
90
+ promptId: this.deps.promptId,
91
+ });
92
+ }
93
+ this.closed = true;
94
+ return;
95
+ }
96
+ this.closed = false;
97
+ this.deps.pi.events.emit("pi-ask-question:state", waiting);
98
+ }
99
+
100
+ /** The prompt cannot accept or expose an answer right now. */
101
+ private promptBusy(): boolean {
102
+ return (
103
+ this.host.finished ||
104
+ this.deps.signal?.aborted === true ||
105
+ this.host.editing ||
106
+ (this.deps.timeout.active && Date.now() >= this.deps.timeout.deadline)
107
+ );
108
+ }
109
+
110
+ private waitingState(): WaitingPromptState | undefined {
111
+ const question = this.host.current();
112
+ if (question === undefined || !this.announced || this.promptBusy()) {
113
+ return undefined;
114
+ }
115
+ return {
116
+ status: "waiting",
117
+ ...this.deps.observer.identity(),
118
+ promptId: this.deps.promptId,
119
+ questionId: question.id,
120
+ revision: this.revision,
121
+ deadline: this.deps.timeout.deadline,
122
+ timeoutActive: this.deps.timeout.active,
123
+ question: question.question,
124
+ options: [...question.options],
125
+ multiSelect: question.multiSelect,
126
+ };
127
+ }
128
+
129
+ private handleRemoteAnswer(data: unknown): void {
130
+ const request = parseRemoteAnswerRequest(data);
131
+ if (request === undefined) {
132
+ return;
133
+ }
134
+ // Other live owners on the shared bus must not acknowledge this prompt.
135
+ if (request.promptId !== this.deps.promptId) {
136
+ return;
137
+ }
138
+ const receipt = (status: "answered" | "stale" | "invalid") =>
139
+ this.deps.pi.events.emit("pi-ask-question:result", {
140
+ requestId: request.requestId,
141
+ promptId: this.deps.promptId,
142
+ status,
143
+ });
144
+ const question = this.host.current();
145
+ if (question === undefined || this.promptBusy()) {
146
+ receipt("stale");
147
+ return;
148
+ }
149
+ if (!this.targetsCurrentPrompt(request, question)) {
150
+ receipt("stale");
151
+ return;
152
+ }
153
+ const validated = validateRemoteAnswer(request.answer, question);
154
+ if (validated === undefined || this.acceptedRequests.size >= MAX_REMOTE_REQUESTS) {
155
+ receipt("invalid");
156
+ return;
157
+ }
158
+ this.acceptRemoteAnswer(request, question, validated);
159
+ receipt("answered");
160
+ }
161
+
162
+ private targetsCurrentPrompt(
163
+ request: RemoteAnswerRequest,
164
+ question: NormalizedQuestion,
165
+ ): boolean {
166
+ return (
167
+ request.questionId === question.id &&
168
+ request.revision === this.revision &&
169
+ !this.acceptedRequests.has(request.requestId) &&
170
+ this.deps.observer.matches(request, this.deps.timeout.deadline)
171
+ );
172
+ }
173
+
174
+ private acceptRemoteAnswer(
175
+ request: RemoteAnswerRequest,
176
+ question: NormalizedQuestion,
177
+ validated: ValidatedRemoteAnswer,
178
+ ): void {
179
+ const target: Target = {
180
+ promptId: this.deps.promptId,
181
+ questionId: question.id,
182
+ revision: this.revision,
183
+ deadline: this.deps.timeout.deadline,
184
+ };
185
+ this.acceptedRequests.add(request.requestId);
186
+ this.deps.timeout.stop();
187
+ this.host.applyRemoteAnswer(question, validated);
188
+ if (this.host.allAnswered()) {
189
+ this.host.finish(false);
190
+ }
191
+ this.forwardAccepted(target, request.requestId, validated);
192
+ }
193
+
194
+ private forwardAccepted(
195
+ target: Target,
196
+ requestId: string,
197
+ validated: ValidatedRemoteAnswer,
198
+ ): void {
199
+ if (validated.kind === "text") {
200
+ this.deps.observer.accept(target, requestId, validated.text);
201
+ } else {
202
+ this.deps.observer.accept(target, requestId, validated.options);
203
+ }
204
+ }
205
+ }