@howaboua/pi-ask 0.0.11 → 0.0.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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @howaboua/pi-ask
2
2
 
3
+ ## 0.0.13
4
+
5
+ - Ask now accepts supervisor replies through Shepherdr's `reply` action. Update and reload Ask and Shepherdr together on controllers and workers.
6
+
7
+ - Supervisors receive questions, choices and a reply template. Valid replies complete the worker's existing wait or steer panel. Human handoffs still require the user's confirmation.
8
+ - Unknown or misnamed fields, such as `options` instead of `choices`, and malformed choices now return errors naming the invalid field and accepted shape instead of silently dropping input.
9
+
10
+ ## 0.0.12
11
+
12
+ - ### Improvements
13
+ - Ask returns help when called without arguments, including an empty structured object. Explicit help remains supported.
14
+
3
15
  ## 0.0.11
4
16
 
5
17
  - Parents can receive and answer asynchronous questions through Shepherdr while workers continue.
package/README.md CHANGED
@@ -26,6 +26,8 @@ For work only you can complete—sign-in, authorization, hardware access, or ano
26
26
 
27
27
  When Pi runs inside Herdr with its Pi integration installed, only a waiting ask marks the pane as blocked. A steering ask remains pending while the agent continues.
28
28
 
29
+ With Shepherdr, supervising agents receive the question, choices and a ready-to-fill reply. Replying completes the same panel. Ordinary agent messages do not answer it, and pending questions remain visible in later reports. Human handoffs still require your action and confirmation.
30
+
29
31
  `Other/rephrase` is always available. Submit it blank when the prompt needs to be rephrased, split, or followed up instead of answered as written.
30
32
 
31
33
  ## Prompt commands
package/ask/contracts.ts CHANGED
@@ -16,8 +16,9 @@ const PromptSchema = Type.Object({
16
16
  });
17
17
 
18
18
  export const AskParameters = Type.Object({
19
+ help: Type.Optional(Type.Literal(true)),
19
20
  handoff: Type.Optional(Type.Boolean()),
20
- prompts: Type.Array(PromptSchema),
21
+ prompts: Type.Optional(Type.Array(PromptSchema)),
21
22
  delivery: Type.Optional(StringEnum(ASK_DELIVERIES)),
22
23
  });
23
24
 
@@ -3,6 +3,7 @@ import type { AskPrompt, AskResponse, PendingAsk } from "./contracts.js";
3
3
  import { normalizeResponses, summarizeResponses } from "./normalize.js";
4
4
  import type { PendingAskUpdate } from "./pending.js";
5
5
  import { askWithPiUi } from "./pi-ui.js";
6
+ import { AskRequests, type RequestChange } from "./requests.js";
6
7
  import { askInTui } from "./tui.js";
7
8
 
