@pi-archimedes/ask 2.1.0 → 2.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/ask",
3
- "version": "2.1.0",
3
+ "version": "2.3.0",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -11,7 +11,7 @@
11
11
  ],
12
12
  "main": "./src/index.ts",
13
13
  "dependencies": {
14
- "@pi-archimedes/core": "2.1.0"
14
+ "@pi-archimedes/core": "2.3.0"
15
15
  },
16
16
  "peerDependencies": {
17
17
  "@earendil-works/pi-coding-agent": ">=0.1.0",
@@ -19,6 +19,8 @@
19
19
  "typebox": ">=1.1.0"
20
20
  },
21
21
  "devDependencies": {
22
+ "@earendil-works/pi-coding-agent": "^0.84.2",
23
+ "@earendil-works/pi-tui": "^0.84.2",
22
24
  "typebox": "^1.1.38",
23
25
  "typescript": "^6.0.0"
24
26
  },
package/src/index.ts CHANGED
@@ -1,217 +1,11 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { Type, type Static } from "typebox";
3
- import { getBus, Events } from "@pi-archimedes/core/bus";
4
- import { connect } from "node:net";
5
- import { randomUUID } from "node:crypto";
6
- import { OTHER_OPTION, type AskQuestion, type AskSelection } from "./selection.js";
7
- import { askSingleQuestionWithInlineNote } from "./picker.js";
8
- import { askQuestionsWithTabs } from "./dialog.js";
9
-
10
- const OptionItemSchema = Type.Object({
11
- label: Type.String({ description: "Display label" }),
12
- });
13
-
14
- const QuestionItemSchema = Type.Object({
15
- id: Type.String({ description: "Question id (e.g. auth, cache, priority)" }),
16
- question: Type.String({ description: "Question text" }),
17
- description: Type.Optional(
18
- Type.String({
19
- description:
20
- "Optional context in Markdown/plain text. Rendered above options with wrapping (supports headings/lists/code blocks).",
21
- }),
22
- ),
23
- options: Type.Array(OptionItemSchema, {
24
- description: "Available options. Do not include 'Other'.",
25
- minItems: 1,
26
- }),
27
- multi: Type.Optional(Type.Boolean({ description: "Allow multi-select" })),
28
- recommended: Type.Optional(
29
- Type.Number({ description: "0-indexed recommended option. '(Recommended)' is shown automatically." }),
30
- ),
31
- });
32
-
33
- const AskParamsSchema = Type.Object({
34
- questions: Type.Array(QuestionItemSchema, { description: "Questions to ask", minItems: 1 }),
35
- });
36
-
37
- type AskParams = Static<typeof AskParamsSchema>;
38
-
39
- interface QuestionResult {
40
- id: string;
41
- question: string;
42
- description: string | undefined;
43
- options: string[];
44
- multi: boolean;
45
- selectedOptions: string[];
46
- customInput: string | undefined;
47
- }
48
-
49
- interface AskToolDetails {
50
- id?: string;
51
- question?: string;
52
- description: string | undefined;
53
- options?: string[];
54
- multi?: boolean;
55
- selectedOptions?: string[];
56
- customInput: string | undefined;
57
- results?: QuestionResult[];
58
- }
59
-
60
- function sanitizeForSessionText(value: string): string {
61
- return value
62
- .replace(/[\r\n\t]/g, " ")
63
- .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
64
- .replace(/\s{2,}/g, " ")
65
- .trim();
66
- }
67
-
68
- function sanitizeMultilineForSessionText(value: string): string {
69
- return value
70
- .replace(/\r\n/g, "\n")
71
- .replace(/\r/g, "\n")
72
- .split("\n")
73
- .map((line) => sanitizeForSessionText(line))
74
- .join("\n")
75
- .trim();
76
- }
77
-
78
- function sanitizeOptionForSessionText(option: string): string {
79
- const sanitizedOption = sanitizeForSessionText(option);
80
- return sanitizedOption.length > 0 ? sanitizedOption : "(empty option)";
81
- }
82
-
83
- function toSessionSafeQuestionResult(result: QuestionResult): QuestionResult {
84
- const selectedOptions = result.selectedOptions
85
- .map((selectedOption) => sanitizeForSessionText(selectedOption))
86
- .filter((selectedOption) => selectedOption.length > 0);
87
-
88
- const rawDescription = result.description;
89
- const description = rawDescription == null ? undefined : sanitizeMultilineForSessionText(rawDescription);
90
- const rawCustomInput = result.customInput;
91
- const customInput = rawCustomInput == null ? undefined : sanitizeForSessionText(rawCustomInput);
92
-
93
- return {
94
- id: sanitizeForSessionText(result.id) || "(unknown)",
95
- question: sanitizeForSessionText(result.question) || "(empty question)",
96
- description: description && description.length > 0 ? description : undefined,
97
- options: result.options.map(sanitizeOptionForSessionText),
98
- multi: result.multi,
99
- selectedOptions,
100
- customInput: customInput && customInput.length > 0 ? customInput : undefined,
101
- };
102
- }
103
-
104
- function formatSelectionForSummary(result: QuestionResult): string {
105
- const hasSelectedOptions = result.selectedOptions.length > 0;
106
- const hasCustomInput = Boolean(result.customInput);
107
-
108
- if (!hasSelectedOptions && !hasCustomInput) {
109
- return "(cancelled)";
110
- }
111
-
112
- if (hasSelectedOptions && hasCustomInput) {
113
- const selectedPart = result.multi
114
- ? `[${result.selectedOptions.join(", ")}]`
115
- : result.selectedOptions[0] ?? "";
116
- return `${selectedPart} + Other: "${result.customInput}"`;
117
- }
118
-
119
- if (hasCustomInput) {
120
- return `"${result.customInput}"`;
121
- }
122
-
123
- if (result.multi) {
124
- return `[${result.selectedOptions.join(", ")}]`;
125
- }
126
-
127
- return result.selectedOptions[0] ?? "";
128
- }
129
-
130
- function formatQuestionResult(result: QuestionResult): string {
131
- return `${result.id}: ${formatSelectionForSummary(result)}`;
132
- }
133
-
134
- function formatQuestionContext(result: QuestionResult, questionIndex: number): string {
135
- const lines: string[] = [`Question ${questionIndex + 1} (${result.id})`, `Prompt: ${result.question}`];
136
-
137
- if (result.description) {
138
- lines.push("Context:");
139
- for (const descriptionLine of result.description.split("\n")) {
140
- lines.push(` ${descriptionLine}`);
141
- }
142
- }
143
-
144
- lines.push("Options:");
145
- lines.push(...result.options.map((option, optionIndex) => ` ${optionIndex + 1}. ${option}`));
146
- lines.push("Response:");
147
-
148
- const hasSelectedOptions = result.selectedOptions.length > 0;
149
- const hasCustomInput = Boolean(result.customInput);
150
-
151
- if (!hasSelectedOptions && !hasCustomInput) {
152
- lines.push(" Selected: (cancelled)");
153
- return lines.join("\n");
154
- }
155
-
156
- if (hasSelectedOptions) {
157
- const selectedText = result.multi
158
- ? `[${result.selectedOptions.join(", ")}]`
159
- : result.selectedOptions[0];
160
- lines.push(` Selected: ${selectedText}`);
161
- }
162
-
163
- if (hasCustomInput) {
164
- if (!hasSelectedOptions) {
165
- lines.push(` Selected: ${OTHER_OPTION}`);
166
- }
167
- lines.push(` Custom input: ${result.customInput}`);
168
- }
169
-
170
- return lines.join("\n");
171
- }
172
-
173
- function buildAskSessionContent(results: QuestionResult[]): string {
174
- const safeResults = results.map(toSessionSafeQuestionResult);
175
- const summaryLines = safeResults.map(formatQuestionResult).join("\n");
176
- const contextBlocks = safeResults.map((result, index) => formatQuestionContext(result, index)).join("\n\n");
177
- return `User answers:\n${summaryLines}\n\nAnswer context:\n${contextBlocks}`;
178
- }
179
-
180
- const ASK_TOOL_DESCRIPTION = `
181
- Ask the user for clarification when a choice materially affects the outcome.
182
-
183
- - Use when multiple valid approaches have different trade-offs.
184
- - Prefer 2-5 concise options.
185
- - Use multi=true when multiple answers are valid.
186
- - Use recommended=<index> (0-indexed) to mark the default option.
187
- - Use description to provide Markdown/plain context (supports long explanations and structure diagrams).
188
- - You can ask multiple related questions in one call using questions[].
189
- - Do NOT include an 'Other' option; UI adds it automatically.
190
- `.trim();
2
+ import { registerIpcRelay } from "./ipc-relay.js";
3
+ import { registerAskTool } from "./tool.js";
191
4
 
