@howaboua/pi-ask 0.0.12 → 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,12 @@
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
+
3
10
  ## 0.0.12
4
11
 
5
12
  - ### Improvements
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
@@ -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) &&
@@ -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,13 @@
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,
9
11
  isAskHelpInput,
10
12
  isSteeringAskInput,
11
13
  normalizeAskInput,
@@ -34,13 +36,13 @@ export function createAskRuntime({
34
36
  deliverSteer,
35
37
  onBlockedChange,
36
38
  onPendingChange,
37
- onSteerActiveChange,
39
+ onRequestChange,
38
40
  }: AskToolOptions = {}) {
39
41
  const coordinator = createAskCoordinator({
40
42
  ...(askInComposer ? { askInComposer } : {}),
41
43
  ...(deliverSteer ? { deliverSteer } : {}),
42
44
  ...(onPendingChange ? { onPendingChange } : {}),
43
- ...(onSteerActiveChange ? { onSteerActiveChange } : {}),
45
+ ...(onRequestChange ? { onRequestChange } : {}),
44
46
  });
45
47
 
46
48
  const tool = defineTool({
@@ -49,6 +51,14 @@ export function createAskRuntime({
49
51
  description: "Request user input or action; {} for help",
50
52
  parameters: AskParameters,
51
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
+ },
52
62
  async execute(toolCallId, params, signal, _onUpdate, ctx) {
53
63
  if (isAskHelpInput(params)) {
54
64
  return {
@@ -104,12 +114,20 @@ export function createAskRuntime({
104
114
  const presentationSignal = signal
105
115
  ? AbortSignal.any([signal, coordinator.sessionSignal])
106
116
  : coordinator.sessionSignal;
107
- const rawResponses = await coordinator.present(ctx, prompts, {
108
- handoff,
109
- signal: presentationSignal,
110
- onActiveChange: (active) =>
111
- onBlockedChange?.({ ...blockedState, active }),
112
- });
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
+ }
113
131
  const responses = normalizeResponses(prompts, rawResponses);
114
132
  if (!responses) {
115
133
  return {
@@ -156,6 +174,7 @@ export function createAskRuntime({
156
174
 
157
175
  return {
158
176
  tool,
177
+ reply: coordinator.reply,
159
178
  restorePending: coordinator.restorePending,
160
179
  shutdown: coordinator.shutdown,
161
180
  };
package/index.ts CHANGED
@@ -9,6 +9,10 @@ import {
9
9
  createSteerDelivery,
10
10
  type TryCodexDeveloperMessage,
11
11
  } from "./ask/delivery.js";
12
+ import {
13
+ publishAskRequest,
14
+ registerAskReplies,
15
+ } from "./ask/extension-messages.js";
12
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";
@@ -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()),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@howaboua/pi-ask",
3
- "version": "0.0.12",
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.48",
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,7 +57,7 @@
57
57
  }
58
58
  },
59
59
  "devDependencies": {
60
- "@howaboua/pi-codex-conversion": "3.0.48",
60
+ "@howaboua/pi-codex-conversion": "3.0.50",
61
61
  "@biomejs/biome": "^2.5.10",
62
62
  "@earendil-works/pi-ai": "1.1.0",
63
63
  "@earendil-works/pi-coding-agent": "1.1.0",