8
9
  type AskInComposer = (
@@ -14,10 +15,11 @@ export interface AskCoordinatorOptions {
14
15
  askInComposer?: AskInComposer;
15
16
  deliverSteer?: (message: string) => void;
16
17
  onPendingChange?: (update: PendingAskUpdate) => void;
17
- onSteerActiveChange?: (request: PendingAsk, active: boolean) => void;
18
+ onRequestChange?: (change: RequestChange) => void;
18
19
  }
19
20
 
20
21
  interface PresentAskOptions {
22
+ id: string;
21
23
  onActiveChange?: (active: boolean) => void;
22
24
  handoff?: boolean;
23
25
  steering?: boolean;
@@ -35,41 +37,46 @@ export function createAskCoordinator({
35
37
  askInComposer,
36
38
  deliverSteer,
37
39
  onPendingChange,
38
- onSteerActiveChange,
40
+ onRequestChange,
39
41
  }: AskCoordinatorOptions = {}) {
42
+ const requests = new AskRequests(onRequestChange);
40
43
  let generation = 0;
41
44
  let sessionAbort = new AbortController();
42
45
  let presentationTail = Promise.resolve();
43
46
  const activeSteers = new Set<string>();
44
- let presentedSteer: PendingAsk | undefined;
45
47
 
46
48
  const present = (
47
49
  ctx: ExtensionContext,
48
50
  prompts: AskPrompt[],
49
51
  {
52
+ id,
50
53
  handoff = false,
51
54
  steering = false,
52
55
  signal,
53
56
  onActiveChange,
54
- }: PresentAskOptions = {},
57
+ }: PresentAskOptions,
55
58
  ): Promise<unknown> => {
59
+ const request = requests.open({ id, prompts, handoff, steering }, signal);
56
60
  const run = async () => {
57
- if (signal?.aborted) return null;
61
+ if (request.signal.aborted) return;
58
62
  try {
59
63
  onActiveChange?.(true);
60
- return askInComposer
61
- ? await askInComposer(prompts, signal)
64
+ const response = askInComposer
65
+ ? await askInComposer(prompts, request.signal)
62
66
  : ctx.mode === "tui"
63
67
  ? await askInTui(ctx, prompts, {
64
68
  handoff,
65
69
  steering,
66
- ...(signal ? { signal } : {}),
70
+ signal: request.signal,
67
71
  })
68
72
  : await askWithPiUi(ctx, prompts, {
69
73
  handoff,
70
74
  steering,
71
- ...(signal ? { signal } : {}),
75
+ signal: request.signal,
72
76
  });
77
+ request.select(response);
78
+ } catch (error) {
79
+ request.fail(error);
73
80
  } finally {
74
81
  onActiveChange?.(false);
75
82
  }
@@ -79,7 +86,7 @@ export function createAskCoordinator({
79
86
  () => undefined,
80
87
  () => undefined,
81
88
  );
82
- return result;
89
+ return request.response;
83
90
  };
84
91
 
85
92
  const notifyFailure = (ctx: ExtensionContext, error: unknown) => {
@@ -102,7 +109,7 @@ export function createAskCoordinator({
102
109
  ctx: ExtensionContext,
103
110
  id: string,
104
111
  responses?: AskResponse[],
105
- ) => {
112
+ ): boolean => {
106
113
  activeSteers.delete(id);
107
114
  try {
108
115
  onPendingChange?.({
@@ -111,8 +118,10 @@ export function createAskCoordinator({
111
118
  id,
112
119
  ...(responses ? { responses } : { dismissed: true }),
113
120
  });
121
+ return true;
114
122
  } catch (error) {
115
123
  notifyFailure(ctx, error);
124
+ return false;
116
125
  }
117
126
  };
118
127
 
@@ -133,49 +142,74 @@ export function createAskCoordinator({
133
142
  activeSteers.add(request.id);
134
143
  const requestGeneration = generation;
135
144
  void present(ctx, request.prompts, {
145
+ id: request.id,
136
146
  steering: true,
137
147
  signal: sessionAbort.signal,
138
- onActiveChange: (active) => {
139
- if (requestGeneration === generation && !sessionAbort.signal.aborted) {
140
- presentedSteer = active ? request : undefined;
141
- onSteerActiveChange?.(request, active);
142
- }
143
- },
144
- }).then(
145
- (responses) => {
148
+ })
149
+ .then(
150
+ (responses) => {
151
+ if (requestGeneration !== generation || sessionAbort.signal.aborted)
152
+ return;
153
+ if (deliver(ctx, steerResponse(request.prompts, responses))) {
154
+ const saved = close(
155
+ ctx,
156
+ request.id,
157
+ normalizeResponses(request.prompts, responses) ?? undefined,
158
+ );
159
+ requests.complete(
160
+ request.id,
161
+ saved
162
+ ? undefined
163
+ : "Answer delivered, but question state could not be saved; inspect the agent before retrying",
164
+ );
165
+ } else {
166
+ requests.complete(
167
+ request.id,
168
+ "Answer could not be delivered; the question is open again",
169
+ );
170
+ activeSteers.delete(request.id);
171
+ startSteer(request, ctx, false);
172
+ }
173
+ },
174
+ (error: unknown) => {
175
+ if (requestGeneration !== generation || sessionAbort.signal.aborted)
176
+ return;
177
+ const message = notifyFailure(ctx, error);
178
+ if (
179
+ deliver(
180
+ ctx,
181
+ `The asynchronous ask failed before I could answer: ${message}`,
182
+ )
183
+ ) {
184
+ const saved = close(ctx, request.id);
185
+ requests.complete(
186
+ request.id,
187
+ saved
188
+ ? undefined
189
+ : "Question state could not be saved; inspect the agent before retrying",
190
+ );
191
+ } else requests.complete(request.id, message);
192
+ },
193
+ )
194
+ .catch((error: unknown) => {
146
195
  if (requestGeneration !== generation || sessionAbort.signal.aborted)
147
196
  return;
148
- if (deliver(ctx, steerResponse(request.prompts, responses)))
149
- close(
150
- ctx,
151
- request.id,
152
- normalizeResponses(request.prompts, responses) ?? undefined,
153
- );
154
- },
155
- (error: unknown) => {
156
- if (requestGeneration !== generation || sessionAbort.signal.aborted)
157
- return;
158
- const message = notifyFailure(ctx, error);
159
- if (
160
- deliver(
161
- ctx,
162
- `The asynchronous ask failed before I could answer: ${message}`,
163
- )
164
- )
165
- close(ctx, request.id);
166
- },
167
- );
197
+ requests.complete(request.id, String(error));
198
+ notifyFailure(ctx, error);
199
+ });
168
200
  };
169
201
 
170
202
  return {
171
203
  canSteer: deliverSteer !== undefined,
172
204
  present,
205
+ reply: (id: string, message: string) => requests.reply(id, message),
206
+ complete: (id: string, error?: string) => requests.complete(id, error),
173
207
  requestSteer(request: PendingAsk, ctx: ExtensionContext) {
174
208
  startSteer(request, ctx, true);
175
209
  },
176
210
  restorePending(pending: readonly PendingAsk[], ctx: ExtensionContext) {
177
211
  generation++;
178
- presentedSteer = undefined;
212
+ requests.reset();
179
213
  sessionAbort.abort();
180
214
  sessionAbort = new AbortController();
181
215
  presentationTail = Promise.resolve();
@@ -184,14 +218,10 @@ export function createAskCoordinator({
184
218
  for (const request of pending) startSteer(request, ctx, false);
185
219
  },
186
220
  shutdown() {
187
- try {
188
- if (presentedSteer) onSteerActiveChange?.(presentedSteer, false);
189
- } finally {
190
- presentedSteer = undefined;
191
- generation++;
192
- sessionAbort.abort();
193
- activeSteers.clear();
194
- }
221
+ generation++;
222
+ requests.reset();
223
+ sessionAbort.abort();
224
+ activeSteers.clear();
195
225
  },
196
226
  get sessionSignal(): AbortSignal {
197
227
  return sessionAbort.signal;
@@ -0,0 +1,85 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { AskRequest, RequestChange } from "./requests.js";
3
+
4
+ const ENTRY = "shepherdr-extension-message";
5
+ const REPLY_CHANNEL = "@howaboua/pi-shepherdr/extension-reply/v1";
6
+ const EXTENSION = "@howaboua/pi-ask";
7
+
8
+ function questionText(request: AskRequest): string {
9
+ return [
10
+ request.handoff
11
+ ? "Human action required; ask the user to complete this handoff"
12
+ : request.steering
13
+ ? "Question pending; the agent can continue reversible work, but still needs an answer"
14
+ : "Question pending; the agent is waiting for an answer",
15
+ ...request.prompts.map((prompt, index) =>
16
+ [
17
+ `${index + 1}. ${prompt.title}`,
18
+ ...(prompt.body ? [prompt.body] : []),
19
+ ...(prompt.choices.length
20
+ ? [
21
+ `Choose ${prompt.multiple ? "one or more" : "one"}:`,
22
+ ...prompt.choices.map(
23
+ (choice) =>
24
+ `- ${choice.label}${choice.description ? `: ${choice.description}` : ""}`,
25
+ ),
26
+ ]
27
+ : ["Free-text answer"]),
28
+ ].join("\n"),
29
+ ),
30
+ ...(request.handoff
31
+ ? []
32
+ : [
33
+ "Reply with a JSON array in prompt order: selections contains exact choice labels; other supplies free text; comment is optional. Blank other requests rephrasing. Replace the placeholders in the reply below, do not send them literally. An ordinary agent message does not resolve this question",
34
+ ]),
35
+ ].join("\n\n");
36
+ }
37
+
38
+ /** Ask owns question text and reply semantics; Shepherdr carries opaque strings. */
39
+ export function publishAskRequest(
40
+ pi: ExtensionAPI,
41
+ change: RequestChange,
42
+ ): void {
43
+ const base = { version: 1, extension: EXTENSION };
44
+ if (change.state === "closed") {
45
+ pi.appendEntry(ENTRY, { ...base, id: change.id, state: "closed" });
46
+ return;
47
+ }
48
+ const { request } = change;
49
+ pi.appendEntry(ENTRY, {
50
+ ...base,
51
+ id: request.id,
52
+ state: "pending",
53
+ message: questionText(request),
54
+ blocking: !request.steering,
55
+ ...(request.handoff
56
+ ? {}
57
+ : {
58
+ reply: JSON.stringify(
59
+ request.prompts.map((prompt) =>
60
+ prompt.choices.length
61
+ ? { selections: ["<choice label>"] }
62
+ : { other: "<answer>" },
63
+ ),
64
+ ),
65
+ }),
66
+ });
67
+ }
68
+
69
+ export function registerAskReplies(
70
+ pi: ExtensionAPI,
71
+ reply: (id: string, message: string) => Promise<string>,
72
+ ): void {
73
+ pi.events.on(REPLY_CHANNEL, (value: unknown) => {
74
+ if (!value || typeof value !== "object") return;
75
+ const event = value as Record<string, unknown>;
76
+ if (
77
+ event["extension"] !== EXTENSION ||
78
+ typeof event["id"] !== "string" ||
79
+ typeof event["message"] !== "string" ||
80
+ typeof event["respond"] !== "function"
81
+ )
82
+ return;
83
+ event["respond"](reply(event["id"], event["message"]));
84
+ });
85
+ }
package/ask/normalize.ts CHANGED
@@ -44,6 +44,44 @@ function normalizePrompt(
44
44
  };
45
45
  }
46
46
 
47
+ const ASK_FIELDS = ["help", "handoff", "prompts", "delivery"];
48
+ const PROMPT_FIELDS = ["title", "body", "multiple", "choices"];
49
+ const CHOICE_FIELDS = ["label", "description"];
50
+ const CHOICE_SHAPE = "{ label, description? }";
51
+
52
+ function unknownField(
53
+ value: Record<string, unknown>,
54
+ fields: string[],
55
+ path: string,
56
+ ): string | undefined {
57
+ const field = Object.keys(value).find((key) => !fields.includes(key));
58
+ return field === undefined
59
+ ? undefined
60
+ : `Unknown ask field ${path}${field}; allowed: ${fields.join(", ")}`;
61
+ }
62
+
63
+ /** Why a tool call would lose input to normalization, so a misnamed field fails instead of vanishing. */
64
+ export function askInputProblem(params: unknown): string | undefined {
65
+ if (!isRecord(params)) return undefined;
66
+ const top = unknownField(params, ASK_FIELDS, "");
67
+ if (top) return top;
68
+ if (!Array.isArray(params["prompts"])) return undefined;
69
+ for (const [index, prompt] of params["prompts"].entries()) {
70
+ if (!isRecord(prompt)) continue;
71
+ const path = `prompts[${index}]`;
72
+ const field = unknownField(prompt, PROMPT_FIELDS, `${path}.`);
73
+ if (field) return `${field}; choices: [${CHOICE_SHAPE}]`;
74
+ if (!Array.isArray(prompt["choices"])) continue;
75
+ for (const [choiceIndex, choice] of prompt["choices"].entries()) {
76
+ const choicePath = `${path}.choices[${choiceIndex}]`;
77
+ if (!isRecord(choice)) return `${choicePath} must be ${CHOICE_SHAPE}`;
78
+ const choiceField = unknownField(choice, CHOICE_FIELDS, `${choicePath}.`);
79
+ if (choiceField) return choiceField;
80
+ }
81
+ }
82
+ return undefined;
83
+ }
84
+
47
85
  export function isSteeringAskInput(input: unknown): boolean {
48
86
  return (
49
87
  isRecord(input) &&
@@ -52,6 +90,13 @@ export function isSteeringAskInput(input: unknown): boolean {
52
90
  );
53
91
  }
54
92
 
93
+ export function isAskHelpInput(input: unknown): boolean {
94
+ if (input === undefined) return true;
95
+ if (!isRecord(input) || Array.isArray(input)) return false;
96
+ const keys = Object.keys(input);
97
+ return keys.length === 0 || (keys.length === 1 && input["help"] === true);
98
+ }
99
+
55
100
  export function normalizeAskInput(params: unknown): {
56
101
  delivery: "wait" | "steer";
57
102
  handoff: boolean;
@@ -0,0 +1,189 @@
1
+ import type { AskPrompt, AskResponse } from "./contracts.js";
2
+ import { customSelectionFor } from "./state.js";
3
+
4
+ export interface AskRequest {
5
+ id: string;
6
+ prompts: AskPrompt[];
7
+ handoff: boolean;
8
+ steering: boolean;
9
+ }
10
+
11
+ export type RequestChange =
12
+ | { state: "pending"; request: AskRequest }
13
+ | { state: "closed"; id: string };
14
+
15
+ type Completion = { error: string } | { message: string };
16
+
17
+ interface OpenRequest {
18
+ request: AskRequest;
19
+ controller: AbortController;
20
+ selected: boolean;
21
+ resolve: (response: unknown) => void;
22
+ finish: (result: Completion) => void;
23
+ completion: Promise<Completion>;
24
+ }
25
+
26
+ function parseAnswers(prompts: AskPrompt[], text: string): AskResponse[] {
27
+ let input: unknown;
28
+ try {
29
+ input = JSON.parse(text);
30
+ } catch {
31
+ throw new Error("Reply must be a JSON array of answers in prompt order");
32
+ }
33
+ if (!Array.isArray(input) || input.length !== prompts.length)
34
+ throw new Error(
35
+ `Provide exactly ${prompts.length} answers in prompt order`,
36
+ );
37
+ return prompts.map((prompt, index) => {
38
+ const value: unknown = input[index];
39
+ if (!value || typeof value !== "object" || Array.isArray(value))
40
+ throw new Error(`Answer ${index + 1} must be an object`);
41
+ const answer = value as Record<string, unknown>;
42
+ if (
43
+ Object.keys(answer).some(
44
+ (key) => !["selections", "other", "comment"].includes(key),
45
+ ) ||
46
+ (answer["other"] !== undefined && typeof answer["other"] !== "string") ||
47
+ (answer["comment"] !== undefined && typeof answer["comment"] !== "string")
48
+ )
49
+ throw new Error(`Answer ${index + 1}: use selections, other or comment`);
50
+ const selections = answer["selections"] ?? [];
51
+ if (
52
+ !Array.isArray(selections) ||
53
+ !selections.every(
54
+ (value): value is string =>
55
+ typeof value === "string" &&
56
+ prompt.choices.some((choice) => choice.label === value),
57
+ )
58
+ )
59
+ throw new Error(
60
+ `Answer ${index + 1}: selections must match choice labels`,
61
+ );
62
+ const selected = [...new Set(selections)];
63
+ if (typeof answer["other"] === "string")
64
+ selected.push(customSelectionFor(answer["other"]));
65
+ const comment =
66
+ typeof answer["comment"] === "string" ? answer["comment"].trim() : "";
67
+ if (!selected.length && !comment)
68
+ throw new Error(
69
+ `Answer ${index + 1}: supply a selection, other or comment`,
70
+ );
71
+ if (!prompt.multiple && selected.length > 1)
72
+ throw new Error(
73
+ `Answer ${index + 1} accepts only one selection or other`,
74
+ );
75
+ return {
76
+ id: prompt.id,
77
+ selections: selected,
78
+ ...(comment ? { comment } : {}),
79
+ };
80
+ });
81
+ }
82
+
83
+ /** Owns the race between the human panel, remote replies and session cancellation. */
84
+ export class AskRequests {
85
+ private readonly requests = new Map<string, OpenRequest>();
86
+ private readonly onChange: ((change: RequestChange) => void) | undefined;
87
+ constructor(onChange?: (change: RequestChange) => void) {
88
+ this.onChange = onChange;
89
+ }
90
+
91
+ open(request: AskRequest, signal?: AbortSignal) {
92
+ if (this.requests.has(request.id))
93
+ throw new Error("Ask is already pending");
94
+ let resolve!: (response: unknown) => void;
95
+ let reject!: (error: unknown) => void;
96
+ let finish!: (result: Completion) => void;
97
+ const response = new Promise<unknown>((done, fail) => {
98
+ resolve = done;
99
+ reject = fail;
100
+ });
101
+ const completion = new Promise<Completion>((done) => {
102
+ finish = done;
103
+ });
104
+ const entry: OpenRequest = {
105
+ request,
106
+ controller: new AbortController(),
107
+ selected: false,
108
+ resolve,
109
+ finish,
110
+ completion,
111
+ };
112
+ const select = (value: unknown) => {
113
+ if (entry.selected) return;
114
+ entry.selected = true;
115
+ resolve(value);
116
+ entry.controller.abort();
117
+ };
118
+ const abort = () => select(null);
119
+ this.onChange?.({ state: "pending", request });
120
+ this.requests.set(request.id, entry);
121
+ signal?.addEventListener("abort", abort, { once: true });
122
+ if (signal?.aborted) abort();
123
+ return {
124
+ response: response.finally(() =>
125
+ signal?.removeEventListener("abort", abort),
126
+ ),
127
+ signal: entry.controller.signal,
128
+ select,
129
+ fail(error: unknown) {
130
+ if (entry.selected) return;
131
+ entry.selected = true;
132
+ reject(error);
133
+ entry.controller.abort();
134
+ },
135
+ };
136
+ }
137
+
138
+ async reply(id: string, text: string): Promise<string> {
139
+ const entry = this.requests.get(id);
140
+ if (!entry)
141
+ throw new Error(
142
+ "This question is no longer pending; read the agent's latest state",
143
+ );
144
+ if (entry.request.handoff)
145
+ throw new Error(
146
+ "This handoff requires the user's action and confirmation",
147
+ );
148
+ if (entry.selected)
149
+ throw new Error(
150
+ "This question already has a response; read the agent's latest state",
151
+ );
152
+ const answers = parseAnswers(entry.request.prompts, text);
153
+ entry.selected = true;
154
+ entry.resolve(answers);
155
+ entry.controller.abort();
156
+ const result = await entry.completion;
157
+ if ("error" in result) throw new Error(result.error);
158
+ return result.message;
159
+ }
160
+
161
+ complete(id: string, error?: string): void {
162
+ const entry = this.requests.get(id);
163
+ if (!entry) return;
164
+ this.requests.delete(id);
165
+ try {
166
+ if (!error || !entry.request.steering)
167
+ this.onChange?.({ state: "closed", id });
168
+ entry.finish(error ? { error } : { message: "Answer accepted" });
169
+ } catch (failure) {
170
+ entry.finish({
171
+ error: `Response received, but question state could not be saved: ${String(failure)}`,
172
+ });
173
+ throw failure;
174
+ }
175
+ }
176
+
177
+ reset(): void {
178
+ for (const entry of this.requests.values()) {
179
+ entry.selected = true;
180
+ entry.resolve(null);
181
+ entry.controller.abort();
182
+ this.complete(
183
+ entry.request.id,
184
+ "Session changed before the answer was delivered; read the agent's latest state",
185
+ );
186
+ }
187
+ this.requests.clear();
188
+ }
189
+ }
package/ask/tool.ts CHANGED
@@ -1,11 +1,14 @@
1
1
  import { defineTool } from "@earendil-works/pi-coding-agent";
2
2
  import { Text } from "@earendil-works/pi-tui";
3
+ import { Check } from "typebox/value";
3
4
  import { AskParameters, type AskPrompt } from "./contracts.js";
4
5
  import {
5
6
  type AskCoordinatorOptions,
6
7
  createAskCoordinator,
7
8
  } from "./coordinator.js";
8
9
  import {
10
+ askInputProblem,
11
+ isAskHelpInput,
9
12
  isSteeringAskInput,
10
13
  normalizeAskInput,
11
14
  normalizeResponses,
@@ -33,27 +36,46 @@ export function createAskRuntime({
33
36
  deliverSteer,
34
37
  onBlockedChange,
35
38
  onPendingChange,
36
- onSteerActiveChange,
39
+ onRequestChange,
37
40
  }: AskToolOptions = {}) {
38
41
  const coordinator = createAskCoordinator({
39
42
  ...(askInComposer ? { askInComposer } : {}),
40
43
  ...(deliverSteer ? { deliverSteer } : {}),
41
44
  ...(onPendingChange ? { onPendingChange } : {}),
42
- ...(onSteerActiveChange ? { onSteerActiveChange } : {}),
45
+ ...(onRequestChange ? { onRequestChange } : {}),
43
46
  });
44
47
 
45
48
  const tool = defineTool({
46
49
  name: "ask",
47
50
  label: "Ask",
48
- description: "Request user input or action",
51
+ description: "Request user input or action; {} for help",
49
52
  parameters: AskParameters,
50
- promptGuidelines: [
51
- "ask: Omit delivery to wait; steer only while reversible work can continue. Handoffs wait for user action; state the completion signal",
52
- "ask: For reviews, make each finding a prompt with disposition choices; do not report first.",
53
- "ask: Other/rephrase is automatic; omit choices for free text",
54
- ],
55
53
  executionMode: "sequential",
54
+ prepareArguments(input) {
55
+ const params = input === undefined ? {} : input;
56
+ const problem = askInputProblem(params);
57
+ if (problem) throw new Error(problem);
58
+ if (!Check(AskParameters, params))
59
+ throw new Error("Invalid ask arguments; ask({}) returns help");
60
+ return params;
61
+ },
56
62
  async execute(toolCallId, params, signal, _onUpdate, ctx) {
63
+ if (isAskHelpInput(params)) {
64
+ return {
65
+ content: [
66
+ textContent(
67
+ [
68
+ "ask({}) or ask({ help: true }) returns help",
69
+ 'ask({ prompts: [{ title, body?, multiple?, choices?: [{ label, description? }] }], delivery?: "wait"|"steer", handoff? })',
70
+ "Omit delivery to wait; steer only while reversible work can continue. Handoffs wait for user action; state the completion signal",
71
+ "For reviews, make each finding a prompt with disposition choices; do not report first",
72
+ "Other/rephrase is automatic; omit choices for free text. Blank Other/rephrase means rephrase or follow up",
73
+ ].join("\n"),
74
+ ),
75
+ ],
76
+ details: { kind: "help" },
77
+ };
78
+ }
57
79
  const { delivery, handoff, prompts } = normalizeAskInput(params);
58
80
  if (prompts.length === 0) {
59
81
  throw new Error(
@@ -92,12 +114,20 @@ export function createAskRuntime({
92
114
  const presentationSignal = signal
93
115
  ? AbortSignal.any([signal, coordinator.sessionSignal])
94
116
  : coordinator.sessionSignal;
95
- const rawResponses = await coordinator.present(ctx, prompts, {
96
- handoff,
97
- signal: presentationSignal,
98
- onActiveChange: (active) =>
99
- onBlockedChange?.({ ...blockedState, active }),
100
- });
117
+ let rawResponses: unknown;
118
+ try {
119
+ rawResponses = await coordinator.present(ctx, prompts, {
120
+ id: toolCallId,
121
+ handoff,
122
+ signal: presentationSignal,
123
+ onActiveChange: (active) =>
124
+ onBlockedChange?.({ ...blockedState, active }),
125
+ });
126
+ coordinator.complete(toolCallId);
127
+ } catch (error) {
128
+ coordinator.complete(toolCallId, String(error));
129
+ throw error;
130
+ }
101
131
  const responses = normalizeResponses(prompts, rawResponses);
102
132
  if (!responses) {
103
133
  return {
@@ -115,6 +145,8 @@ export function createAskRuntime({
115
145
  };
116
146
  },
117
147
  renderCall(args, theme, context) {
148
+ if (isAskHelpInput(args))
149
+ return new Text(theme.fg("toolTitle", theme.bold("ask help")), 0, 0);
118
150
  const count = Array.isArray(args.prompts) ? args.prompts.length : 0;
119
151
  const label =
120
152
  args.handoff === true
@@ -142,6 +174,7 @@ export function createAskRuntime({
142
174
 
143
175
  return {
144
176
  tool,
177
+ reply: coordinator.reply,
145
178
  restorePending: coordinator.restorePending,
146
179
  shutdown: coordinator.shutdown,
147
180
  };
package/index.ts CHANGED
@@ -9,7 +9,11 @@ import {
9
9
  createSteerDelivery,
10
10
  type TryCodexDeveloperMessage,
11
11
  } from "./ask/delivery.js";
12
- import { isSteeringAskInput } from "./ask/normalize.js";
12
+ import {
13
+ publishAskRequest,
14
+ registerAskReplies,
15
+ } from "./ask/extension-messages.js";
16
+ import { isAskHelpInput, isSteeringAskInput } from "./ask/normalize.js";
13
17
  import { PENDING_ASK_ENTRY_TYPE, readPendingAsks } from "./ask/pending.js";
14
18
  import { createAskRuntime, createAskTool } from "./ask/tool.js";
15
19
  import registerPackageChangelog from "./changelog.js";
@@ -27,57 +31,25 @@ export default async function humanInTheLoop(pi: ExtensionAPI): Promise<void> {
27
31
  registerPackageChangelog(pi);
28
32
  const trySendCodexDeveloperMessage = await loadCodexDeveloperMessageSender();
29
33
  const askRuntime = createAskRuntime({
34
+ onRequestChange: (change) => publishAskRequest(pi, change),
30
35
  deliverSteer: createSteerDelivery(pi, trySendCodexDeveloperMessage),
31
36
  onBlockedChange: (state) => {
32
- const notify = () => {
33
- pi.events.emit(REALTIME_VOICE_PROMPT_CHANNEL, {
34
- id: state.id,
35
- active: state.active,
36
- prompt: state.prompt,
37
- });
38
- pi.events.emit("herdr:blocked", {
39
- active: state.active,
40
- label: state.label,
41
- });
42
- };
43
- // Publish before Herdr reports blocked, including calls nested inside exec.
44
- try {
45
- pi.appendEntry(
46
- "pi-ask-active",
47
- state.active
48
- ? {
49
- version: 1,
50
- state: "active",
51
- id: state.id,
52
- handoff: state.handoff,
53
- prompts: state.prompts,
54
- }
55
- : { version: 1, state: "closed", id: state.id },
56
- );
57
- } finally {
58
- if (!state.active) notify();
59
- }
60
- if (state.active) notify();
37
+ pi.events.emit(REALTIME_VOICE_PROMPT_CHANNEL, {
38
+ id: state.id,
39
+ active: state.active,
40
+ prompt: state.prompt,
41
+ });
42
+ pi.events.emit("herdr:blocked", {
43
+ active: state.active,
44
+ label: state.label,
45
+ });
61
46
  },
62
47
  onPendingChange: (update) => {
63
48
  pi.appendEntry(PENDING_ASK_ENTRY_TYPE, update);
64
49
  },
65
- onSteerActiveChange: (request, active) => {
66
- pi.appendEntry(
67
- "pi-ask-active",
68
- active
69
- ? {
70
- version: 1,
71
- state: "active",
72
- id: request.id,
73
- delivery: "steer",
74
- prompts: request.prompts,
75
- }
76
- : { version: 1, state: "closed", id: request.id },
77
- );
78
- },
79
50
  });
80
51
  const ask = askRuntime.tool;
52
+ registerAskReplies(pi, askRuntime.reply);
81
53
  const restorePending = (ctx: ExtensionContext) => {
82
54
  askRuntime.restorePending(
83
55
  readPendingAsks(ctx.sessionManager.getBranch()),
@@ -127,9 +99,9 @@ async function registerAskInCodeMode(
127
99
  await import("@howaboua/pi-codex-conversion/code-mode");
128
100
  const registration = registerCodeModeExtensionTools(pi, () => [
129
101
  adaptToolForCodeMode(ask, {
130
- blocking: (input) => !isSteeringAskInput(input),
131
- usage:
132
- 'await tools.ask({ prompts: [{ title, body?, multiple?, choices?: [{ label, description? }] }], delivery?: "wait"|"steer", handoff? })',
102
+ blocking: (input) =>
103
+ !isAskHelpInput(input) && !isSteeringAskInput(input),
104
+ usage: "await tools.ask() // help",
133
105
  }),
134
106
  ]);
135
107
  pi.on("session_shutdown", () => registration.unregister());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@howaboua/pi-ask",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
4
4
  "description": "Interactive decisions, review triage, and human handoffs for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -45,7 +45,7 @@
45
45
  ]
46
46
  },
47
47
  "peerDependencies": {
48
- "@howaboua/pi-codex-conversion": ">=3.0.46",
48
+ "@howaboua/pi-codex-conversion": ">=3.0.50",
49
49
  "@earendil-works/pi-ai": ">=1.0.0",
50
50
  "@earendil-works/pi-coding-agent": ">=1.0.0",
51
51
  "@earendil-works/pi-tui": ">=1.0.0",
@@ -57,18 +57,17 @@
57
57
  }
58
58
  },
59
59
  "devDependencies": {
60
- "@howaboua/pi-codex-conversion": "3.0.46",
60
+ "@howaboua/pi-codex-conversion": "3.0.50",
61
61
  "@biomejs/biome": "^2.5.10",
62
- "@earendil-works/pi-ai": "1.0.4",
63
- "@earendil-works/pi-coding-agent": "1.0.4",
64
- "@earendil-works/pi-tui": "1.0.4",
62
+ "@earendil-works/pi-ai": "1.1.0",
63
+ "@earendil-works/pi-coding-agent": "1.1.0",
64
+ "@earendil-works/pi-tui": "1.1.0",
65
65
  "typebox": "1.3.18",
66
66
  "typescript": "^7.0.2"
67
67
  },
68
68
  "scripts": {
69
- "check": "bun run lint && bun run typecheck && bun run test",
69
+ "check": "bun run lint && bun run typecheck",
70
70
  "prepack": "bun run check",
71
- "test": "bun test",
72
71
  "typecheck": "tsgo --noEmit",
73
72
  "lint": "biome check .",
74
73
  "format": "biome check --write .",