192
5
  export function registerAsk(pi: ExtensionAPI) {
193
6
  const unsubscribes: Array<() => void> = [];
194
7
  let currentCtx: ExtensionContext | undefined;
195
8
 
196
- // Listen for ask requests from subagents via the bus — show UI immediately
197
- const unsubAskRequest = getBus().on(Events.ASK_REQUEST, async (payload: unknown) => {
198
- const data = payload as {
199
- source: string;
200
- requestId: string;
201
- questions: AskQuestion[];
202
- };
203
-
204
- // Skip main agent — it shows its own UI in the tool handler
205
- if (data.source === "main") return;
206
-
207
- if (!data.questions || data.questions.length === 0) return;
208
-
209
- // Defer to next tick so TUI can process current state
210
- await new Promise((resolve) => setImmediate(resolve));
211
- await handleAskRequest(data);
212
- });
213
- unsubscribes.push(unsubAskRequest);
214
-
215
9
  // Keep ctx reference fresh
216
10
  pi.on("session_start", (_event, ctx: ExtensionContext) => {
217
11
  currentCtx = ctx;
@@ -220,231 +14,15 @@ export function registerAsk(pi: ExtensionAPI) {
220
14
  currentCtx = ctx;
221
15
  });
222
16
 
17
+ // Register the IPC relay for subagent ask requests
18
+ registerIpcRelay(pi, () => currentCtx, unsubscribes);
19
+
20
+ // Register the ask tool
21
+ registerAskTool(pi);
22
+
223
23
  // Clean up on shutdown
224
24
  pi.on("session_shutdown", (_event, _ctx) => {
225
25
  unsubscribes.forEach((unsub) => unsub());
226
26
  unsubscribes.length = 0;
227
27
  });
228
-
229
- async function handleAskRequest(data: {
230
- requestId: string;
231
- questions: AskQuestion[];
232
- }) {
233
- if (!currentCtx?.ui) return;
234
-
235
- const questions = data.questions;
236
- let cancelled = true;
237
- let selections: AskSelection[] = [];
238
-
239
- try {
240
- if (questions.length === 1) {
241
- const q = questions[0]!;
242
- if (q.multi) {
243
- // Multi-select single question routes to the tabs dialog (picker doesn't support multi)
244
- const res = await askQuestionsWithTabs(currentCtx.ui, questions);
245
- cancelled = res.cancelled;
246
- selections = res.selections;
247
- } else {
248
- const input: { question: string; description?: string; options: typeof q.options; recommended?: number } = {
249
- question: q.question,
250
- options: q.options,
251
- };
252
- if (q.description && q.description.trim().length > 0) input.description = q.description;
253
- if (q.recommended != null) input.recommended = q.recommended;
254
- const sel = await askSingleQuestionWithInlineNote(currentCtx.ui, input);
255
- selections = [sel];
256
- cancelled = sel.selectedOptions.length === 0 && !sel.customInput;
257
- }
258
- } else {
259
- const res = await askQuestionsWithTabs(currentCtx.ui, questions);
260
- cancelled = res.cancelled;
261
- selections = res.selections;
262
- }
263
- } catch {
264
- cancelled = true;
265
- selections = questions.map(() => ({ selectedOptions: [] }));
266
- }
267
-
268
- // Build results matching the request's questions
269
- const results = questions.map((q, i) => ({
270
- id: q.id,
271
- selectedOptions: selections[i]?.selectedOptions ?? [],
272
- customInput: selections[i]?.customInput,
273
- }));
274
-
275
- // Emit on bus — parent's streamEvents writes to child socket
276
- getBus().emit(Events.ASK_RESPONSE, {
277
- requestId: data.requestId,
278
- cancelled,
279
- results,
280
- });
281
- }
282
-
283
- pi.registerTool({
284
- name: "ask",
285
- label: "Ask",
286
- description: ASK_TOOL_DESCRIPTION,
287
- parameters: AskParamsSchema,
288
-
289
- async execute(_toolCallId, params: AskParams, _signal, _onUpdate, ctx) {
290
- // Headless/subagent path: the child's ask tool connects to the parent's
291
- // PI_SUBAGENT_SOCKET bridge (created in subagent/spawn.ts:startAskSocketServer)
292
- // and exchanges ask_request/ask_response JSON lines.
293
- if (!ctx.hasUI) {
294
- const requestId = randomUUID();
295
- const socketPath = process.env.PI_SUBAGENT_SOCKET;
296
-
297
- const response = await new Promise<{
298
- cancelled: boolean;
299
- results: Array<{ id: string; selectedOptions: string[]; customInput?: string }>;
300
- }>((resolve) => {
301
- if (!socketPath) {
302
- resolve({ cancelled: true, results: params.questions.map((q) => ({ id: q.id, selectedOptions: [] })) });
303
- return;
304
- }
305
-
306
- let resolved = false;
307
- const finish = (r: { cancelled: boolean; results: Array<{ id: string; selectedOptions: string[]; customInput?: string }> }) => {
308
- if (resolved) return;
309
- resolved = true;
310
- clearTimeout(timer);
311
- resolve(r);
312
- };
313
-
314
- const socket = connect(socketPath, () => {
315
- // Connected — send the question to the parent
316
- socket.write(JSON.stringify({
317
- type: "ask_request",
318
- requestId,
319
- questions: params.questions,
320
- }) + "\n");
321
- });
322
-
323
- let buffer = "";
324
- socket.on("data", (data: Buffer) => {
325
- buffer += data.toString();
326
- const lines = buffer.split("\n");
327
- buffer = lines.pop() ?? "";
328
- for (const line of lines) {
329
- try {
330
- const msg = JSON.parse(line);
331
- if (msg.type === "ask_response" && msg.requestId === requestId) {
332
- socket.end();
333
- finish({ cancelled: msg.cancelled, results: msg.results });
334
- }
335
- } catch { /* ignore */ }
336
- }
337
- });
338
-
339
- socket.on("error", () => {
340
- finish({ cancelled: true, results: params.questions.map((q) => ({ id: q.id, selectedOptions: [] })) });
341
- });
342
-
343
- socket.on("close", () => {
344
- finish({ cancelled: true, results: params.questions.map((q) => ({ id: q.id, selectedOptions: [] })) });
345
- });
346
-
347
- // Timeout after 5 minutes
348
- const timer = setTimeout(() => {
349
- socket.end();
350
- finish({ cancelled: true, results: params.questions.map((q) => ({ id: q.id, selectedOptions: [] })) });
351
- }, 5 * 60 * 1000);
352
- timer.unref();
353
- });
354
-
355
- // Build results from response
356
- const results: QuestionResult[] = params.questions.map((q, i) => {
357
- const r = response.results[i];
358
- return {
359
- id: q.id,
360
- question: q.question,
361
- description: q.description && q.description.trim().length > 0 ? q.description : undefined,
362
- options: q.options.map((o) => o.label),
363
- multi: q.multi ?? false,
364
- selectedOptions: r?.selectedOptions ?? [],
365
- customInput: r?.customInput ?? undefined,
366
- };
367
- });
368
-
369
- if (response.cancelled && results.every((r) => r.selectedOptions.length === 0)) {
370
- return {
371
- content: [{ type: "text", text: "User cancelled the question." }],
372
- details: { results, customInput: undefined, description: undefined } satisfies AskToolDetails,
373
- };
374
- }
375
-
376
- return {
377
- content: [{ type: "text", text: buildAskSessionContent(results) }],
378
- details: { results, customInput: undefined, description: undefined } satisfies AskToolDetails,
379
- };
380
- }
381
-
382
- if (params.questions.length === 0) {
383
- return {
384
- content: [{ type: "text", text: "Error: questions must not be empty" }],
385
- details: {},
386
- };
387
- }
388
-
389
- // Emit bus event so notify (if installed) can schedule a desktop notification
390
- getBus().emit(Events.ASK_REQUEST, { source: "main", requestId: randomUUID(), questions: params.questions });
391
-
392
- if (params.questions.length === 1) {
393
- const q = params.questions[0]!;
394
- const selection = q.multi
395
- ? (await askQuestionsWithTabs(ctx.ui, [q as AskQuestion])).selections[0] ?? { selectedOptions: [] }
396
- : await askSingleQuestionWithInlineNote(ctx.ui, q as AskQuestion);
397
- const optionLabels = q.options.map((option) => option.label);
398
- const desc = q.description && q.description.trim().length > 0 ? q.description : undefined;
399
-
400
- const result: QuestionResult = {
401
- id: q.id,
402
- question: q.question,
403
- description: desc,
404
- options: optionLabels,
405
- multi: q.multi ?? false,
406
- selectedOptions: selection.selectedOptions,
407
- customInput: selection.customInput,
408
- };
409
-
410
- const details: AskToolDetails = {
411
- id: q.id,
412
- question: q.question,
413
- description: desc,
414
- options: optionLabels,
415
- multi: q.multi ?? false,
416
- selectedOptions: selection.selectedOptions,
417
- customInput: selection.customInput,
418
- results: [result],
419
- };
420
-
421
- return {
422
- content: [{ type: "text", text: buildAskSessionContent([result]) }],
423
- details,
424
- };
425
- }
426
-
427
- const results: QuestionResult[] = [];
428
- const tabResult = await askQuestionsWithTabs(ctx.ui, params.questions as AskQuestion[]);
429
- for (let i = 0; i < params.questions.length; i++) {
430
- const q = params.questions[i]!;
431
- const selection = tabResult.selections[i] ?? { selectedOptions: [] };
432
- const desc = q.description && q.description.trim().length > 0 ? q.description : undefined;
433
- results.push({
434
- id: q.id,
435
- question: q.question,
436
- description: desc,
437
- options: q.options.map((option) => option.label),
438
- multi: q.multi ?? false,
439
- selectedOptions: selection.selectedOptions,
440
- customInput: selection.customInput,
441
- });
442
- }
443
-
444
- return {
445
- content: [{ type: "text", text: buildAskSessionContent(results) }],
446
- details: { results, customInput: undefined, description: undefined } satisfies AskToolDetails,
447
- };
448
- },
449
- });
450
28
  }
@@ -0,0 +1,87 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { getBus, Events } from "@pi-archimedes/core/bus";
3
+ import { type AskQuestion, type AskSelection } from "./selection.js";
4
+ import { askSingleQuestionWithInlineNote } from "./picker.js";
5
+ import { askQuestionsWithTabs } from "./dialog.js";
6
+
7
+ export function registerIpcRelay(
8
+ pi: ExtensionAPI,
9
+ getCtx: () => ExtensionContext | undefined,
10
+ unsubscribes: Array<() => void>,
11
+ ): void {
12
+ const unsubAskRequest = getBus().on(Events.ASK_REQUEST, async (payload: unknown) => {
13
+ const data = payload as {
14
+ source: string;
15
+ requestId: string;
16
+ questions: AskQuestion[];
17
+ };
18
+
19
+ // Skip main agent — it shows its own UI in the tool handler
20
+ if (data.source === "main") return;
21
+
22
+ if (!data.questions || data.questions.length === 0) return;
23
+
24
+ // Defer to next tick so TUI can process current state
25
+ await new Promise((resolve) => setImmediate(resolve));
26
+ await handleAskRequest(data, getCtx);
27
+ });
28
+ unsubscribes.push(unsubAskRequest);
29
+ }
30
+
31
+ async function handleAskRequest(
32
+ data: {
33
+ requestId: string;
34
+ questions: AskQuestion[];
35
+ },
36
+ getCtx: () => ExtensionContext | undefined,
37
+ ): Promise<void> {
38
+ const ctx = getCtx();
39
+ if (!ctx?.ui) return;
40
+
41
+ const questions = data.questions;
42
+ let cancelled = true;
43
+ let selections: AskSelection[] = [];
44
+
45
+ try {
46
+ if (questions.length === 1) {
47
+ const q = questions[0]!;
48
+ if (q.multi) {
49
+ // Multi-select single question routes to the tabs dialog (picker doesn't support multi)
50
+ const res = await askQuestionsWithTabs(ctx.ui, questions);
51
+ cancelled = res.cancelled;
52
+ selections = res.selections;
53
+ } else {
54
+ const input: { question: string; description?: string; options: typeof q.options; recommended?: number } = {
55
+ question: q.question,
56
+ options: q.options,
57
+ };
58
+ if (q.description && q.description.trim().length > 0) input.description = q.description;
59
+ if (q.recommended != null) input.recommended = q.recommended;
60
+ const sel = await askSingleQuestionWithInlineNote(ctx.ui, input);
61
+ selections = [sel];
62
+ cancelled = sel.selectedOptions.length === 0 && !sel.customInput;
63
+ }
64
+ } else {
65
+ const res = await askQuestionsWithTabs(ctx.ui, questions);
66
+ cancelled = res.cancelled;
67
+ selections = res.selections;
68
+ }
69
+ } catch {
70
+ cancelled = true;
71
+ selections = questions.map(() => ({ selectedOptions: [] }));
72
+ }
73
+
74
+ // Build results matching the request's questions
75
+ const results = questions.map((q, i) => ({
76
+ id: q.id,
77
+ selectedOptions: selections[i]?.selectedOptions ?? [],
78
+ customInput: selections[i]?.customInput,
79
+ }));
80
+
81
+ // Emit on bus — parent's streamEvents writes to child socket
82
+ getBus().emit(Events.ASK_RESPONSE, {
83
+ requestId: data.requestId,
84
+ cancelled,
85
+ results,
86
+ });
87
+ }
package/src/tool.ts ADDED
@@ -0,0 +1,370 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type, type Static } from "typebox";
3
+ import { getBus, Events } from "@pi-archimedes/core/bus";
4
+ import { connect } from "node:net";
5
+ import { randomUUID } from "node:crypto";
6
+ import { OTHER_OPTION, type AskQuestion } from "./selection.js";
7
+ import { askSingleQuestionWithInlineNote } from "./picker.js";
8
+ import { askQuestionsWithTabs } from "./dialog.js";
9
+
10
+ // ── Schemas ──────────────────────────────────────────────────────────────────
11
+
12
+ const OptionItemSchema = Type.Object({
13
+ label: Type.String({ description: "Display label" }),
14
+ });
15
+
16
+ const QuestionItemSchema = Type.Object({
17
+ id: Type.String({ description: "Question id (e.g. auth, cache, priority)" }),
18
+ question: Type.String({ description: "Question text" }),
19
+ description: Type.Optional(
20
+ Type.String({
21
+ description:
22
+ "Optional context in Markdown/plain text. Rendered above options with wrapping (supports headings/lists/code blocks).",
23
+ }),
24
+ ),
25
+ options: Type.Array(OptionItemSchema, {
26
+ description: "Available options. Do not include 'Other'.",
27
+ minItems: 1,
28
+ }),
29
+ multi: Type.Optional(Type.Boolean({ description: "Allow multi-select" })),
30
+ recommended: Type.Optional(
31
+ Type.Number({ description: "0-indexed recommended option. '(Recommended)' is shown automatically." }),
32
+ ),
33
+ });
34
+
35
+ const AskParamsSchema = Type.Object({
36
+ questions: Type.Array(QuestionItemSchema, { description: "Questions to ask", minItems: 1 }),
37
+ });
38
+
39
+ type AskParams = Static<typeof AskParamsSchema>;
40
+
41
+ // ── Types ────────────────────────────────────────────────────────────────────
42
+
43
+ export interface QuestionResult {
44
+ id: string;
45
+ question: string;
46
+ description: string | undefined;
47
+ options: string[];
48
+ multi: boolean;
49
+ selectedOptions: string[];
50
+ customInput: string | undefined;
51
+ }
52
+
53
+ export interface AskToolDetails {
54
+ id?: string;
55
+ question?: string;
56
+ description: string | undefined;
57
+ options?: string[];
58
+ multi?: boolean;
59
+ selectedOptions?: string[];
60
+ customInput: string | undefined;
61
+ results?: QuestionResult[];
62
+ }
63
+
64
+ // ── Session-text helpers ─────────────────────────────────────────────────────
65
+
66
+ function sanitizeForSessionText(value: string): string {
67
+ return value
68
+ .replace(/[\r\n\t]/g, " ")
69
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
70
+ .replace(/\s{2,}/g, " ")
71
+ .trim();
72
+ }
73
+
74
+ function sanitizeMultilineForSessionText(value: string): string {
75
+ return value
76
+ .replace(/\r\n/g, "\n")
77
+ .replace(/\r/g, "\n")
78
+ .split("\n")
79
+ .map((line) => sanitizeForSessionText(line))
80
+ .join("\n")
81
+ .trim();
82
+ }
83
+
84
+ function sanitizeOptionForSessionText(option: string): string {
85
+ const sanitizedOption = sanitizeForSessionText(option);
86
+ return sanitizedOption.length > 0 ? sanitizedOption : "(empty option)";
87
+ }
88
+
89
+ function toSessionSafeQuestionResult(result: QuestionResult): QuestionResult {
90
+ const selectedOptions = result.selectedOptions
91
+ .map((selectedOption) => sanitizeForSessionText(selectedOption))
92
+ .filter((selectedOption) => selectedOption.length > 0);
93
+
94
+ const rawDescription = result.description;
95
+ const description = rawDescription == null ? undefined : sanitizeMultilineForSessionText(rawDescription);
96
+ const rawCustomInput = result.customInput;
97
+ const customInput = rawCustomInput == null ? undefined : sanitizeForSessionText(rawCustomInput);
98
+
99
+ return {
100
+ id: sanitizeForSessionText(result.id) || "(unknown)",
101
+ question: sanitizeForSessionText(result.question) || "(empty question)",
102
+ description: description && description.length > 0 ? description : undefined,
103
+ options: result.options.map(sanitizeOptionForSessionText),
104
+ multi: result.multi,
105
+ selectedOptions,
106
+ customInput: customInput && customInput.length > 0 ? customInput : undefined,
107
+ };
108
+ }
109
+
110
+ function formatSelectionForSummary(result: QuestionResult): string {
111
+ const hasSelectedOptions = result.selectedOptions.length > 0;
112
+ const hasCustomInput = Boolean(result.customInput);
113
+
114
+ if (!hasSelectedOptions && !hasCustomInput) {
115
+ return "(cancelled)";
116
+ }
117
+
118
+ if (hasSelectedOptions && hasCustomInput) {
119
+ const selectedPart = result.multi
120
+ ? `[${result.selectedOptions.join(", ")}]`
121
+ : result.selectedOptions[0] ?? "";
122
+ return `${selectedPart} + Other: "${result.customInput}"`;
123
+ }
124
+
125
+ if (hasCustomInput) {
126
+ return `"${result.customInput}"`;
127
+ }
128
+
129
+ if (result.multi) {
130
+ return `[${result.selectedOptions.join(", ")}]`;
131
+ }
132
+
133
+ return result.selectedOptions[0] ?? "";
134
+ }
135
+
136
+ function formatQuestionResult(result: QuestionResult): string {
137
+ return `${result.id}: ${formatSelectionForSummary(result)}`;
138
+ }
139
+
140
+ function formatQuestionContext(result: QuestionResult, questionIndex: number): string {
141
+ const lines: string[] = [`Question ${questionIndex + 1} (${result.id})`, `Prompt: ${result.question}`];
142
+
143
+ if (result.description) {
144
+ lines.push("Context:");
145
+ for (const descriptionLine of result.description.split("\n")) {
146
+ lines.push(` ${descriptionLine}`);
147
+ }
148
+ }
149
+
150
+ lines.push("Options:");
151
+ lines.push(...result.options.map((option, optionIndex) => ` ${optionIndex + 1}. ${option}`));
152
+ lines.push("Response:");
153
+
154
+ const hasSelectedOptions = result.selectedOptions.length > 0;
155
+ const hasCustomInput = Boolean(result.customInput);
156
+
157
+ if (!hasSelectedOptions && !hasCustomInput) {
158
+ lines.push(" Selected: (cancelled)");
159
+ return lines.join("\n");
160
+ }
161
+
162
+ if (hasSelectedOptions) {
163
+ const selectedText = result.multi
164
+ ? `[${result.selectedOptions.join(", ")}]`
165
+ : result.selectedOptions[0];
166
+ lines.push(` Selected: ${selectedText}`);
167
+ }
168
+
169
+ if (hasCustomInput) {
170
+ if (!hasSelectedOptions) {
171
+ lines.push(` Selected: ${OTHER_OPTION}`);
172
+ }
173
+ lines.push(` Custom input: ${result.customInput}`);
174
+ }
175
+
176
+ return lines.join("\n");
177
+ }
178
+
179
+ export function buildAskSessionContent(results: QuestionResult[]): string {
180
+ const safeResults = results.map(toSessionSafeQuestionResult);
181
+ const summaryLines = safeResults.map(formatQuestionResult).join("\n");
182
+ const contextBlocks = safeResults.map((result, index) => formatQuestionContext(result, index)).join("\n\n");
183
+ return `User answers:\n${summaryLines}\n\nAnswer context:\n${contextBlocks}`;
184
+ }
185
+
186
+ // ── Tool description ─────────────────────────────────────────────────────────
187
+
188
+ const ASK_TOOL_DESCRIPTION = `
189
+ Ask the user for clarification when a choice materially affects the outcome.
190
+
191
+ - Use when multiple valid approaches have different trade-offs.
192
+ - Prefer 2-5 concise options.
193
+ - Use multi=true when multiple answers are valid.
194
+ - Use recommended=<index> (0-indexed) to mark the default option.
195
+ - Use description to provide Markdown/plain context (supports long explanations and structure diagrams).
196
+ - You can ask multiple related questions in one call using questions[].
197
+ - Do NOT include an 'Other' option; UI adds it automatically.
198
+ `.trim();
199
+
200
+ // ── Tool registration ────────────────────────────────────────────────────────
201
+
202
+ export function registerAskTool(pi: ExtensionAPI): void {
203
+ pi.registerTool({
204
+ name: "ask",
205
+ label: "Ask",
206
+ description: ASK_TOOL_DESCRIPTION,
207
+ parameters: AskParamsSchema,
208
+
209
+ async execute(_toolCallId, params: AskParams, _signal, _onUpdate, ctx) {
210
+ // Headless/subagent path: the child's ask tool connects to the parent's
211
+ // PI_SUBAGENT_SOCKET bridge (created in subagent/spawn.ts:startAskSocketServer)
212
+ // and exchanges ask_request/ask_response JSON lines.
213
+ if (!ctx.hasUI) {
214
+ const requestId = randomUUID();
215
+ const socketPath = process.env.PI_SUBAGENT_SOCKET;
216
+
217
+ const response = await new Promise<{
218
+ cancelled: boolean;
219
+ results: Array<{ id: string; selectedOptions: string[]; customInput?: string }>;
220
+ }>((resolve) => {
221
+ if (!socketPath) {
222
+ resolve({ cancelled: true, results: params.questions.map((q) => ({ id: q.id, selectedOptions: [] })) });
223
+ return;
224
+ }
225
+
226
+ let resolved = false;
227
+ const finish = (r: { cancelled: boolean; results: Array<{ id: string; selectedOptions: string[]; customInput?: string }> }) => {
228
+ if (resolved) return;
229
+ resolved = true;
230
+ clearTimeout(timer);
231
+ resolve(r);
232
+ };
233
+
234
+ const socket = connect(socketPath, () => {
235
+ // Connected — send the question to the parent
236
+ socket.write(JSON.stringify({
237
+ type: "ask_request",
238
+ requestId,
239
+ questions: params.questions,
240
+ }) + "\n");
241
+ });
242
+
243
+ let buffer = "";
244
+ socket.on("data", (data: Buffer) => {
245
+ buffer += data.toString();
246
+ const lines = buffer.split("\n");
247
+ buffer = lines.pop() ?? "";
248
+ for (const line of lines) {
249
+ try {
250
+ const msg = JSON.parse(line);
251
+ if (msg.type === "ask_response" && msg.requestId === requestId) {
252
+ socket.end();
253
+ finish({ cancelled: msg.cancelled, results: msg.results });
254
+ }
255
+ } catch { /* ignore */ }
256
+ }
257
+ });
258
+
259
+ socket.on("error", () => {
260
+ finish({ cancelled: true, results: params.questions.map((q) => ({ id: q.id, selectedOptions: [] })) });
261
+ });
262
+
263
+ socket.on("close", () => {
264
+ finish({ cancelled: true, results: params.questions.map((q) => ({ id: q.id, selectedOptions: [] })) });
265
+ });
266
+
267
+ // Timeout after 5 minutes
268
+ const timer = setTimeout(() => {
269
+ socket.end();
270
+ finish({ cancelled: true, results: params.questions.map((q) => ({ id: q.id, selectedOptions: [] })) });
271
+ }, 5 * 60 * 1000);
272
+ timer.unref();
273
+ });
274
+
275
+ // Build results from response
276
+ const results: QuestionResult[] = params.questions.map((q, i) => {
277
+ const r = response.results[i];
278
+ return {
279
+ id: q.id,
280
+ question: q.question,
281
+ description: q.description && q.description.trim().length > 0 ? q.description : undefined,
282
+ options: q.options.map((o) => o.label),
283
+ multi: q.multi ?? false,
284
+ selectedOptions: r?.selectedOptions ?? [],
285
+ customInput: r?.customInput ?? undefined,
286
+ };
287
+ });
288
+
289
+ if (response.cancelled && results.every((r) => r.selectedOptions.length === 0)) {
290
+ return {
291
+ content: [{ type: "text", text: "User cancelled the question." }],
292
+ details: { results, customInput: undefined, description: undefined } satisfies AskToolDetails,
293
+ };
294
+ }
295
+
296
+ return {
297
+ content: [{ type: "text", text: buildAskSessionContent(results) }],
298
+ details: { results, customInput: undefined, description: undefined } satisfies AskToolDetails,
299
+ };
300
+ }
301
+
302
+ if (params.questions.length === 0) {
303
+ return {
304
+ content: [{ type: "text", text: "Error: questions must not be empty" }],
305
+ details: {},
306
+ };
307
+ }
308
+
309
+ // Emit bus event so notify (if installed) can schedule a desktop notification
310
+ getBus().emit(Events.ASK_REQUEST, { source: "main", requestId: randomUUID(), questions: params.questions });
311
+
312
+ if (params.questions.length === 1) {
313
+ const q = params.questions[0]!;
314
+ const selection = q.multi
315
+ ? (await askQuestionsWithTabs(ctx.ui, [q as AskQuestion])).selections[0] ?? { selectedOptions: [] }
316
+ : await askSingleQuestionWithInlineNote(ctx.ui, q as AskQuestion);
317
+ const optionLabels = q.options.map((option) => option.label);
318
+ const desc = q.description && q.description.trim().length > 0 ? q.description : undefined;
319
+
320
+ const result: QuestionResult = {
321
+ id: q.id,
322
+ question: q.question,
323
+ description: desc,
324
+ options: optionLabels,
325
+ multi: q.multi ?? false,
326
+ selectedOptions: selection.selectedOptions,
327
+ customInput: selection.customInput,
328
+ };
329
+
330
+ const details: AskToolDetails = {
331
+ id: q.id,
332
+ question: q.question,
333
+ description: desc,
334
+ options: optionLabels,
335
+ multi: q.multi ?? false,
336
+ selectedOptions: selection.selectedOptions,
337
+ customInput: selection.customInput,
338
+ results: [result],
339
+ };
340
+
341
+ return {
342
+ content: [{ type: "text", text: buildAskSessionContent([result]) }],
343
+ details,
344
+ };
345
+ }
346
+
347
+ const results: QuestionResult[] = [];
348
+ const tabResult = await askQuestionsWithTabs(ctx.ui, params.questions as AskQuestion[]);
349
+ for (let i = 0; i < params.questions.length; i++) {
350
+ const q = params.questions[i]!;
351
+ const selection = tabResult.selections[i] ?? { selectedOptions: [] };
352
+ const desc = q.description && q.description.trim().length > 0 ? q.description : undefined;
353
+ results.push({
354
+ id: q.id,
355
+ question: q.question,
356
+ description: desc,
357
+ options: q.options.map((option) => option.label),
358
+ multi: q.multi ?? false,
359
+ selectedOptions: selection.selectedOptions,
360
+ customInput: selection.customInput,
361
+ });
362
+ }
363
+
364
+ return {
365
+ content: [{ type: "text", text: buildAskSessionContent(results) }],
366
+ details: { results, customInput: undefined, description: undefined } satisfies AskToolDetails,
367
+ };
368
+ },
369
+ });
370
+ }