@hachej/boring-ask-user 0.1.13
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 +266 -0
- package/dist/front/index.d.ts +17 -0
- package/dist/front/index.js +546 -0
- package/dist/server/index.d.ts +205 -0
- package/dist/server/index.js +1011 -0
- package/dist/shared/index.d.ts +3443 -0
- package/dist/shared/index.js +305 -0
- package/dist/types-CF72YmK-.d.ts +193 -0
- package/package.json +72 -0
|
@@ -0,0 +1,1011 @@
|
|
|
1
|
+
// src/server/askUserServerPlugin.ts
|
|
2
|
+
import { join as join2 } from "path";
|
|
3
|
+
import { defineServerPlugin } from "@hachej/boring-workspace/server";
|
|
4
|
+
|
|
5
|
+
// src/shared/constants.ts
|
|
6
|
+
var ASK_USER_PLUGIN_ID = "ask-user";
|
|
7
|
+
var ASK_USER_SURFACE_KIND = "questions";
|
|
8
|
+
var ASK_USER_COMMAND_KINDS = {
|
|
9
|
+
SUBMIT: "questions.submit",
|
|
10
|
+
CANCEL: "questions.cancel"
|
|
11
|
+
};
|
|
12
|
+
var ASK_USER_UI_STATE_SLOTS = {
|
|
13
|
+
PENDING: "questions.pending"
|
|
14
|
+
};
|
|
15
|
+
var ASK_USER_SCHEMA_LIMITS = {
|
|
16
|
+
maxFields: 8,
|
|
17
|
+
maxOptionsPerField: 50,
|
|
18
|
+
maxFieldNameLength: 64,
|
|
19
|
+
maxTitleLength: 200,
|
|
20
|
+
maxLabelLength: 160,
|
|
21
|
+
maxHelpTextLength: 500,
|
|
22
|
+
maxContextLength: 4e3,
|
|
23
|
+
maxSerializedSchemaBytes: 32e3,
|
|
24
|
+
maxFreeformAnswerLength: 4e3,
|
|
25
|
+
minTimeoutMs: 1e3,
|
|
26
|
+
maxTimeoutMs: 30 * 6e4,
|
|
27
|
+
defaultTimeoutMs: 10 * 6e4
|
|
28
|
+
};
|
|
29
|
+
var ASK_USER_FIELD_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
30
|
+
var ASK_USER_RESERVED_FIELD_NAMES = /* @__PURE__ */ new Set([
|
|
31
|
+
"__proto__",
|
|
32
|
+
"prototype",
|
|
33
|
+
"constructor"
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
// src/server/askUserRuntime.ts
|
|
37
|
+
import { randomBytes, randomUUID } from "crypto";
|
|
38
|
+
|
|
39
|
+
// src/shared/error-codes.ts
|
|
40
|
+
var ASK_USER_ERROR_CODES = {
|
|
41
|
+
PENDING_EXISTS: "ASK_USER_PENDING_EXISTS",
|
|
42
|
+
QUESTION_NOT_FOUND: "ASK_USER_QUESTION_NOT_FOUND",
|
|
43
|
+
QUESTION_ABANDONED: "ASK_USER_QUESTION_ABANDONED",
|
|
44
|
+
QUESTION_NOT_READY: "ASK_USER_QUESTION_NOT_READY",
|
|
45
|
+
SCHEMA_INVALID: "ASK_USER_SCHEMA_INVALID",
|
|
46
|
+
ANSWER_INVALID: "ASK_USER_ANSWER_INVALID",
|
|
47
|
+
SESSION_MISMATCH: "ASK_USER_SESSION_MISMATCH",
|
|
48
|
+
UI_UNAVAILABLE: "ASK_USER_UI_UNAVAILABLE",
|
|
49
|
+
ALREADY_ANSWERED: "ASK_USER_ALREADY_ANSWERED",
|
|
50
|
+
ALREADY_CANCELLED: "ASK_USER_ALREADY_CANCELLED",
|
|
51
|
+
UNAUTHORIZED: "ASK_USER_UNAUTHORIZED",
|
|
52
|
+
UI_ACK_TIMEOUT: "ASK_USER_UI_ACK_TIMEOUT",
|
|
53
|
+
RATE_LIMITED: "ASK_USER_RATE_LIMITED",
|
|
54
|
+
RUNTIME_UNAVAILABLE: "ASK_USER_RUNTIME_UNAVAILABLE"
|
|
55
|
+
};
|
|
56
|
+
var ASK_USER_ERROR_CODE_VALUES = Object.values(ASK_USER_ERROR_CODES);
|
|
57
|
+
|
|
58
|
+
// src/shared/schema.ts
|
|
59
|
+
import { z } from "zod";
|
|
60
|
+
var isoStringSchema = z.string().min(1);
|
|
61
|
+
var fieldNameSchema = z.string().regex(ASK_USER_FIELD_NAME_PATTERN, "field name must match ^[A-Za-z][A-Za-z0-9_-]{0,63}$").refine((name) => !ASK_USER_RESERVED_FIELD_NAMES.has(name), "field name is reserved");
|
|
62
|
+
var boundedString = (max) => z.string().max(max);
|
|
63
|
+
var optionalBoundedString = (max) => boundedString(max).optional();
|
|
64
|
+
var askUserOptionSchema = z.object({
|
|
65
|
+
value: z.string().min(1).max(ASK_USER_SCHEMA_LIMITS.maxLabelLength),
|
|
66
|
+
label: boundedString(ASK_USER_SCHEMA_LIMITS.maxLabelLength),
|
|
67
|
+
description: optionalBoundedString(ASK_USER_SCHEMA_LIMITS.maxHelpTextLength)
|
|
68
|
+
}).strict();
|
|
69
|
+
var baseFieldSchema = {
|
|
70
|
+
name: fieldNameSchema,
|
|
71
|
+
label: boundedString(ASK_USER_SCHEMA_LIMITS.maxLabelLength),
|
|
72
|
+
required: z.boolean().optional(),
|
|
73
|
+
helpText: optionalBoundedString(ASK_USER_SCHEMA_LIMITS.maxHelpTextLength)
|
|
74
|
+
};
|
|
75
|
+
function safePattern(pattern) {
|
|
76
|
+
try {
|
|
77
|
+
if (/\\[1-9]/.test(pattern)) return false;
|
|
78
|
+
if (/\(\?([=!<]|<=|<!)/.test(pattern)) return false;
|
|
79
|
+
new RegExp(pattern);
|
|
80
|
+
return true;
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
var textFieldSchema = z.object({
|
|
86
|
+
type: z.literal("text"),
|
|
87
|
+
...baseFieldSchema,
|
|
88
|
+
placeholder: optionalBoundedString(ASK_USER_SCHEMA_LIMITS.maxLabelLength),
|
|
89
|
+
defaultValue: optionalBoundedString(ASK_USER_SCHEMA_LIMITS.maxFreeformAnswerLength),
|
|
90
|
+
minLength: z.number().int().min(0).max(ASK_USER_SCHEMA_LIMITS.maxFreeformAnswerLength).optional(),
|
|
91
|
+
maxLength: z.number().int().min(0).max(ASK_USER_SCHEMA_LIMITS.maxFreeformAnswerLength).optional(),
|
|
92
|
+
pattern: z.string().max(512).refine(safePattern, "pattern must be safe and valid").optional()
|
|
93
|
+
}).strict().superRefine((field, ctx) => {
|
|
94
|
+
if (field.minLength !== void 0 && field.maxLength !== void 0 && field.minLength > field.maxLength) {
|
|
95
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["minLength"], message: "minLength must be <= maxLength" });
|
|
96
|
+
}
|
|
97
|
+
if (field.defaultValue !== void 0) {
|
|
98
|
+
if (field.minLength !== void 0 && field.defaultValue.length < field.minLength) {
|
|
99
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["defaultValue"], message: "defaultValue shorter than minLength" });
|
|
100
|
+
}
|
|
101
|
+
if (field.maxLength !== void 0 && field.defaultValue.length > field.maxLength) {
|
|
102
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["defaultValue"], message: "defaultValue longer than maxLength" });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
var textareaFieldSchema = z.object({
|
|
107
|
+
type: z.literal("textarea"),
|
|
108
|
+
...baseFieldSchema,
|
|
109
|
+
placeholder: optionalBoundedString(ASK_USER_SCHEMA_LIMITS.maxLabelLength),
|
|
110
|
+
defaultValue: optionalBoundedString(ASK_USER_SCHEMA_LIMITS.maxFreeformAnswerLength),
|
|
111
|
+
minLength: z.number().int().min(0).max(ASK_USER_SCHEMA_LIMITS.maxFreeformAnswerLength).optional(),
|
|
112
|
+
maxLength: z.number().int().min(0).max(ASK_USER_SCHEMA_LIMITS.maxFreeformAnswerLength).optional()
|
|
113
|
+
}).strict().superRefine((field, ctx) => {
|
|
114
|
+
if (field.minLength !== void 0 && field.maxLength !== void 0 && field.minLength > field.maxLength) {
|
|
115
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["minLength"], message: "minLength must be <= maxLength" });
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
function optionsRefinement(field, ctx) {
|
|
119
|
+
const seen = /* @__PURE__ */ new Set();
|
|
120
|
+
for (let i = 0; i < field.options.length; i++) {
|
|
121
|
+
const value = field.options[i]?.value;
|
|
122
|
+
if (seen.has(value)) {
|
|
123
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["options", i, "value"], message: "duplicate option value" });
|
|
124
|
+
}
|
|
125
|
+
seen.add(value);
|
|
126
|
+
}
|
|
127
|
+
if (field.defaultValue !== void 0 && !seen.has(field.defaultValue)) {
|
|
128
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["defaultValue"], message: "defaultValue must reference an option value" });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
var selectFieldSchema = z.object({
|
|
132
|
+
type: z.literal("select"),
|
|
133
|
+
...baseFieldSchema,
|
|
134
|
+
options: z.array(askUserOptionSchema).min(2).max(ASK_USER_SCHEMA_LIMITS.maxOptionsPerField),
|
|
135
|
+
defaultValue: z.string().optional()
|
|
136
|
+
}).strict().superRefine(optionsRefinement);
|
|
137
|
+
var radioFieldSchema = z.object({
|
|
138
|
+
type: z.literal("radio"),
|
|
139
|
+
...baseFieldSchema,
|
|
140
|
+
options: z.array(askUserOptionSchema).min(2).max(ASK_USER_SCHEMA_LIMITS.maxOptionsPerField),
|
|
141
|
+
defaultValue: z.string().optional()
|
|
142
|
+
}).strict().superRefine(optionsRefinement);
|
|
143
|
+
var multiselectFieldSchema = z.object({
|
|
144
|
+
type: z.literal("multiselect"),
|
|
145
|
+
...baseFieldSchema,
|
|
146
|
+
options: z.array(askUserOptionSchema).min(1).max(ASK_USER_SCHEMA_LIMITS.maxOptionsPerField),
|
|
147
|
+
defaultValue: z.array(z.string()).optional(),
|
|
148
|
+
minSelections: z.number().int().min(0).optional(),
|
|
149
|
+
maxSelections: z.number().int().min(0).optional()
|
|
150
|
+
}).strict().superRefine((field, ctx) => {
|
|
151
|
+
const values = /* @__PURE__ */ new Set();
|
|
152
|
+
for (let i = 0; i < field.options.length; i++) {
|
|
153
|
+
const value = field.options[i]?.value;
|
|
154
|
+
if (values.has(value)) {
|
|
155
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["options", i, "value"], message: "duplicate option value" });
|
|
156
|
+
}
|
|
157
|
+
values.add(value);
|
|
158
|
+
}
|
|
159
|
+
if (field.defaultValue) {
|
|
160
|
+
for (const value of field.defaultValue) {
|
|
161
|
+
if (!values.has(value)) {
|
|
162
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["defaultValue"], message: "defaultValue must reference option values" });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (field.minSelections !== void 0 && field.maxSelections !== void 0 && field.minSelections > field.maxSelections) {
|
|
167
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["minSelections"], message: "minSelections must be <= maxSelections" });
|
|
168
|
+
}
|
|
169
|
+
if (field.maxSelections !== void 0 && field.maxSelections > field.options.length) {
|
|
170
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["maxSelections"], message: "maxSelections must be <= options.length" });
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
var checkboxFieldSchema = z.object({
|
|
174
|
+
type: z.literal("checkbox"),
|
|
175
|
+
name: fieldNameSchema,
|
|
176
|
+
label: boundedString(ASK_USER_SCHEMA_LIMITS.maxLabelLength),
|
|
177
|
+
defaultValue: z.boolean().optional(),
|
|
178
|
+
helpText: optionalBoundedString(ASK_USER_SCHEMA_LIMITS.maxHelpTextLength)
|
|
179
|
+
}).strict();
|
|
180
|
+
var numberFieldSchema = z.object({
|
|
181
|
+
type: z.literal("number"),
|
|
182
|
+
...baseFieldSchema,
|
|
183
|
+
defaultValue: z.number().finite().optional(),
|
|
184
|
+
placeholder: optionalBoundedString(ASK_USER_SCHEMA_LIMITS.maxLabelLength),
|
|
185
|
+
min: z.number().finite().optional(),
|
|
186
|
+
max: z.number().finite().optional(),
|
|
187
|
+
step: z.number().finite().positive().optional(),
|
|
188
|
+
integer: z.boolean().optional()
|
|
189
|
+
}).strict().superRefine((field, ctx) => {
|
|
190
|
+
if (field.min !== void 0 && field.max !== void 0 && field.min > field.max) {
|
|
191
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["min"], message: "min must be <= max" });
|
|
192
|
+
}
|
|
193
|
+
if (field.defaultValue !== void 0) {
|
|
194
|
+
if (field.integer && !Number.isInteger(field.defaultValue)) {
|
|
195
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["defaultValue"], message: "defaultValue must be an integer" });
|
|
196
|
+
}
|
|
197
|
+
if (field.min !== void 0 && field.defaultValue < field.min) {
|
|
198
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["defaultValue"], message: "defaultValue must be >= min" });
|
|
199
|
+
}
|
|
200
|
+
if (field.max !== void 0 && field.defaultValue > field.max) {
|
|
201
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["defaultValue"], message: "defaultValue must be <= max" });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
var AskUserFieldSchema = z.union([
|
|
206
|
+
textFieldSchema,
|
|
207
|
+
textareaFieldSchema,
|
|
208
|
+
selectFieldSchema,
|
|
209
|
+
multiselectFieldSchema,
|
|
210
|
+
checkboxFieldSchema,
|
|
211
|
+
radioFieldSchema,
|
|
212
|
+
numberFieldSchema
|
|
213
|
+
]);
|
|
214
|
+
var AskUserFormSchemaSchema = z.object({
|
|
215
|
+
wireVersion: z.literal(1),
|
|
216
|
+
fields: z.array(AskUserFieldSchema).min(1).max(ASK_USER_SCHEMA_LIMITS.maxFields),
|
|
217
|
+
submitLabel: optionalBoundedString(ASK_USER_SCHEMA_LIMITS.maxLabelLength)
|
|
218
|
+
}).strict().superRefine((schema, ctx) => {
|
|
219
|
+
const names = /* @__PURE__ */ new Set();
|
|
220
|
+
for (let i = 0; i < schema.fields.length; i++) {
|
|
221
|
+
const name = schema.fields[i]?.name;
|
|
222
|
+
if (names.has(name)) {
|
|
223
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["fields", i, "name"], message: "duplicate field name" });
|
|
224
|
+
}
|
|
225
|
+
names.add(name);
|
|
226
|
+
}
|
|
227
|
+
if (serializedSize(schema) > ASK_USER_SCHEMA_LIMITS.maxSerializedSchemaBytes) {
|
|
228
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "schema exceeds max serialized size" });
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
var AskUserToolInputSchema = z.object({
|
|
232
|
+
title: boundedString(ASK_USER_SCHEMA_LIMITS.maxTitleLength).min(1),
|
|
233
|
+
context: optionalBoundedString(ASK_USER_SCHEMA_LIMITS.maxContextLength),
|
|
234
|
+
schema: AskUserFormSchemaSchema,
|
|
235
|
+
timeoutMs: z.number().int().min(ASK_USER_SCHEMA_LIMITS.minTimeoutMs).max(ASK_USER_SCHEMA_LIMITS.maxTimeoutMs).optional()
|
|
236
|
+
}).strict();
|
|
237
|
+
var AskUserRequestSchema = z.object({
|
|
238
|
+
sessionId: z.string().min(1),
|
|
239
|
+
title: boundedString(ASK_USER_SCHEMA_LIMITS.maxTitleLength).optional(),
|
|
240
|
+
context: optionalBoundedString(ASK_USER_SCHEMA_LIMITS.maxContextLength),
|
|
241
|
+
schema: AskUserFormSchemaSchema.optional(),
|
|
242
|
+
timeoutMs: z.number().int().min(ASK_USER_SCHEMA_LIMITS.minTimeoutMs).max(ASK_USER_SCHEMA_LIMITS.maxTimeoutMs).optional()
|
|
243
|
+
}).strict();
|
|
244
|
+
var AskUserAnswerValueSchema = z.union([
|
|
245
|
+
z.string().max(ASK_USER_SCHEMA_LIMITS.maxFreeformAnswerLength),
|
|
246
|
+
z.array(z.string()).max(ASK_USER_SCHEMA_LIMITS.maxOptionsPerField),
|
|
247
|
+
z.boolean(),
|
|
248
|
+
z.number().finite(),
|
|
249
|
+
z.null()
|
|
250
|
+
]);
|
|
251
|
+
var AskUserAnswerSchema = z.object({
|
|
252
|
+
questionId: z.string().min(1),
|
|
253
|
+
sessionId: z.string().min(1),
|
|
254
|
+
values: z.record(fieldNameSchema, AskUserAnswerValueSchema),
|
|
255
|
+
submittedAt: isoStringSchema
|
|
256
|
+
}).strict();
|
|
257
|
+
var commandParamsBase = {
|
|
258
|
+
questionId: z.string().min(1),
|
|
259
|
+
sessionId: z.string().min(1)
|
|
260
|
+
};
|
|
261
|
+
var QuestionsSubmitCommandSchema = z.object({
|
|
262
|
+
kind: z.literal(ASK_USER_COMMAND_KINDS.SUBMIT),
|
|
263
|
+
params: z.object({
|
|
264
|
+
...commandParamsBase,
|
|
265
|
+
answerToken: z.string().min(1),
|
|
266
|
+
values: z.record(fieldNameSchema, AskUserAnswerValueSchema)
|
|
267
|
+
}).strict()
|
|
268
|
+
}).strict();
|
|
269
|
+
var QuestionsCancelCommandSchema = z.object({
|
|
270
|
+
kind: z.literal(ASK_USER_COMMAND_KINDS.CANCEL),
|
|
271
|
+
params: z.object({ ...commandParamsBase, answerToken: z.string().min(1) }).strict()
|
|
272
|
+
}).strict();
|
|
273
|
+
var QuestionsCommandSchema = z.discriminatedUnion("kind", [
|
|
274
|
+
QuestionsSubmitCommandSchema,
|
|
275
|
+
QuestionsCancelCommandSchema
|
|
276
|
+
]);
|
|
277
|
+
function serializedSize(value) {
|
|
278
|
+
return new TextEncoder().encode(JSON.stringify(value)).length;
|
|
279
|
+
}
|
|
280
|
+
function validateAskUserToolInput(value) {
|
|
281
|
+
return AskUserToolInputSchema.safeParse(value);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// src/server/askUserRuntime.ts
|
|
285
|
+
var AskUserRuntimeError = class extends Error {
|
|
286
|
+
constructor(code, message) {
|
|
287
|
+
super(message);
|
|
288
|
+
this.code = code;
|
|
289
|
+
}
|
|
290
|
+
code;
|
|
291
|
+
};
|
|
292
|
+
var InProcessAskUserCoordinator = class {
|
|
293
|
+
waiters = /* @__PURE__ */ new Map();
|
|
294
|
+
registerWaiter(questionId, sessionId, signal) {
|
|
295
|
+
if (this.waiters.has(questionId)) {
|
|
296
|
+
throw new AskUserRuntimeError(ASK_USER_ERROR_CODES.PENDING_EXISTS, `waiter already exists for ${questionId}`);
|
|
297
|
+
}
|
|
298
|
+
if (signal?.aborted) return Promise.resolve({ status: "cancelled", questionId, sessionId, reason: "aborted" });
|
|
299
|
+
return new Promise((resolve) => {
|
|
300
|
+
const onAbort = () => this.resolveCancelled(questionId, "aborted");
|
|
301
|
+
const waiter = {
|
|
302
|
+
sessionId,
|
|
303
|
+
settled: false,
|
|
304
|
+
resolve,
|
|
305
|
+
cleanup: () => signal?.removeEventListener("abort", onAbort)
|
|
306
|
+
};
|
|
307
|
+
this.waiters.set(questionId, waiter);
|
|
308
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
hasWaiter(questionId) {
|
|
312
|
+
return this.waiters.has(questionId);
|
|
313
|
+
}
|
|
314
|
+
resolveAnswered(questionId, answer) {
|
|
315
|
+
return this.resolve(questionId, { status: "answered", answer });
|
|
316
|
+
}
|
|
317
|
+
resolveCancelled(questionId, reason) {
|
|
318
|
+
const waiter = this.waiters.get(questionId);
|
|
319
|
+
const sessionId = waiter?.sessionId ?? "";
|
|
320
|
+
return this.resolve(questionId, { status: "cancelled", questionId, sessionId, reason });
|
|
321
|
+
}
|
|
322
|
+
resolve(questionId, result) {
|
|
323
|
+
const waiter = this.waiters.get(questionId);
|
|
324
|
+
if (!waiter || waiter.settled) return false;
|
|
325
|
+
waiter.settled = true;
|
|
326
|
+
this.waiters.delete(questionId);
|
|
327
|
+
waiter.cleanup();
|
|
328
|
+
waiter.resolve(result);
|
|
329
|
+
return true;
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
var AskUserRuntime = class {
|
|
333
|
+
coordinator;
|
|
334
|
+
store;
|
|
335
|
+
ownerPrincipalId;
|
|
336
|
+
now;
|
|
337
|
+
perSessionPerMinute;
|
|
338
|
+
perPrincipalPerHour;
|
|
339
|
+
uiBridge;
|
|
340
|
+
sessionBuckets = /* @__PURE__ */ new Map();
|
|
341
|
+
principalBuckets = /* @__PURE__ */ new Map();
|
|
342
|
+
constructor(options) {
|
|
343
|
+
this.store = options.store;
|
|
344
|
+
this.coordinator = options.coordinator ?? new InProcessAskUserCoordinator();
|
|
345
|
+
this.ownerPrincipalId = options.ownerPrincipalId ?? "anonymous";
|
|
346
|
+
this.now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
347
|
+
this.perSessionPerMinute = options.limits?.perSessionPerMinute ?? 6;
|
|
348
|
+
this.perPrincipalPerHour = options.limits?.perPrincipalPerHour ?? 30;
|
|
349
|
+
this.uiBridge = options.uiBridge;
|
|
350
|
+
}
|
|
351
|
+
async abandonOrphanedPending(sessionIds) {
|
|
352
|
+
for (const sessionId of sessionIds) {
|
|
353
|
+
const pending = await this.store.getPending(sessionId);
|
|
354
|
+
if (pending && !this.coordinator.hasWaiter(pending.questionId)) {
|
|
355
|
+
await this.abandon(pending.questionId, pending.sessionId);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
async ask(request, signal) {
|
|
360
|
+
this.assertAllowed(request.sessionId);
|
|
361
|
+
const question = this.createQuestion(request);
|
|
362
|
+
const parsed = AskUserFormSchemaSchema.safeParse(request.schema);
|
|
363
|
+
if (!parsed.success) throw new AskUserRuntimeError(ASK_USER_ERROR_CODES.SCHEMA_INVALID, parsed.error.message);
|
|
364
|
+
question.schema = parsed.data;
|
|
365
|
+
await this.store.createPending(question);
|
|
366
|
+
await this.store.appendTranscriptEvent({ type: "created", question, at: this.isoNow() });
|
|
367
|
+
await this.store.appendTranscriptEvent({ type: "ready", questionId: question.questionId, sessionId: question.sessionId, schema: parsed.data, at: this.isoNow() });
|
|
368
|
+
return this.waitForAnswerWithOpen(question, request.timeoutMs, signal);
|
|
369
|
+
}
|
|
370
|
+
async submitAnswer(questionId, sessionId, values) {
|
|
371
|
+
const question = await this.store.getByQuestionId(questionId);
|
|
372
|
+
if (!question || question.sessionId !== sessionId) throw new AskUserRuntimeError(ASK_USER_ERROR_CODES.QUESTION_NOT_FOUND, "question not found");
|
|
373
|
+
if (!this.coordinator.hasWaiter(questionId)) {
|
|
374
|
+
await this.abandon(questionId, sessionId);
|
|
375
|
+
return "abandoned";
|
|
376
|
+
}
|
|
377
|
+
const answer = { questionId, sessionId, values, submittedAt: this.isoNow() };
|
|
378
|
+
await this.store.answer(questionId, answer);
|
|
379
|
+
await this.store.appendTranscriptEvent({ type: "answered", answer, at: this.isoNow() });
|
|
380
|
+
this.coordinator.resolveAnswered(questionId, answer);
|
|
381
|
+
return "answered";
|
|
382
|
+
}
|
|
383
|
+
async cancelQuestion(questionId, sessionId, reason = "user_cancelled") {
|
|
384
|
+
const question = await this.store.getByQuestionId(questionId);
|
|
385
|
+
if (!question || question.sessionId !== sessionId) throw new AskUserRuntimeError(ASK_USER_ERROR_CODES.QUESTION_NOT_FOUND, "question not found");
|
|
386
|
+
if (!this.coordinator.hasWaiter(questionId)) {
|
|
387
|
+
await this.abandon(questionId, sessionId);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
await this.store.cancel(questionId);
|
|
391
|
+
await this.store.appendTranscriptEvent({ type: "cancelled", questionId, sessionId, reason, at: this.isoNow() });
|
|
392
|
+
this.coordinator.resolveCancelled(questionId, reason);
|
|
393
|
+
}
|
|
394
|
+
async waitForAnswerWithOpen(question, timeoutMs, signal) {
|
|
395
|
+
const pendingAnswer = this.waitForAnswer(question, timeoutMs, signal);
|
|
396
|
+
void this.openQuestionSurface(question);
|
|
397
|
+
return pendingAnswer;
|
|
398
|
+
}
|
|
399
|
+
async openQuestionSurface(question) {
|
|
400
|
+
if (!this.uiBridge) return;
|
|
401
|
+
try {
|
|
402
|
+
await this.uiBridge.postCommand({ kind: "openSurface", params: { kind: ASK_USER_SURFACE_KIND, target: question.questionId, meta: { question } } });
|
|
403
|
+
} catch {
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
async waitForAnswer(question, timeoutMs, signal) {
|
|
407
|
+
const controller = new AbortController();
|
|
408
|
+
const relayAbort = () => controller.abort();
|
|
409
|
+
signal?.addEventListener("abort", relayAbort, { once: true });
|
|
410
|
+
if (signal?.aborted) controller.abort();
|
|
411
|
+
const timeout = timeoutMs ? setTimeout(() => controller.abort(), timeoutMs) : void 0;
|
|
412
|
+
const result = await this.coordinator.registerWaiter(question.questionId, question.sessionId, controller.signal);
|
|
413
|
+
signal?.removeEventListener("abort", relayAbort);
|
|
414
|
+
if (timeout) clearTimeout(timeout);
|
|
415
|
+
if (result.status === "cancelled" && result.reason === "aborted") {
|
|
416
|
+
const reason = signal?.aborted ? "aborted" : "timeout";
|
|
417
|
+
try {
|
|
418
|
+
await this.store.cancel(question.questionId);
|
|
419
|
+
await this.store.appendTranscriptEvent({ type: "cancelled", questionId: question.questionId, sessionId: question.sessionId, reason, at: this.isoNow() });
|
|
420
|
+
} catch {
|
|
421
|
+
}
|
|
422
|
+
return { status: "cancelled", questionId: question.questionId, sessionId: question.sessionId, reason };
|
|
423
|
+
}
|
|
424
|
+
return result;
|
|
425
|
+
}
|
|
426
|
+
async abandon(questionId, sessionId) {
|
|
427
|
+
await this.store.markAbandoned(questionId);
|
|
428
|
+
await this.store.appendTranscriptEvent({ type: "abandoned", questionId, sessionId, at: this.isoNow() });
|
|
429
|
+
this.coordinator.resolveCancelled(questionId, "abandoned");
|
|
430
|
+
}
|
|
431
|
+
createQuestion(request) {
|
|
432
|
+
const at = this.isoNow();
|
|
433
|
+
return {
|
|
434
|
+
questionId: randomUUID(),
|
|
435
|
+
sessionId: request.sessionId,
|
|
436
|
+
ownerPrincipalId: this.ownerPrincipalId,
|
|
437
|
+
status: "ready",
|
|
438
|
+
title: request.title,
|
|
439
|
+
context: request.context,
|
|
440
|
+
answerToken: randomBytes(32).toString("base64url"),
|
|
441
|
+
createdAt: at,
|
|
442
|
+
updatedAt: at
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
assertAllowed(sessionId) {
|
|
446
|
+
const principalId = this.ownerPrincipalId;
|
|
447
|
+
if (!this.consume(this.sessionBuckets, sessionId, 6e4, this.perSessionPerMinute) || !this.consume(this.principalBuckets, principalId, 36e5, this.perPrincipalPerHour)) {
|
|
448
|
+
throw new AskUserRuntimeError(ASK_USER_ERROR_CODES.RATE_LIMITED, "ask_user rate limit exceeded");
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
consume(buckets, key, windowMs, limit) {
|
|
452
|
+
const now = this.now().getTime();
|
|
453
|
+
const bucket = buckets.get(key);
|
|
454
|
+
if (!bucket || bucket.resetAt <= now) {
|
|
455
|
+
buckets.set(key, { count: 1, resetAt: now + windowMs });
|
|
456
|
+
return true;
|
|
457
|
+
}
|
|
458
|
+
if (bucket.count >= limit) return false;
|
|
459
|
+
bucket.count += 1;
|
|
460
|
+
return true;
|
|
461
|
+
}
|
|
462
|
+
isoNow() {
|
|
463
|
+
return this.now().toISOString();
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
function requireAskUserRuntime(runtime) {
|
|
467
|
+
if (!runtime) throw new AskUserRuntimeError(ASK_USER_ERROR_CODES.RUNTIME_UNAVAILABLE, "ask_user runtime unavailable");
|
|
468
|
+
return runtime;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// src/server/askUserStore.ts
|
|
472
|
+
import { mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
473
|
+
import { dirname, join } from "path";
|
|
474
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
475
|
+
var AskUserStoreError = class extends Error {
|
|
476
|
+
constructor(code, message) {
|
|
477
|
+
super(message);
|
|
478
|
+
this.code = code;
|
|
479
|
+
}
|
|
480
|
+
code;
|
|
481
|
+
};
|
|
482
|
+
var EMPTY_STATE = {
|
|
483
|
+
questions: {},
|
|
484
|
+
pendingBySession: {},
|
|
485
|
+
answers: {},
|
|
486
|
+
transcriptsBySession: {}
|
|
487
|
+
};
|
|
488
|
+
var FileAskUserStore = class {
|
|
489
|
+
constructor(filePath) {
|
|
490
|
+
this.filePath = filePath;
|
|
491
|
+
}
|
|
492
|
+
filePath;
|
|
493
|
+
state = null;
|
|
494
|
+
writeChain = Promise.resolve();
|
|
495
|
+
listeners = /* @__PURE__ */ new Set();
|
|
496
|
+
async getPending(sessionId) {
|
|
497
|
+
const state = await this.load();
|
|
498
|
+
const questionId = state.pendingBySession[sessionId];
|
|
499
|
+
if (!questionId) return null;
|
|
500
|
+
const question = state.questions[questionId];
|
|
501
|
+
if (!question || !isPending(question)) return null;
|
|
502
|
+
return clone(question);
|
|
503
|
+
}
|
|
504
|
+
async getByQuestionId(questionId) {
|
|
505
|
+
const state = await this.load();
|
|
506
|
+
return state.questions[questionId] ? clone(state.questions[questionId]) : null;
|
|
507
|
+
}
|
|
508
|
+
async createPending(question) {
|
|
509
|
+
await this.mutate(async (state) => {
|
|
510
|
+
const existing = Object.values(state.pendingBySession).find((questionId) => isPending(state.questions[questionId]));
|
|
511
|
+
if (existing) {
|
|
512
|
+
throw new AskUserStoreError(ASK_USER_ERROR_CODES.PENDING_EXISTS, "a pending question already exists");
|
|
513
|
+
}
|
|
514
|
+
state.questions[question.questionId] = clone(question);
|
|
515
|
+
if (isPending(question)) state.pendingBySession[question.sessionId] = question.questionId;
|
|
516
|
+
this.emit({ sessionId: question.sessionId, questionId: question.questionId, reason: "create" });
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
async answer(questionId, answer) {
|
|
520
|
+
await this.mutate(async (state) => {
|
|
521
|
+
const question = requireQuestion(state, questionId);
|
|
522
|
+
if (answer.questionId !== questionId || answer.sessionId !== question.sessionId) {
|
|
523
|
+
throw new AskUserStoreError(ASK_USER_ERROR_CODES.SESSION_MISMATCH, "answer does not match question/session");
|
|
524
|
+
}
|
|
525
|
+
if (question.status === "cancelled") throw new AskUserStoreError(ASK_USER_ERROR_CODES.ALREADY_CANCELLED, "question already cancelled");
|
|
526
|
+
if (question.status === "answered") throw new AskUserStoreError(ASK_USER_ERROR_CODES.ALREADY_ANSWERED, "question already answered");
|
|
527
|
+
if (question.status !== "ready") throw new AskUserStoreError(ASK_USER_ERROR_CODES.ANSWER_INVALID, "question is not ready");
|
|
528
|
+
question.status = "answered";
|
|
529
|
+
question.updatedAt = nowIso();
|
|
530
|
+
state.answers[questionId] = clone(answer);
|
|
531
|
+
delete state.pendingBySession[question.sessionId];
|
|
532
|
+
this.emit({ sessionId: question.sessionId, questionId, reason: "answer" });
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
async cancel(questionId) {
|
|
536
|
+
await this.mutate(async (state) => {
|
|
537
|
+
const question = requireQuestion(state, questionId);
|
|
538
|
+
if (question.status === "answered") throw new AskUserStoreError(ASK_USER_ERROR_CODES.ALREADY_ANSWERED, "question already answered");
|
|
539
|
+
if (question.status === "cancelled") throw new AskUserStoreError(ASK_USER_ERROR_CODES.ALREADY_CANCELLED, "question already cancelled");
|
|
540
|
+
if (!isPending(question)) throw new AskUserStoreError(ASK_USER_ERROR_CODES.QUESTION_NOT_FOUND, "question is not pending");
|
|
541
|
+
question.status = "cancelled";
|
|
542
|
+
question.updatedAt = nowIso();
|
|
543
|
+
delete state.pendingBySession[question.sessionId];
|
|
544
|
+
this.emit({ sessionId: question.sessionId, questionId, reason: "cancel" });
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
async markAbandoned(questionId) {
|
|
548
|
+
await this.mutate(async (state) => {
|
|
549
|
+
const question = requireQuestion(state, questionId);
|
|
550
|
+
if (!isPending(question)) return;
|
|
551
|
+
question.status = "abandoned";
|
|
552
|
+
question.updatedAt = nowIso();
|
|
553
|
+
delete state.pendingBySession[question.sessionId];
|
|
554
|
+
this.emit({ sessionId: question.sessionId, questionId, reason: "abandon" });
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
async clearPending(sessionId) {
|
|
558
|
+
await this.mutate(async (state) => {
|
|
559
|
+
const questionId = state.pendingBySession[sessionId];
|
|
560
|
+
if (!questionId) return;
|
|
561
|
+
delete state.pendingBySession[sessionId];
|
|
562
|
+
this.emit({ sessionId, questionId, reason: "clear" });
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
async appendTranscriptEvent(event) {
|
|
566
|
+
await this.mutate(async (state) => {
|
|
567
|
+
const sessionId = transcriptSessionId(event);
|
|
568
|
+
state.transcriptsBySession[sessionId] = [...state.transcriptsBySession[sessionId] ?? [], clone(event)];
|
|
569
|
+
this.emit({ sessionId, questionId: transcriptQuestionId(event), reason: "transcript" });
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
async listTranscriptEvents(sessionId) {
|
|
573
|
+
const state = await this.load();
|
|
574
|
+
return clone(state.transcriptsBySession[sessionId] ?? []);
|
|
575
|
+
}
|
|
576
|
+
async getTranscriptEventsForQuestion(questionId) {
|
|
577
|
+
const state = await this.load();
|
|
578
|
+
const events = Object.values(state.transcriptsBySession).flat().filter((event) => transcriptQuestionId(event) === questionId);
|
|
579
|
+
return clone(events);
|
|
580
|
+
}
|
|
581
|
+
subscribe(listener) {
|
|
582
|
+
this.listeners.add(listener);
|
|
583
|
+
return () => this.listeners.delete(listener);
|
|
584
|
+
}
|
|
585
|
+
async mutate(fn) {
|
|
586
|
+
const run = this.writeChain.then(async () => {
|
|
587
|
+
const state = await this.load();
|
|
588
|
+
await fn(state);
|
|
589
|
+
await this.save(state);
|
|
590
|
+
});
|
|
591
|
+
this.writeChain = run.catch(() => void 0);
|
|
592
|
+
return run;
|
|
593
|
+
}
|
|
594
|
+
async load() {
|
|
595
|
+
if (this.state) return this.state;
|
|
596
|
+
try {
|
|
597
|
+
const raw = await readFile(this.filePath, "utf8");
|
|
598
|
+
this.state = { ...clone(EMPTY_STATE), ...JSON.parse(raw) };
|
|
599
|
+
} catch (error) {
|
|
600
|
+
if (error.code !== "ENOENT") throw error;
|
|
601
|
+
this.state = clone(EMPTY_STATE);
|
|
602
|
+
}
|
|
603
|
+
return this.state;
|
|
604
|
+
}
|
|
605
|
+
async save(state) {
|
|
606
|
+
await mkdir(dirname(this.filePath), { recursive: true });
|
|
607
|
+
const tmp = join(dirname(this.filePath), `.${randomUUID2()}.tmp`);
|
|
608
|
+
await writeFile(tmp, JSON.stringify(state, null, 2), "utf8");
|
|
609
|
+
await rename(tmp, this.filePath);
|
|
610
|
+
}
|
|
611
|
+
emit(change) {
|
|
612
|
+
for (const listener of this.listeners) {
|
|
613
|
+
try {
|
|
614
|
+
const result = listener(change);
|
|
615
|
+
if (isPromiseLike(result)) result.catch(() => void 0);
|
|
616
|
+
} catch {
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
function isPromiseLike(value) {
|
|
622
|
+
return !!value && typeof value === "object" && "catch" in value && typeof value.catch === "function";
|
|
623
|
+
}
|
|
624
|
+
function isPending(question) {
|
|
625
|
+
return question?.status === "ready";
|
|
626
|
+
}
|
|
627
|
+
function requireQuestion(state, questionId) {
|
|
628
|
+
const question = state.questions[questionId];
|
|
629
|
+
if (!question) throw new AskUserStoreError(ASK_USER_ERROR_CODES.QUESTION_NOT_FOUND, `question ${questionId} not found`);
|
|
630
|
+
return question;
|
|
631
|
+
}
|
|
632
|
+
function transcriptSessionId(event) {
|
|
633
|
+
switch (event.type) {
|
|
634
|
+
case "created":
|
|
635
|
+
return event.question.sessionId;
|
|
636
|
+
case "answered":
|
|
637
|
+
return event.answer.sessionId;
|
|
638
|
+
default:
|
|
639
|
+
return event.sessionId;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
function transcriptQuestionId(event) {
|
|
643
|
+
switch (event.type) {
|
|
644
|
+
case "created":
|
|
645
|
+
return event.question.questionId;
|
|
646
|
+
case "answered":
|
|
647
|
+
return event.answer.questionId;
|
|
648
|
+
default:
|
|
649
|
+
return event.questionId;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
function nowIso() {
|
|
653
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
654
|
+
}
|
|
655
|
+
function clone(value) {
|
|
656
|
+
return JSON.parse(JSON.stringify(value));
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// src/server/askUserStatePublisher.ts
|
|
660
|
+
var AskUserStatePublisher = class {
|
|
661
|
+
constructor(store, bridge) {
|
|
662
|
+
this.store = store;
|
|
663
|
+
this.bridge = bridge;
|
|
664
|
+
}
|
|
665
|
+
store;
|
|
666
|
+
bridge;
|
|
667
|
+
unsubscribe;
|
|
668
|
+
start() {
|
|
669
|
+
if (this.unsubscribe) return this.unsubscribe;
|
|
670
|
+
this.unsubscribe = this.store.subscribe((change) => {
|
|
671
|
+
void this.publishSession(change.sessionId);
|
|
672
|
+
});
|
|
673
|
+
return () => this.stop();
|
|
674
|
+
}
|
|
675
|
+
stop() {
|
|
676
|
+
this.unsubscribe?.();
|
|
677
|
+
this.unsubscribe = void 0;
|
|
678
|
+
}
|
|
679
|
+
async publishSession(sessionId) {
|
|
680
|
+
const question = await this.store.getPending(sessionId);
|
|
681
|
+
const current = await this.bridge.getState() ?? {};
|
|
682
|
+
const next = {
|
|
683
|
+
...current,
|
|
684
|
+
[ASK_USER_UI_STATE_SLOTS.PENDING]: { question }
|
|
685
|
+
};
|
|
686
|
+
await this.bridge.setState(next);
|
|
687
|
+
}
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
// src/server/createAskUserTool.ts
|
|
691
|
+
function createAskUserTool(options) {
|
|
692
|
+
return {
|
|
693
|
+
name: "ask_user",
|
|
694
|
+
label: "Ask user",
|
|
695
|
+
description: "Ask the user a blocking structured question in the Workspace Questions pane. Supports true multi-field forms via schema.fields (text, textarea, select, radio, multiselect, checkbox, number).",
|
|
696
|
+
promptSnippet: "Use `ask_user` whenever you need a missing user decision before continuing. It opens a blocking form in the Workspace Questions pane; do not simulate the question in chat. Pass `schema: { wireVersion: 1, fields: [...] }` with field types `text`, `textarea`, `select`, `radio`, `multiselect`, `checkbox`, or `number`. Do not use JSON Schema `properties`; put every requested input in `schema.fields`.",
|
|
697
|
+
parameters: {
|
|
698
|
+
type: "object",
|
|
699
|
+
properties: {
|
|
700
|
+
title: { type: "string", description: "Short question title." },
|
|
701
|
+
context: { type: "string", description: "Optional context shown above the form." },
|
|
702
|
+
schema: {
|
|
703
|
+
type: "object",
|
|
704
|
+
description: "Structured multi-field form schema. Use { wireVersion: 1, fields: [...] }. Supported field types: text, textarea, select, radio, multiselect, checkbox, number.",
|
|
705
|
+
properties: {
|
|
706
|
+
wireVersion: { type: "number", enum: [1] },
|
|
707
|
+
submitLabel: { type: "string" },
|
|
708
|
+
fields: {
|
|
709
|
+
type: "array",
|
|
710
|
+
items: {
|
|
711
|
+
type: "object",
|
|
712
|
+
properties: {
|
|
713
|
+
type: { type: "string", enum: ["text", "textarea", "select", "radio", "multiselect", "checkbox", "number"] },
|
|
714
|
+
name: { type: "string" },
|
|
715
|
+
label: { type: "string" },
|
|
716
|
+
required: { type: "boolean" },
|
|
717
|
+
helpText: { type: "string" },
|
|
718
|
+
placeholder: { type: "string" },
|
|
719
|
+
options: { type: "array", items: { type: "object", properties: { value: { type: "string" }, label: { type: "string" }, description: { type: "string" } }, required: ["value", "label"] } }
|
|
720
|
+
},
|
|
721
|
+
required: ["type", "name", "label"],
|
|
722
|
+
additionalProperties: true
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
},
|
|
726
|
+
required: ["wireVersion", "fields"],
|
|
727
|
+
additionalProperties: true
|
|
728
|
+
},
|
|
729
|
+
timeoutMs: { type: "number", description: "Optional timeout in milliseconds." }
|
|
730
|
+
},
|
|
731
|
+
required: ["title", "schema"],
|
|
732
|
+
additionalProperties: false
|
|
733
|
+
},
|
|
734
|
+
async execute(_toolCallId, params, signal, sessionId) {
|
|
735
|
+
const parsed = validateAskUserToolInput(params);
|
|
736
|
+
if (!parsed.success) {
|
|
737
|
+
return {
|
|
738
|
+
isError: true,
|
|
739
|
+
content: [{ type: "text", text: `Invalid ask_user input: ${parsed.error.issues[0]?.message ?? parsed.error.message}. Pass schema: { wireVersion: 1, fields: [{ type, name, label, ... }] }.` }]
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
const input = parsed.data;
|
|
743
|
+
try {
|
|
744
|
+
const result = await options.runtime.ask({ ...input, sessionId: sessionId ?? resolveSessionId(options.sessionId) }, signal);
|
|
745
|
+
return formatAskUserResult(result);
|
|
746
|
+
} catch (error) {
|
|
747
|
+
return {
|
|
748
|
+
isError: true,
|
|
749
|
+
content: [{ type: "text", text: `ask_user failed: ${error instanceof Error ? error.message : String(error)}` }],
|
|
750
|
+
details: error && typeof error === "object" && "code" in error ? { code: error.code } : void 0
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
function resolveSessionId(sessionId) {
|
|
757
|
+
return typeof sessionId === "function" ? sessionId() : sessionId;
|
|
758
|
+
}
|
|
759
|
+
function formatAskUserResult(result) {
|
|
760
|
+
if (result.status === "answered") {
|
|
761
|
+
return {
|
|
762
|
+
content: [{ type: "text", text: `User answered: ${JSON.stringify(result.answer.values)}` }],
|
|
763
|
+
details: result
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
return {
|
|
767
|
+
isError: true,
|
|
768
|
+
content: [{ type: "text", text: `User question cancelled: ${result.reason}` }],
|
|
769
|
+
details: result
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// src/server/questionsBridge.ts
|
|
774
|
+
import { timingSafeEqual } from "crypto";
|
|
775
|
+
var QuestionsBridgeError = class extends Error {
|
|
776
|
+
constructor(code, message, statusCode = 400) {
|
|
777
|
+
super(message);
|
|
778
|
+
this.code = code;
|
|
779
|
+
this.statusCode = statusCode;
|
|
780
|
+
}
|
|
781
|
+
code;
|
|
782
|
+
statusCode;
|
|
783
|
+
};
|
|
784
|
+
var QuestionsBridge = class {
|
|
785
|
+
constructor(options) {
|
|
786
|
+
this.options = options;
|
|
787
|
+
}
|
|
788
|
+
options;
|
|
789
|
+
async handle(command) {
|
|
790
|
+
const auth = await this.resolveAuth();
|
|
791
|
+
const question = await this.requireQuestion(command.params.questionId);
|
|
792
|
+
this.assertSession(question, command.params.sessionId, auth);
|
|
793
|
+
this.assertToken(question.answerToken, command.params.answerToken);
|
|
794
|
+
if (command.kind === "questions.cancel") {
|
|
795
|
+
if (question.status === "answered") throw new QuestionsBridgeError(ASK_USER_ERROR_CODES.ALREADY_ANSWERED, "question already answered", 409);
|
|
796
|
+
if (question.status === "cancelled") return { ok: true, status: "cancelled" };
|
|
797
|
+
if (question.status !== "ready") throw new QuestionsBridgeError(ASK_USER_ERROR_CODES.QUESTION_NOT_READY, "question is not ready", 409);
|
|
798
|
+
await this.options.runtime.cancelQuestion(question.questionId, question.sessionId, "user_cancelled");
|
|
799
|
+
return { ok: true, status: "cancelled" };
|
|
800
|
+
}
|
|
801
|
+
if (question.status === "answered") return { ok: true, status: "answered" };
|
|
802
|
+
if (question.status === "cancelled") throw new QuestionsBridgeError(ASK_USER_ERROR_CODES.ALREADY_CANCELLED, "question already cancelled", 409);
|
|
803
|
+
if (question.status !== "ready" || !question.schema) throw new QuestionsBridgeError(ASK_USER_ERROR_CODES.ANSWER_INVALID, "question is not ready", 409);
|
|
804
|
+
validateAnswerValues(question.schema.fields, command.params.values);
|
|
805
|
+
try {
|
|
806
|
+
const status = await this.options.runtime.submitAnswer(question.questionId, question.sessionId, command.params.values);
|
|
807
|
+
if (status === "abandoned") {
|
|
808
|
+
const latest = await this.options.store.getByQuestionId(question.questionId);
|
|
809
|
+
if (latest?.status === "answered") return { ok: true, status: "answered" };
|
|
810
|
+
throw new QuestionsBridgeError(ASK_USER_ERROR_CODES.QUESTION_NOT_FOUND, "question waiter is no longer available", 409);
|
|
811
|
+
}
|
|
812
|
+
} catch (error) {
|
|
813
|
+
if (isCode(error, ASK_USER_ERROR_CODES.ALREADY_ANSWERED)) return { ok: true, status: "answered" };
|
|
814
|
+
throw error;
|
|
815
|
+
}
|
|
816
|
+
return { ok: true, status: "answered" };
|
|
817
|
+
}
|
|
818
|
+
async resolveAuth() {
|
|
819
|
+
return await this.options.getAuthContext?.() ?? { sessionId: "anonymous", principalId: "anonymous" };
|
|
820
|
+
}
|
|
821
|
+
async requireQuestion(questionId) {
|
|
822
|
+
const question = await this.options.store.getByQuestionId(questionId);
|
|
823
|
+
if (!question) throw new QuestionsBridgeError(ASK_USER_ERROR_CODES.QUESTION_NOT_FOUND, "question not found", 404);
|
|
824
|
+
return question;
|
|
825
|
+
}
|
|
826
|
+
assertSession(question, browserSessionId, auth) {
|
|
827
|
+
if (question.sessionId !== browserSessionId || auth.sessionId !== browserSessionId) {
|
|
828
|
+
throw new QuestionsBridgeError(ASK_USER_ERROR_CODES.SESSION_MISMATCH, "session mismatch", 403);
|
|
829
|
+
}
|
|
830
|
+
if (question.ownerPrincipalId !== auth.principalId) {
|
|
831
|
+
throw new QuestionsBridgeError(ASK_USER_ERROR_CODES.UNAUTHORIZED, "principal mismatch", 403);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
assertToken(expected, actual) {
|
|
835
|
+
if (!constantTimeEqual(expected, actual)) {
|
|
836
|
+
throw new QuestionsBridgeError(ASK_USER_ERROR_CODES.UNAUTHORIZED, "invalid answer token", 403);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
function constantTimeEqual(expected, actual) {
|
|
841
|
+
const expectedBytes = new TextEncoder().encode(expected);
|
|
842
|
+
const actualBytes = new TextEncoder().encode(actual);
|
|
843
|
+
const length = Math.max(expectedBytes.length, actualBytes.length);
|
|
844
|
+
const left = new Uint8Array(length);
|
|
845
|
+
const right = new Uint8Array(length);
|
|
846
|
+
left.set(expectedBytes);
|
|
847
|
+
right.set(actualBytes);
|
|
848
|
+
return timingSafeEqual(left, right) && expectedBytes.length === actualBytes.length;
|
|
849
|
+
}
|
|
850
|
+
function validateAnswerValues(fields, values) {
|
|
851
|
+
const fieldByName = new Map(fields.map((field) => [field.name, field]));
|
|
852
|
+
for (const name of Object.keys(values)) {
|
|
853
|
+
if (!fieldByName.has(name)) throw new QuestionsBridgeError(ASK_USER_ERROR_CODES.ANSWER_INVALID, `unknown answer field ${name}`);
|
|
854
|
+
}
|
|
855
|
+
for (const field of fields) {
|
|
856
|
+
const value = values[field.name];
|
|
857
|
+
if (value === void 0 || value === null || value === "" || Array.isArray(value) && value.length === 0) {
|
|
858
|
+
if ("required" in field && field.required) throw new QuestionsBridgeError(ASK_USER_ERROR_CODES.ANSWER_INVALID, `${field.name} is required`);
|
|
859
|
+
continue;
|
|
860
|
+
}
|
|
861
|
+
switch (field.type) {
|
|
862
|
+
case "text":
|
|
863
|
+
case "textarea":
|
|
864
|
+
if (typeof value !== "string") throw invalid(field.name);
|
|
865
|
+
if (field.minLength !== void 0 && value.length < field.minLength) throw invalid(field.name);
|
|
866
|
+
if (field.maxLength !== void 0 && value.length > field.maxLength) throw invalid(field.name);
|
|
867
|
+
if (field.type === "text" && field.pattern && !new RegExp(field.pattern).test(value)) throw invalid(field.name);
|
|
868
|
+
break;
|
|
869
|
+
case "select":
|
|
870
|
+
case "radio":
|
|
871
|
+
if (typeof value !== "string" || !field.options.some((option) => option.value === value)) throw invalid(field.name);
|
|
872
|
+
break;
|
|
873
|
+
case "multiselect":
|
|
874
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || !field.options.some((option) => option.value === item))) throw invalid(field.name);
|
|
875
|
+
if (field.minSelections !== void 0 && value.length < field.minSelections) throw invalid(field.name);
|
|
876
|
+
if (field.maxSelections !== void 0 && value.length > field.maxSelections) throw invalid(field.name);
|
|
877
|
+
break;
|
|
878
|
+
case "checkbox":
|
|
879
|
+
if (typeof value !== "boolean") throw invalid(field.name);
|
|
880
|
+
break;
|
|
881
|
+
case "number":
|
|
882
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw invalid(field.name);
|
|
883
|
+
if (field.integer && !Number.isInteger(value)) throw invalid(field.name);
|
|
884
|
+
if (field.min !== void 0 && value < field.min) throw invalid(field.name);
|
|
885
|
+
if (field.max !== void 0 && value > field.max) throw invalid(field.name);
|
|
886
|
+
break;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
function invalid(name) {
|
|
891
|
+
return new QuestionsBridgeError(ASK_USER_ERROR_CODES.ANSWER_INVALID, `invalid answer for ${name}`);
|
|
892
|
+
}
|
|
893
|
+
function isCode(error, code) {
|
|
894
|
+
return !!error && typeof error === "object" && "code" in error && error.code === code;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// src/server/questionsRoutes.ts
|
|
898
|
+
function questionsRoutes(app, opts, done) {
|
|
899
|
+
app.post("/api/v1/questions/commands", async (request, reply) => {
|
|
900
|
+
if (!passesOrigin(request, opts.allowedOrigins)) return reply.code(403).send({ error: "forbidden", message: "invalid origin" });
|
|
901
|
+
if (!await passesCsrf(request, opts)) return reply.code(403).send({ error: "forbidden", message: "invalid csrf token" });
|
|
902
|
+
const parsed = QuestionsCommandSchema.safeParse(request.body);
|
|
903
|
+
if (!parsed.success) {
|
|
904
|
+
return reply.code(400).send({ error: "validation_error", message: parsed.error.issues[0]?.message ?? "invalid command" });
|
|
905
|
+
}
|
|
906
|
+
const bridge = new QuestionsBridge({
|
|
907
|
+
store: opts.store,
|
|
908
|
+
runtime: opts.runtime,
|
|
909
|
+
getAuthContext: opts.getAuthContext ? () => opts.getAuthContext(request) : void 0
|
|
910
|
+
});
|
|
911
|
+
try {
|
|
912
|
+
return await bridge.handle(parsed.data);
|
|
913
|
+
} catch (error) {
|
|
914
|
+
return sendError(reply, error);
|
|
915
|
+
}
|
|
916
|
+
});
|
|
917
|
+
done();
|
|
918
|
+
}
|
|
919
|
+
function passesOrigin(request, allowedOrigins) {
|
|
920
|
+
if (!allowedOrigins || allowedOrigins.length === 0) return true;
|
|
921
|
+
const origin = request.headers.origin;
|
|
922
|
+
return typeof origin === "string" && allowedOrigins.includes(origin);
|
|
923
|
+
}
|
|
924
|
+
async function passesCsrf(request, opts) {
|
|
925
|
+
if (!opts.csrfToken) return true;
|
|
926
|
+
const headerName = (opts.csrfHeaderName ?? "x-csrf-token").toLowerCase();
|
|
927
|
+
const actual = request.headers[headerName];
|
|
928
|
+
const expected = typeof opts.csrfToken === "function" ? await opts.csrfToken(request) : opts.csrfToken;
|
|
929
|
+
return typeof actual === "string" && typeof expected === "string" && constantTimeEqual(expected, actual);
|
|
930
|
+
}
|
|
931
|
+
function sendError(reply, error) {
|
|
932
|
+
if (error instanceof QuestionsBridgeError) {
|
|
933
|
+
return reply.code(error.statusCode).send({ error: error.code, message: error.message });
|
|
934
|
+
}
|
|
935
|
+
throw error;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// src/server/askUserServerPlugin.ts
|
|
939
|
+
function createAskUserServerPlugin(options) {
|
|
940
|
+
const store = options.store ?? createDefaultStore(options.workspaceRoot);
|
|
941
|
+
const runtime = options.runtime ?? new AskUserRuntime({ store, uiBridge: options.bridge });
|
|
942
|
+
const stopPublisher = options.bridge ? new AskUserStatePublisher(store, options.bridge).start() : void 0;
|
|
943
|
+
const routes = async (app) => {
|
|
944
|
+
app.addHook("onClose", async () => {
|
|
945
|
+
stopPublisher?.();
|
|
946
|
+
options.onClose?.();
|
|
947
|
+
});
|
|
948
|
+
await app.register(questionsRoutes, { ...defaultRoutes, ...options.routes, runtime, store });
|
|
949
|
+
};
|
|
950
|
+
const askUserTool = createAskUserTool({ runtime, sessionId: options.sessionId ?? (() => "default") });
|
|
951
|
+
return defineServerPlugin({
|
|
952
|
+
id: ASK_USER_PLUGIN_ID,
|
|
953
|
+
label: "Questions",
|
|
954
|
+
systemPrompt: "When you need a blocking decision from the user, call the `ask_user` tool. Do not roleplay or simulate the form in chat; the active form appears in the Workspace Questions pane.",
|
|
955
|
+
agentTools: [{
|
|
956
|
+
name: askUserTool.name,
|
|
957
|
+
description: askUserTool.description,
|
|
958
|
+
promptSnippet: askUserTool.promptSnippet,
|
|
959
|
+
parameters: askUserTool.parameters,
|
|
960
|
+
execute(params, ctx) {
|
|
961
|
+
return askUserTool.execute(ctx.toolCallId, params, ctx.abortSignal, ctx.sessionId);
|
|
962
|
+
}
|
|
963
|
+
}],
|
|
964
|
+
routes,
|
|
965
|
+
preservedUiStateKeys: [ASK_USER_UI_STATE_SLOTS.PENDING]
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
var defaultRoutes = {
|
|
969
|
+
// No-auth playground/default shells still need the browser command channel to
|
|
970
|
+
// bind to the question's owning session. The answerToken remains the terminal
|
|
971
|
+
// mutation secret; this context only prevents the default anonymous session
|
|
972
|
+
// sentinel from rejecting legitimate no-auth submits as SESSION_MISMATCH.
|
|
973
|
+
getAuthContext: (request) => {
|
|
974
|
+
const body = request.body;
|
|
975
|
+
return {
|
|
976
|
+
sessionId: typeof body?.params?.sessionId === "string" ? body.params.sessionId : "anonymous",
|
|
977
|
+
principalId: "anonymous"
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
};
|
|
981
|
+
function createDefaultStore(workspaceRoot) {
|
|
982
|
+
if (!workspaceRoot) throw new Error("createAskUserServerPlugin requires workspaceRoot when store is not provided");
|
|
983
|
+
return new FileAskUserStore(join2(workspaceRoot, ".boring", "ask-user.json"));
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// src/server/index.ts
|
|
987
|
+
function defaultAskUserServerPlugin(options, ctx) {
|
|
988
|
+
return createAskUserServerPlugin({
|
|
989
|
+
...options ?? {},
|
|
990
|
+
workspaceRoot: options?.workspaceRoot ?? ctx.workspaceRoot,
|
|
991
|
+
bridge: options?.bridge ?? ctx.bridge
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
export {
|
|
995
|
+
ASK_USER_PLUGIN_ID,
|
|
996
|
+
AskUserRuntime,
|
|
997
|
+
AskUserRuntimeError,
|
|
998
|
+
AskUserStatePublisher,
|
|
999
|
+
AskUserStoreError,
|
|
1000
|
+
FileAskUserStore,
|
|
1001
|
+
InProcessAskUserCoordinator,
|
|
1002
|
+
QuestionsBridge,
|
|
1003
|
+
QuestionsBridgeError,
|
|
1004
|
+
constantTimeEqual,
|
|
1005
|
+
createAskUserServerPlugin,
|
|
1006
|
+
createAskUserTool,
|
|
1007
|
+
defaultAskUserServerPlugin as default,
|
|
1008
|
+
questionsRoutes,
|
|
1009
|
+
requireAskUserRuntime,
|
|
1010
|
+
validateAnswerValues
|
|
1011
|
+
};
|