@black942026/pi-ask-user 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +160 -40
- package/package.json +2 -2
- package/src/adapters/pi.ts +4 -1
- package/src/answers.ts +118 -33
- package/src/config.ts +14 -10
- package/src/core.ts +49 -12
- package/src/deadline.ts +22 -6
- package/src/index.ts +17 -2
- package/src/parse.ts +7 -0
- package/src/schema.ts +188 -89
- package/src/types.ts +107 -15
- package/src/ui/custom.ts +423 -18
- package/src/ui/native.ts +162 -59
package/src/core.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { combineSignals, createDeadline, safeTimeoutMs } from "./deadline.ts";
|
|
2
|
+
import { validateAnswer } from "./answers.ts";
|
|
2
3
|
import { AskUserValidationError, normalizeAskUserRequest } from "./schema.ts";
|
|
3
4
|
import type {
|
|
4
5
|
AskUIInput,
|
|
@@ -92,9 +93,17 @@ export async function askUserNormalized(
|
|
|
92
93
|
const effectiveRequest = applyPreferences(request, host.preferences);
|
|
93
94
|
const deadline = createDeadline(effectiveRequest.totalTimeoutMs, now);
|
|
94
95
|
const deadlineController = new AbortController();
|
|
96
|
+
const timeoutCancelled = new AbortController();
|
|
95
97
|
const linked = combineSignals(signal, deadlineController.signal);
|
|
96
98
|
|
|
97
99
|
const input: AskUIInput = { request: effectiveRequest, deadline, signal: linked.signal };
|
|
100
|
+
if (route === "custom") {
|
|
101
|
+
input.cancelTimeout = () => {
|
|
102
|
+
if (!deadline.cancel()) return false;
|
|
103
|
+
timeoutCancelled.abort();
|
|
104
|
+
return true;
|
|
105
|
+
};
|
|
106
|
+
}
|
|
98
107
|
if (onUpdate) input.onUpdate = onUpdate;
|
|
99
108
|
|
|
100
109
|
// Wait events are emitted exactly around a real UI attempt: `waitStarted`
|
|
@@ -138,12 +147,14 @@ export async function askUserNormalized(
|
|
|
138
147
|
}
|
|
139
148
|
|
|
140
149
|
// The core owns the guarantee: even a renderer that ignores its AbortSignal
|
|
141
|
-
// cannot hang the call
|
|
142
|
-
//
|
|
150
|
+
// cannot hang the call unless the user explicitly disables the timeout.
|
|
151
|
+
// The deadline and caller's cancellation are raced independently against
|
|
152
|
+
// the renderer; a late renderer result is ignored.
|
|
143
153
|
let race: DeadlineRace;
|
|
144
154
|
try {
|
|
145
|
-
race = await raceWithDeadline(
|
|
146
|
-
|
|
155
|
+
race = await raceWithDeadline(
|
|
156
|
+
renderPromise, deadline.remainingMs(), signal,
|
|
157
|
+
() => deadlineController.abort(), timeoutCancelled.signal,
|
|
147
158
|
);
|
|
148
159
|
} finally {
|
|
149
160
|
// Always release the listeners installed on the caller's signal.
|
|
@@ -178,7 +189,8 @@ type DeadlineRace =
|
|
|
178
189
|
*
|
|
179
190
|
* This is what makes the timeout guarantee independent of renderer behaviour: a
|
|
180
191
|
* renderer that never resolves (or that ignores its signal) still yields
|
|
181
|
-
* `timeout
|
|
192
|
+
* `timeout`, unless the user explicitly cancels the timeout. Late results are
|
|
193
|
+
* discarded, and the rejection path is always
|
|
182
194
|
* handled so a late failure cannot become an unhandled rejection.
|
|
183
195
|
*/
|
|
184
196
|
function raceWithDeadline(
|
|
@@ -186,15 +198,23 @@ function raceWithDeadline(
|
|
|
186
198
|
remainingMs: number,
|
|
187
199
|
callerSignal: AbortSignal | undefined,
|
|
188
200
|
onDeadline: () => void,
|
|
201
|
+
timeoutCancelled: AbortSignal,
|
|
189
202
|
): Promise<DeadlineRace> {
|
|
190
203
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
191
204
|
let abortHandler: (() => void) | undefined;
|
|
205
|
+
const clearDeadlineTimer = (): void => {
|
|
206
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
207
|
+
timer = undefined;
|
|
208
|
+
};
|
|
192
209
|
const racers: Array<Promise<DeadlineRace>> = [
|
|
193
210
|
render.then(
|
|
194
211
|
(outcome): DeadlineRace => ({ kind: "outcome", outcome }),
|
|
195
212
|
(error): DeadlineRace => ({ kind: "outcome", outcome: { kind: "error", message: messageOf(error) } }),
|
|
196
213
|
),
|
|
197
214
|
new Promise<DeadlineRace>((resolve) => {
|
|
215
|
+
// Cancellation may happen synchronously during renderer construction.
|
|
216
|
+
if (timeoutCancelled.aborted) return;
|
|
217
|
+
timeoutCancelled.addEventListener("abort", clearDeadlineTimer, { once: true });
|
|
198
218
|
// `safeTimeoutMs` prevents a >32-bit delay from firing immediately.
|
|
199
219
|
timer = setTimeout(() => {
|
|
200
220
|
onDeadline();
|
|
@@ -215,7 +235,8 @@ function raceWithDeadline(
|
|
|
215
235
|
);
|
|
216
236
|
}
|
|
217
237
|
return Promise.race(racers).finally(() => {
|
|
218
|
-
|
|
238
|
+
clearDeadlineTimer();
|
|
239
|
+
timeoutCancelled.removeEventListener("abort", clearDeadlineTimer);
|
|
219
240
|
// Remove the listener when the render settled first, so repeated calls do
|
|
220
241
|
// not accumulate listeners on a long-lived caller signal.
|
|
221
242
|
if (abortHandler && callerSignal) callerSignal.removeEventListener("abort", abortHandler);
|
|
@@ -252,7 +273,15 @@ function mapOutcome(
|
|
|
252
273
|
): AskUserResult {
|
|
253
274
|
switch (outcome.kind) {
|
|
254
275
|
case "submitted": {
|
|
255
|
-
|
|
276
|
+
if (!Array.isArray(outcome.answers)) {
|
|
277
|
+
return {
|
|
278
|
+
status: "error",
|
|
279
|
+
route,
|
|
280
|
+
answers: [],
|
|
281
|
+
error: { code: "internal", message: `${route} UI returned a malformed answers payload.` },
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
const answers = outcome.answers;
|
|
256
285
|
if (answers.length !== request.questions.length) {
|
|
257
286
|
return {
|
|
258
287
|
status: "error",
|
|
@@ -264,15 +293,23 @@ function mapOutcome(
|
|
|
264
293
|
},
|
|
265
294
|
};
|
|
266
295
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
296
|
+
for (let index = 0; index < answers.length; index += 1) {
|
|
297
|
+
const answer = answers[index]!;
|
|
298
|
+
let invalid: string | undefined;
|
|
299
|
+
try {
|
|
300
|
+
invalid = validateAnswer(request.questions[index]!, answer);
|
|
301
|
+
} catch (error) {
|
|
302
|
+
invalid = `answer validation failed: ${messageOf(error)}`;
|
|
303
|
+
}
|
|
304
|
+
if (!invalid) continue;
|
|
271
305
|
return {
|
|
272
306
|
status: "error",
|
|
273
307
|
route,
|
|
274
308
|
answers,
|
|
275
|
-
error: {
|
|
309
|
+
error: {
|
|
310
|
+
code: invalid.includes("empty") || invalid.includes("missing") ? "empty_answer" : "internal",
|
|
311
|
+
message: `The ${route} UI returned an invalid answer for question "${request.questions[index]!.title}": ${invalid}.`,
|
|
312
|
+
},
|
|
276
313
|
};
|
|
277
314
|
}
|
|
278
315
|
return { status: "answered", route, answers };
|
package/src/deadline.ts
CHANGED
|
@@ -13,27 +13,43 @@ export function safeTimeoutMs(ms: number): number {
|
|
|
13
13
|
return Math.min(Math.floor(ms), MAX_SAFE_TIMEOUT_MS);
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/** A shared deadline that the user can disable for the current questionnaire. */
|
|
17
|
+
export interface CancellableDeadline extends Deadline {
|
|
18
|
+
/** Disable expiry. Returns false if the deadline has already expired. */
|
|
19
|
+
cancel(): boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
16
22
|
/**
|
|
17
23
|
* Create a single overall deadline for a questionnaire.
|
|
18
24
|
*
|
|
19
|
-
* The deadline is
|
|
25
|
+
* The initial deadline is `totalMs` (which callers compute as
|
|
20
26
|
* `timeoutPerQuestionMs × questionCount`). It is clamped to
|
|
21
27
|
* {@link MAX_SAFE_TIMEOUT_MS} so `setTimeout` never overflows. Every interaction
|
|
22
28
|
* for the questionnaire shares this one deadline; native dialogs receive only
|
|
23
|
-
* the remaining time.
|
|
29
|
+
* the remaining time. `cancel()` disables expiry but does not clear any timers;
|
|
30
|
+
* the core and custom UI clear their timers separately. A deadline never
|
|
31
|
+
* degrades to another route.
|
|
24
32
|
*/
|
|
25
|
-
export function createDeadline(totalMs: number, now: () => number = Date.now):
|
|
33
|
+
export function createDeadline(totalMs: number, now: () => number = Date.now): CancellableDeadline {
|
|
26
34
|
const startedAt = now();
|
|
27
35
|
const safeTotal = safeTimeoutMs(totalMs);
|
|
28
36
|
const expiresAt = safeTotal === 0 ? startedAt : startedAt + safeTotal;
|
|
37
|
+
let cancelled = false;
|
|
29
38
|
return {
|
|
30
39
|
startedAt,
|
|
31
|
-
expiresAt
|
|
40
|
+
get expiresAt(): number {
|
|
41
|
+
return cancelled ? Infinity : expiresAt;
|
|
42
|
+
},
|
|
32
43
|
remainingMs(): number {
|
|
33
|
-
return Math.max(0, expiresAt - now());
|
|
44
|
+
return cancelled ? Infinity : Math.max(0, expiresAt - now());
|
|
34
45
|
},
|
|
35
46
|
expired(): boolean {
|
|
36
|
-
return now() >= expiresAt;
|
|
47
|
+
return !cancelled && now() >= expiresAt;
|
|
48
|
+
},
|
|
49
|
+
cancel(): boolean {
|
|
50
|
+
if (!cancelled && now() >= expiresAt) return false;
|
|
51
|
+
cancelled = true;
|
|
52
|
+
return true;
|
|
37
53
|
},
|
|
38
54
|
};
|
|
39
55
|
}
|
package/src/index.ts
CHANGED
|
@@ -38,6 +38,7 @@ export {
|
|
|
38
38
|
DEFAULT_TIMEOUT_PER_QUESTION_MS,
|
|
39
39
|
LIMITS,
|
|
40
40
|
MAX_OPTIONS,
|
|
41
|
+
MAX_FORM_FIELDS,
|
|
41
42
|
MAX_QUESTIONS,
|
|
42
43
|
normalizeAskUserRequest,
|
|
43
44
|
} from "./schema.ts";
|
|
@@ -77,6 +78,7 @@ export {
|
|
|
77
78
|
emptyDraft,
|
|
78
79
|
finalizeAnswers,
|
|
79
80
|
toAnswer,
|
|
81
|
+
validateAnswer,
|
|
80
82
|
type DraftAnswer,
|
|
81
83
|
type FinalizeResult,
|
|
82
84
|
} from "./answers.ts";
|
|
@@ -98,7 +100,7 @@ export interface AskUserToolDetails {
|
|
|
98
100
|
progress?: string;
|
|
99
101
|
}
|
|
100
102
|
|
|
101
|
-
const DESCRIPTION = "Ask the user
|
|
103
|
+
const DESCRIPTION = "Ask the user structured single-choice, multi-choice, text, boolean, or flat form questions through the host's interactive UI.";
|
|
102
104
|
|
|
103
105
|
const PROMPT_SNIPPET = "Ask the user structured questions when you would otherwise guess.";
|
|
104
106
|
|
|
@@ -106,6 +108,7 @@ const PROMPT_GUIDELINES = [
|
|
|
106
108
|
"Use ask_user when a choice is high-impact or ambiguous and you cannot infer the answer.",
|
|
107
109
|
"If the tool reports the UI could not run, ask the user the question yourself in ordinary text instead.",
|
|
108
110
|
"Never invent an answer; on aborted or timeout, report it plainly and do not re-ask.",
|
|
111
|
+
"Use kind=bool for a real boolean. Use kind=simple-form with flat uniquely named fields when several typed values belong in one logical question; forms cannot nest.",
|
|
109
112
|
];
|
|
110
113
|
|
|
111
114
|
/**
|
|
@@ -136,6 +139,12 @@ function describeRequestAsText(request: NormalizedRequest): string {
|
|
|
136
139
|
const description = option.description?.trim();
|
|
137
140
|
lines.push(` ${optionIndex + 1}) ${option.label}${description ? ` — ${description}` : ""}`);
|
|
138
141
|
});
|
|
142
|
+
if (question.kind === "bool") lines.push(" answer: true or false");
|
|
143
|
+
for (const field of question.fields ?? []) {
|
|
144
|
+
lines.push(` - ${field.label} [${field.name}: ${field.kind}]${field.required ? " required" : " optional"}`);
|
|
145
|
+
field.options.forEach((option, optionIndex) => lines.push(` ${optionIndex + 1}) ${option.label}`));
|
|
146
|
+
if (field.hasDefault) lines.push(` default: ${JSON.stringify(field.default)}`);
|
|
147
|
+
}
|
|
139
148
|
if (question.hasDefault && question.default !== undefined) lines.push(` default: ${question.default}`);
|
|
140
149
|
});
|
|
141
150
|
return lines.join("\n");
|
|
@@ -151,6 +160,10 @@ function buildModelContent(result: AskUserResult, request: NormalizedRequest): s
|
|
|
151
160
|
switch (result.status) {
|
|
152
161
|
case "answered": {
|
|
153
162
|
const lines = result.answers.map((answer) => {
|
|
163
|
+
if (answer.value !== undefined) {
|
|
164
|
+
const suffix = answer.usedDefault ? " (default used)" : "";
|
|
165
|
+
return `- ${answer.title}: ${JSON.stringify(answer.value)}${suffix}`;
|
|
166
|
+
}
|
|
154
167
|
const parts: string[] = [];
|
|
155
168
|
if (answer.selections.length > 0) parts.push(answer.selections.join(", "));
|
|
156
169
|
if (answer.freeText) parts.push(answer.freeText);
|
|
@@ -211,7 +224,9 @@ export interface RegisterAskUserOptions {
|
|
|
211
224
|
timeoutPerQuestionMs?: number;
|
|
212
225
|
/**
|
|
213
226
|
* Path to the user config file, or `false` to skip reading it. Defaults to
|
|
214
|
-
* `~/.pi/ask-user
|
|
227
|
+
* `<Pi agent directory>/ask-user.json` (normally `~/.pi/agent/ask-user.json`).
|
|
228
|
+
* The directory follows Pi's `PI_CODING_AGENT_DIR`; there are no AskUser-specific
|
|
229
|
+
* environment-variable preferences.
|
|
215
230
|
*/
|
|
216
231
|
configFile?: string | false;
|
|
217
232
|
}
|
package/src/parse.ts
CHANGED
|
@@ -54,6 +54,9 @@ export function parseNativeAnswer(question: NormalizedQuestion, raw: string): Pa
|
|
|
54
54
|
if (question.kind === "input") {
|
|
55
55
|
return { ok: true, value: { selections: [], freeText: trimmed, empty: false } };
|
|
56
56
|
}
|
|
57
|
+
if (question.kind === "bool" || question.kind === "simple-form") {
|
|
58
|
+
return { ok: false, error: `${question.kind} uses typed native input and cannot be parsed as a legacy answer.` };
|
|
59
|
+
}
|
|
57
60
|
|
|
58
61
|
const pipeIndex = raw.indexOf("|");
|
|
59
62
|
const left = pipeIndex >= 0 ? raw.slice(0, pipeIndex) : raw;
|
|
@@ -115,5 +118,9 @@ export function nativeInputHint(question: NormalizedQuestion): string {
|
|
|
115
118
|
: "Free input. Reply with text to answer.";
|
|
116
119
|
case "input":
|
|
117
120
|
return "Free input. Reply with text to answer.";
|
|
121
|
+
case "bool":
|
|
122
|
+
return "Boolean. Reply with true or false.";
|
|
123
|
+
case "simple-form":
|
|
124
|
+
return "Form fields are asked one at a time.";
|
|
118
125
|
}
|
|
119
126
|
}
|