@howaboua/pi-ask 0.0.6 → 0.0.8

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,13 @@
1
1
  # @howaboua/pi-ask
2
2
 
3
+ ## 0.0.8
4
+
5
+ - Shepherdr now discovers and answers waiting questions invoked inside Code and Notebook Mode.
6
+
7
+ ## 0.0.7
8
+
9
+ - The `ask` tool now supports steering questions that return immediately, preserve the full response panel, and deliver answers at the next safe boundary using the developer role under active Pi Codex Responses.
10
+
3
11
  ## 0.0.6
4
12
 
5
13
  - Added a Code and Notebook Mode bridge API. This allows extensions that use Pi TUI to run inside `exec`.
package/README.md CHANGED
@@ -14,15 +14,17 @@ Try it for one session:
14
14
  pi -e npm:@howaboua/pi-ask
15
15
  ```
16
16
 
17
- When [Pi Codex](https://www.npmjs.com/package/@howaboua/pi-codex-conversion) 3.0.24 or newer is installed too, `ask` is available inside Code and Notebook Mode as `await tools.ask({ prompts, handoff? })`.
17
+ When [Pi Codex](https://www.npmjs.com/package/@howaboua/pi-codex-conversion) 3.0.25 or newer is installed too, `ask` is available inside Code and Notebook Mode as `await tools.ask({ prompts, delivery?, handoff? })`.
18
18
 
19
19
  ## How it behaves
20
20
 
21
21
  The agent can present several independently decidable prompts in one tabbed panel. Each prompt supports a short title, supporting evidence, one or more choices, free text, and an optional comment. Review findings become one prompt each, so you can fix, defer, or reject them without translating a wall of prose back into instructions.
22
22
 
23
+ Questions wait for your response by default. When the agent can continue useful reversible work, it can use `delivery: "steer"` instead. The tool then returns immediately, keeps the same panel open, and delivers your eventual response as steering at the next safe boundary. An active Pi Codex Responses adapter sends the answer with the developer role; other setups receive an ordinary user steering message. Pending steering questions survive session reloads.
24
+
23
25
  For work only you can complete—sign-in, authorization, hardware access, or another manual step—the agent can open a handoff and wait until you mark it done or unable to complete.
24
26
 
25
- When Pi runs inside Herdr with its Pi integration installed, an open ask panel marks the pane as blocked until you submit, dismiss, or abort it.
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.
26
28
 
27
29
  `Other/rephrase` is always available. Submit it blank when the prompt needs to be rephrased, split, or followed up instead of answered as written.
28
30
 
package/ask/contracts.ts CHANGED
@@ -1,5 +1,8 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
1
2
  import { type Static, Type } from "typebox";
2
3
 
4
+ const ASK_DELIVERIES = ["wait", "steer"] as const;
5
+
3
6
  const ChoiceSchema = Type.Object({
4
7
  label: Type.String({ description: "Short choice." }),
5
8
  description: Type.Optional(Type.String({ description: "Optional detail." })),
@@ -19,9 +22,14 @@ export const AskParameters = Type.Object({
19
22
  Type.Boolean({ description: "Wait for user action." }),
20
23
  ),
21
24
  prompts: Type.Array(PromptSchema, { description: "Prompts." }),
25
+ delivery: Type.Optional(
26
+ StringEnum(ASK_DELIVERIES, {
27
+ description:
28
+ "Wait for a gating response; steer while continuing reversible work. Omit to wait.",
29
+ }),
30
+ ),
22
31
  });
23
32
 
24
- export type AskInput = Static<typeof AskParameters>;
25
33
  export type PromptChoice = Static<typeof ChoiceSchema>;
26
34
 
27
35
  export interface AskPrompt {
@@ -32,6 +40,11 @@ export interface AskPrompt {
32
40
  choices: PromptChoice[];
33
41
  }
34
42
 
43
+ export interface PendingAsk {
44
+ id: string;
45
+ prompts: AskPrompt[];
46
+ }
47
+
35
48
  export interface AskResponse {
36
49
  id: string;
37
50
  selections: string[];
@@ -0,0 +1,172 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { AskPrompt, PendingAsk } from "./contracts.js";
3
+ import { normalizeResponses, summarizeResponses } from "./normalize.js";
4
+ import type { PendingAskUpdate } from "./pending.js";
5
+ import { askWithPiUi } from "./pi-ui.js";
6
+ import { askInTui } from "./tui.js";
7
+
8
+ type AskInComposer = (
9
+ prompts: AskPrompt[],
10
+ signal: AbortSignal | undefined,
11
+ ) => Promise<unknown>;
12
+
13
+ export interface AskCoordinatorOptions {
14
+ askInComposer?: AskInComposer;
15
+ deliverSteer?: (message: string) => void;
16
+ onPendingChange?: (update: PendingAskUpdate) => void;
17
+ }
18
+
19
+ interface PresentAskOptions {
20
+ onActiveChange?: (active: boolean) => void;
21
+ handoff?: boolean;
22
+ steering?: boolean;
23
+ signal?: AbortSignal;
24
+ }
25
+
26
+ function steerResponse(prompts: AskPrompt[], responses: unknown): string {
27
+ const normalized = normalizeResponses(prompts, responses);
28
+ return normalized
29
+ ? `Response to your earlier ask:\n${summarizeResponses(prompts, normalized)}`
30
+ : `I dismissed your earlier ask: ${prompts.map((prompt) => prompt.title).join("; ")}`;
31
+ }
32
+
33
+ export function createAskCoordinator({
34
+ askInComposer,
35
+ deliverSteer,
36
+ onPendingChange,
37
+ }: AskCoordinatorOptions = {}) {
38
+ let generation = 0;
39
+ let sessionAbort = new AbortController();
40
+ let presentationTail = Promise.resolve();
41
+ const activeSteers = new Set<string>();
42
+
43
+ const present = (
44
+ ctx: ExtensionContext,
45
+ prompts: AskPrompt[],
46
+ {
47
+ handoff = false,
48
+ steering = false,
49
+ signal,
50
+ onActiveChange,
51
+ }: PresentAskOptions = {},
52
+ ): Promise<unknown> => {
53
+ const run = async () => {
54
+ if (signal?.aborted) return null;
55
+ try {
56
+ onActiveChange?.(true);
57
+ return askInComposer
58
+ ? await askInComposer(prompts, signal)
59
+ : ctx.mode === "tui"
60
+ ? await askInTui(ctx, prompts, {
61
+ handoff,
62
+ steering,
63
+ ...(signal ? { signal } : {}),
64
+ })
65
+ : await askWithPiUi(ctx, prompts, {
66
+ handoff,
67
+ steering,
68
+ ...(signal ? { signal } : {}),
69
+ });
70
+ } finally {
71
+ onActiveChange?.(false);
72
+ }
73
+ };
74
+ const result = presentationTail.then(run, run);
75
+ presentationTail = result.then(
76
+ () => undefined,
77
+ () => undefined,
78
+ );
79
+ return result;
80
+ };
81
+
82
+ const notifyFailure = (ctx: ExtensionContext, error: unknown) => {
83
+ const message = error instanceof Error ? error.message : String(error);
84
+ ctx.ui.notify(`Async ask failed: ${message}`, "error");
85
+ return message;
86
+ };
87
+
88
+ const deliver = (ctx: ExtensionContext, message: string): boolean => {
89
+ try {
90
+ deliverSteer?.(message);
91
+ return true;
92
+ } catch (error) {
93
+ notifyFailure(ctx, error);
94
+ return false;
95
+ }
96
+ };
97
+
98
+ const close = (ctx: ExtensionContext, id: string) => {
99
+ activeSteers.delete(id);
100
+ try {
101
+ onPendingChange?.({ version: 1, state: "closed", id });
102
+ } catch (error) {
103
+ notifyFailure(ctx, error);
104
+ }
105
+ };
106
+
107
+ const startSteer = (
108
+ request: PendingAsk,
109
+ ctx: ExtensionContext,
110
+ persist: boolean,
111
+ ) => {
112
+ if (activeSteers.has(request.id) || sessionAbort.signal.aborted) return;
113
+ if (persist) {
114
+ onPendingChange?.({
115
+ version: 1,
116
+ state: "pending",
117
+ id: request.id,
118
+ prompts: request.prompts,
119
+ });
120
+ }
121
+ activeSteers.add(request.id);
122
+ const requestGeneration = generation;
123
+ void present(ctx, request.prompts, {
124
+ steering: true,
125
+ signal: sessionAbort.signal,
126
+ }).then(
127
+ (responses) => {
128
+ if (requestGeneration !== generation || sessionAbort.signal.aborted)
129
+ return;
130
+ if (deliver(ctx, steerResponse(request.prompts, responses)))
131
+ close(ctx, request.id);
132
+ },
133
+ (error: unknown) => {
134
+ if (requestGeneration !== generation || sessionAbort.signal.aborted)
135
+ return;
136
+ const message = notifyFailure(ctx, error);
137
+ if (
138
+ deliver(
139
+ ctx,
140
+ `The asynchronous ask failed before I could answer: ${message}`,
141
+ )
142
+ )
143
+ close(ctx, request.id);
144
+ },
145
+ );
146
+ };
147
+
148
+ return {
149
+ canSteer: deliverSteer !== undefined,
150
+ present,
151
+ requestSteer(request: PendingAsk, ctx: ExtensionContext) {
152
+ startSteer(request, ctx, true);
153
+ },
154
+ restorePending(pending: readonly PendingAsk[], ctx: ExtensionContext) {
155
+ generation++;
156
+ sessionAbort.abort();
157
+ sessionAbort = new AbortController();
158
+ presentationTail = Promise.resolve();
159
+ activeSteers.clear();
160
+ if ((!askInComposer && !ctx.hasUI) || !deliverSteer) return;
161
+ for (const request of pending) startSteer(request, ctx, false);
162
+ },
163
+ shutdown() {
164
+ generation++;
165
+ sessionAbort.abort();
166
+ activeSteers.clear();
167
+ },
168
+ get sessionSignal(): AbortSignal {
169
+ return sessionAbort.signal;
170
+ },
171
+ };
172
+ }
@@ -0,0 +1,23 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ export type TryCodexDeveloperMessage = (
4
+ pi: ExtensionAPI,
5
+ content: string,
6
+ options: { deliverAs: "steer"; triggerTurn: true },
7
+ ) => boolean;
8
+
9
+ export function createSteerDelivery(
10
+ pi: ExtensionAPI,
11
+ trySendCodexDeveloperMessage?: TryCodexDeveloperMessage,
12
+ ): (message: string) => void {
13
+ return (message) => {
14
+ if (
15
+ trySendCodexDeveloperMessage?.(pi, message, {
16
+ deliverAs: "steer",
17
+ triggerTurn: true,
18
+ })
19
+ )
20
+ return;
21
+ pi.sendUserMessage(message, { deliverAs: "steer" });
22
+ };
23
+ }
package/ask/normalize.ts CHANGED
@@ -1,10 +1,5 @@
1
1
  import { HANDOFF_CHOICES, OTHER_OPTION_LABEL } from "./constants.js";
2
- import type {
3
- AskInput,
4
- AskPrompt,
5
- AskResponse,
6
- PromptChoice,
7
- } from "./contracts.js";
2
+ import type { AskPrompt, AskResponse, PromptChoice } from "./contracts.js";
8
3
 
9
4
  function isRecord(value: unknown): value is Record<string, unknown> {
10
5
  return typeof value === "object" && value !== null;
@@ -49,17 +44,31 @@ function normalizePrompt(
49
44
  };
50
45
  }
51
46
 
52
- export function normalizeAskInput(params: AskInput | null | undefined): {
47
+ export function isSteeringAskInput(input: unknown): boolean {
48
+ return (
49
+ isRecord(input) &&
50
+ input["delivery"] === "steer" &&
51
+ input["handoff"] !== true
52
+ );
53
+ }
54
+
55
+ export function normalizeAskInput(params: unknown): {
56
+ delivery: "wait" | "steer";
53
57
  handoff: boolean;
54
58
  prompts: AskPrompt[];
55
59
  } {
56
- const handoff = params?.handoff === true;
57
- const prompts = Array.isArray(params?.prompts)
58
- ? params.prompts
60
+ const input = isRecord(params) ? params : {};
61
+ const handoff = input["handoff"] === true;
62
+ const prompts = Array.isArray(input["prompts"])
63
+ ? input["prompts"]
59
64
  .map((prompt, index) => normalizePrompt(prompt, index, handoff))
60
65
  .filter((prompt) => prompt !== null)
61
66
  : [];
62
- return { handoff, prompts };
67
+ return {
68
+ delivery: input["delivery"] === "steer" ? "steer" : "wait",
69
+ handoff,
70
+ prompts,
71
+ };
63
72
  }
64
73
 
65
74
  export function normalizeResponses(
package/ask/pending.ts ADDED
@@ -0,0 +1,39 @@
1
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
2
+ import type { AskPrompt, PendingAsk } from "./contracts.js";
3
+ import { normalizeAskInput } from "./normalize.js";
4
+
5
+ export const PENDING_ASK_ENTRY_TYPE = "pi-ask-pending";
6
+
7
+ export type PendingAskUpdate =
8
+ | { version: 1; state: "pending"; id: string; prompts: AskPrompt[] }
9
+ | { version: 1; state: "closed"; id: string };
10
+
11
+ function isRecord(value: unknown): value is Record<string, unknown> {
12
+ return typeof value === "object" && value !== null;
13
+ }
14
+
15
+ function parsePendingAskUpdate(value: unknown): PendingAskUpdate | undefined {
16
+ if (!isRecord(value) || value["version"] !== 1) return undefined;
17
+ const id = typeof value["id"] === "string" ? value["id"].trim() : "";
18
+ if (!id) return undefined;
19
+ if (value["state"] === "closed") return { version: 1, state: "closed", id };
20
+ if (value["state"] !== "pending") return undefined;
21
+ const { prompts } = normalizeAskInput({ prompts: value["prompts"] });
22
+ if (prompts.length === 0) return undefined;
23
+ return { version: 1, state: "pending", id, prompts };
24
+ }
25
+
26
+ export function readPendingAsks(
27
+ entries: readonly SessionEntry[],
28
+ ): PendingAsk[] {
29
+ const pending = new Map<string, PendingAsk>();
30
+ for (const entry of entries) {
31
+ if (entry.type !== "custom" || entry.customType !== PENDING_ASK_ENTRY_TYPE)
32
+ continue;
33
+ const update = parsePendingAskUpdate(entry.data);
34
+ if (!update) continue;
35
+ if (update.state === "closed") pending.delete(update.id);
36
+ else pending.set(update.id, { id: update.id, prompts: update.prompts });
37
+ }
38
+ return [...pending.values()];
39
+ }
package/ask/pi-ui.ts CHANGED
@@ -22,9 +22,14 @@ async function askOther(
22
22
  return value === undefined ? null : customSelectionFor(value);
23
23
  }
24
24
 
25
- function promptText(prompt: AskPrompt, handoff: boolean): string {
25
+ function promptText(
26
+ prompt: AskPrompt,
27
+ handoff: boolean,
28
+ steering: boolean,
29
+ ): string {
26
30
  const text = prompt.body ? `${prompt.title}\n\n${prompt.body}` : prompt.title;
27
- return handoff ? `Human action needed\n\n${text}` : text;
31
+ if (handoff) return `Human action needed\n\n${text}`;
32
+ return steering ? `Agent continues while you decide\n\n${text}` : text;
28
33
  }
29
34
 
30
35
  async function askComment(
@@ -56,18 +61,19 @@ async function askSingleWithPiUi(
56
61
  ctx: ExtensionContext,
57
62
  prompt: AskPrompt,
58
63
  handoff: boolean,
64
+ steering: boolean,
59
65
  signal?: AbortSignal,
60
66
  ): Promise<string[] | null> {
61
67
  const choices = prompt.choices.map((choice) => choice.label);
62
68
  const picked =
63
69
  choices.length > 0
64
70
  ? await ctx.ui.select(
65
- promptText(prompt, handoff),
71
+ promptText(prompt, handoff, steering),
66
72
  [...choices, OTHER_OPTION_LABEL],
67
73
  dialogOptions(signal),
68
74
  )
69
75
  : await ctx.ui.input(
70
- promptText(prompt, handoff),
76
+ promptText(prompt, handoff, steering),
71
77
  "Response",
72
78
  dialogOptions(signal),
73
79
  );
@@ -82,6 +88,7 @@ async function askMultipleWithPiUi(
82
88
  ctx: ExtensionContext,
83
89
  prompt: AskPrompt,
84
90
  handoff: boolean,
91
+ steering: boolean,
85
92
  signal?: AbortSignal,
86
93
  ): Promise<string[] | null> {
87
94
  const choices = prompt.choices.map((choice) => choice.label);
@@ -89,7 +96,7 @@ async function askMultipleWithPiUi(
89
96
  const picked: string[] = [];
90
97
  while (true) {
91
98
  const next = await ctx.ui.select(
92
- promptText(prompt, handoff),
99
+ promptText(prompt, handoff, steering),
93
100
  [
94
101
  ...choices.filter((choice) => !picked.includes(choice)),
95
102
  OTHER_OPTION_LABEL,
@@ -112,14 +119,18 @@ async function askMultipleWithPiUi(
112
119
  export async function askWithPiUi(
113
120
  ctx: ExtensionContext,
114
121
  prompts: AskPrompt[],
115
- { handoff = false, signal }: { handoff?: boolean; signal?: AbortSignal } = {},
122
+ {
123
+ handoff = false,
124
+ steering = false,
125
+ signal,
126
+ }: { handoff?: boolean; steering?: boolean; signal?: AbortSignal } = {},
116
127
  ): Promise<AskResponse[] | null> {
117
128
  if (!ctx.hasUI || signal?.aborted) return null;
118
129
  const responses: AskResponse[] = [];
119
130
  for (const prompt of prompts) {
120
131
  const selections = prompt.multiple
121
- ? await askMultipleWithPiUi(ctx, prompt, handoff, signal)
122
- : await askSingleWithPiUi(ctx, prompt, handoff, signal);
132
+ ? await askMultipleWithPiUi(ctx, prompt, handoff, steering, signal)
133
+ : await askSingleWithPiUi(ctx, prompt, handoff, steering, signal);
123
134
  if (!selections) return null;
124
135
  const comment = await askComment(ctx, prompt, signal);
125
136
  if (comment === null || signal?.aborted) return null;
package/ask/tool.ts CHANGED
@@ -2,50 +2,58 @@ import { defineTool } from "@earendil-works/pi-coding-agent";
2
2
  import { Text } from "@earendil-works/pi-tui";
3
3
  import { AskParameters, type AskPrompt } from "./contracts.js";
4
4
  import {
5
+ type AskCoordinatorOptions,
6
+ createAskCoordinator,
7
+ } from "./coordinator.js";
8
+ import {
9
+ isSteeringAskInput,
5
10
  normalizeAskInput,
6
11
  normalizeResponses,
7
12
  summarizeResponses,
8
13
  textContent,
9
14
  } from "./normalize.js";
10
- import { askWithPiUi } from "./pi-ui.js";
11
- import { askInTui } from "./tui.js";
12
-
13
- type AskInComposer = (
14
- prompts: AskPrompt[],
15
- signal: AbortSignal | undefined,
16
- ) => Promise<unknown>;
17
15
 
18
16
  interface BlockedState {
19
17
  id: string;
20
18
  active: boolean;
21
19
  label: string;
22
20
  prompt: string;
21
+ handoff: boolean;
22
+ prompts: AskPrompt[];
23
23
  }
24
24
 
25
25
  type OnBlockedChange = (state: BlockedState) => void;
26
26
 
27
- export function createAskTool({
27
+ interface AskToolOptions extends AskCoordinatorOptions {
28
+ onBlockedChange?: OnBlockedChange;
29
+ }
30
+
31
+ export function createAskRuntime({
28
32
  askInComposer,
33
+ deliverSteer,
29
34
  onBlockedChange,
30
- }: {
31
- askInComposer?: AskInComposer;
32
- onBlockedChange?: OnBlockedChange;
33
- } = {}) {
34
- return defineTool({
35
+ onPendingChange,
36
+ }: AskToolOptions = {}) {
37
+ const coordinator = createAskCoordinator({
38
+ ...(askInComposer ? { askInComposer } : {}),
39
+ ...(deliverSteer ? { deliverSteer } : {}),
40
+ ...(onPendingChange ? { onPendingChange } : {}),
41
+ });
42
+
43
+ const tool = defineTool({
35
44
  name: "ask",
36
45
  label: "Ask",
37
- description:
38
- "Request user input or action and return the response. Requires interactive UI.",
46
+ description: "Request user input or action. Requires interactive UI.",
39
47
  parameters: AskParameters,
40
48
  promptSnippet: "Request human input or action.",
41
49
  promptGuidelines: [
42
- "ask: Use for needed user decisions or input; set handoff true for a user-only action and state its completion signal.",
50
+ "ask: Use steer only while useful reversible work can continue; wait for gating answers and handoffs, stating a handoff's completion signal.",
43
51
  "ask: For reviews, make each finding a prompt with disposition choices; do not report first.",
44
52
  "ask: Do not add Other/rephrase; it is automatic.",
45
53
  ],
46
54
  executionMode: "sequential",
47
55
  async execute(toolCallId, params, signal, _onUpdate, ctx) {
48
- const { handoff, prompts } = normalizeAskInput(params);
56
+ const { delivery, handoff, prompts } = normalizeAskInput(params);
49
57
  if (prompts.length === 0) {
50
58
  throw new Error(
51
59
  "ask requires at least one prompt with a non-empty title.",
@@ -53,32 +61,42 @@ export function createAskTool({
53
61
  }
54
62
  if (!askInComposer && !ctx.hasUI)
55
63
  throw new Error("ask requires an interactive UI.");
64
+ if (handoff && delivery === "steer")
65
+ throw new Error("ask handoffs require delivery wait.");
66
+ if (delivery === "steer") {
67
+ if (!coordinator.canSteer)
68
+ throw new Error("ask steering delivery is unavailable.");
69
+ signal?.throwIfAborted();
70
+ coordinator.requestSteer({ id: toolCallId, prompts }, ctx);
71
+ return {
72
+ content: [
73
+ textContent(
74
+ "Question presented. Continue working; the response will arrive as steering.",
75
+ ),
76
+ ],
77
+ details: { kind: "prompt", pending: true, id: toolCallId },
78
+ };
79
+ }
56
80
 
57
81
  const blockedState = {
58
82
  id: toolCallId,
59
83
  active: true,
84
+ handoff,
85
+ prompts,
60
86
  label: handoff ? "Human action needed" : "Waiting for input",
61
87
  prompt: handoff
62
88
  ? "The user needs to complete an action before the active work can continue. Please announce this briefly in your natural voice."
63
89
  : "User input is required before the active work can continue. Please announce this briefly in your natural voice.",
64
90
  };
65
- onBlockedChange?.(blockedState);
66
- let rawResponses: unknown;
67
- try {
68
- rawResponses = askInComposer
69
- ? await askInComposer(prompts, signal)
70
- : ctx.mode === "tui"
71
- ? await askInTui(ctx, prompts, {
72
- handoff,
73
- ...(signal ? { signal } : {}),
74
- })
75
- : await askWithPiUi(ctx, prompts, {
76
- handoff,
77
- ...(signal ? { signal } : {}),
78
- });
79
- } finally {
80
- onBlockedChange?.({ ...blockedState, active: false });
81
- }
91
+ const presentationSignal = signal
92
+ ? AbortSignal.any([signal, coordinator.sessionSignal])
93
+ : coordinator.sessionSignal;
94
+ const rawResponses = await coordinator.present(ctx, prompts, {
95
+ handoff,
96
+ signal: presentationSignal,
97
+ onActiveChange: (active) =>
98
+ onBlockedChange?.({ ...blockedState, active }),
99
+ });
82
100
  const responses = normalizeResponses(prompts, rawResponses);
83
101
  if (!responses) {
84
102
  return {
@@ -97,12 +115,18 @@ export function createAskTool({
97
115
  },
98
116
  renderCall(args, theme, context) {
99
117
  const count = Array.isArray(args.prompts) ? args.prompts.length : 0;
118
+ const label =
119
+ args.handoff === true
120
+ ? "ask handoff "
121
+ : isSteeringAskInput(args)
122
+ ? "ask steer "
123
+ : "ask ";
100
124
  return new Text(
101
125
  theme.fg(
102
126
  context && "isBlocked" in context && context.isBlocked === true
103
127
  ? "warning"
104
128
  : "toolTitle",
105
- theme.bold(args.handoff === true ? "ask handoff " : "ask "),
129
+ theme.bold(label),
106
130
  ) + theme.fg("muted", `${count} prompt${count === 1 ? "" : "s"}`),
107
131
  0,
108
132
  0,
@@ -114,4 +138,14 @@ export function createAskTool({
114
138
  return new Text(theme.fg("success", text), 0, 0);
115
139
  },
116
140
  });
141
+
142
+ return {
143
+ tool,
144
+ restorePending: coordinator.restorePending,
145
+ shutdown: coordinator.shutdown,
146
+ };
147
+ }
148
+
149
+ export function createAskTool(options: AskToolOptions = {}) {
150
+ return createAskRuntime(options).tool;
117
151
  }
package/ask/tui-render.ts CHANGED
@@ -14,6 +14,7 @@ interface RenderTabsOptions {
14
14
  handoff: boolean;
15
15
  prompts: AskPrompt[];
16
16
  responded: (index: number) => boolean;
17
+ steering: boolean;
17
18
  tab: number;
18
19
  theme: AskTheme;
19
20
  }
@@ -80,10 +81,11 @@ export function renderAskTabs({
80
81
  handoff,
81
82
  prompts,
82
83
  responded,
84
+ steering,
83
85
  tab,
84
86
  theme,
85
87
  }: RenderTabsOptions): string {
86
- return ` ${Array.from({ length: prompts.length + 1 }, (_, index) => {
88
+ const tabs = Array.from({ length: prompts.length + 1 }, (_, index) => {
87
89
  const active = index === tab;
88
90
  const label =
89
91
  index === prompts.length
@@ -94,7 +96,8 @@ export function renderAskTabs({
94
96
  return active
95
97
  ? theme.bg("selectedBg", theme.fg("text", ` ${label} `))
96
98
  : theme.fg("muted", ` ${label} `);
97
- }).join(" ")}`;
99
+ }).join(" ");
100
+ return ` ${tabs}${steering ? theme.fg("muted", " Agent continues while you decide") : ""}`;
98
101
  }
99
102
 
100
103
  export function renderAskReview({
package/ask/tui.ts CHANGED
@@ -27,7 +27,11 @@ type AddLine = (line?: string) => void;
27
27
  export async function askInTui(
28
28
  ctx: ExtensionContext,
29
29
  prompts: AskPrompt[],
30
- { handoff = false, signal }: { handoff?: boolean; signal?: AbortSignal } = {},
30
+ {
31
+ handoff = false,
32
+ steering = false,
33
+ signal,
34
+ }: { handoff?: boolean; steering?: boolean; signal?: AbortSignal } = {},
31
35
  ): Promise<AskResponse[] | null> {
32
36
  if (!ctx.hasUI || signal?.aborted) return null;
33
37
  return await ctx.ui.custom<AskResponse[] | null>((tui, theme, kb, done) => {
@@ -170,7 +174,14 @@ export async function askInTui(
170
174
  lines.push(truncateToWidth(line, width));
171
175
  add(theme.fg("accent", "─".repeat(width)));
172
176
  add(
173
- renderAskTabs({ handoff, prompts, responded, tab: state.tab, theme }),
177
+ renderAskTabs({
178
+ handoff,
179
+ prompts,
180
+ responded,
181
+ steering,
182
+ tab: state.tab,
183
+ theme,
184
+ }),
174
185
  );
175
186
  add();
176
187
  if (isReview()) {
package/index.ts CHANGED
@@ -1,8 +1,17 @@
1
1
  import { dirname, join } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import type {
4
+ ExtensionAPI,
5
+ ExtensionContext,
6
+ } from "@earendil-works/pi-coding-agent";
4
7
  import { loadAskConfig } from "./ask/config.js";
5
- import { createAskTool } from "./ask/tool.js";
8
+ import {
9
+ createSteerDelivery,
10
+ type TryCodexDeveloperMessage,
11
+ } from "./ask/delivery.js";
12
+ import { isSteeringAskInput } from "./ask/normalize.js";
13
+ import { PENDING_ASK_ENTRY_TYPE, readPendingAsks } from "./ask/pending.js";
14
+ import { createAskRuntime, createAskTool } from "./ask/tool.js";
6
15
  import registerPackageChangelog from "./changelog.js";
7
16
 
8
17
  export { createAskTool } from "./ask/tool.js";
@@ -12,24 +21,60 @@ const REALTIME_VOICE_PROMPT_CHANNEL =
12
21
  "@howaboua/pi-codex-conversion/realtime-voice-prompt/v1";
13
22
  const CODE_MODE_PACKAGE = "@howaboua/pi-codex-conversion";
14
23
  const CODE_MODE_MODULE = `${CODE_MODE_PACKAGE}/code-mode`;
24
+ const DEVELOPER_MESSAGES_MODULE = `${CODE_MODE_PACKAGE}/developer-messages`;
15
25
 
16
26
  export default async function humanInTheLoop(pi: ExtensionAPI): Promise<void> {
17
27
  registerPackageChangelog(pi);
18
- const ask = createAskTool({
28
+ const trySendCodexDeveloperMessage = await loadCodexDeveloperMessageSender();
29
+ const askRuntime = createAskRuntime({
30
+ deliverSteer: createSteerDelivery(pi, trySendCodexDeveloperMessage),
19
31
  onBlockedChange: (state) => {
20
- pi.events.emit(REALTIME_VOICE_PROMPT_CHANNEL, {
21
- id: state.id,
22
- active: state.active,
23
- prompt: state.prompt,
24
- });
25
- pi.events.emit("herdr:blocked", {
26
- active: state.active,
27
- label: state.label,
28
- });
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();
61
+ },
62
+ onPendingChange: (update) => {
63
+ pi.appendEntry(PENDING_ASK_ENTRY_TYPE, update);
29
64
  },
30
65
  });
66
+ const ask = askRuntime.tool;
67
+ const restorePending = (ctx: ExtensionContext) => {
68
+ askRuntime.restorePending(
69
+ readPendingAsks(ctx.sessionManager.getBranch()),
70
+ ctx,
71
+ );
72
+ };
31
73
  pi.registerTool(ask);
32
74
  await registerAskInCodeMode(pi, ask);
75
+ pi.on("session_start", (_event, ctx) => restorePending(ctx));
76
+ pi.on("session_tree", (_event, ctx) => restorePending(ctx));
77
+ pi.on("session_shutdown", () => askRuntime.shutdown());
33
78
  pi.on("resources_discover", () => {
34
79
  const config = loadAskConfig();
35
80
  return {
@@ -41,6 +86,24 @@ export default async function humanInTheLoop(pi: ExtensionAPI): Promise<void> {
41
86
  });
42
87
  }
43
88
 
89
+ async function loadCodexDeveloperMessageSender(): Promise<
90
+ TryCodexDeveloperMessage | undefined
91
+ > {
92
+ try {
93
+ const { trySendCodexDeveloperMessage } = await import(
94
+ "@howaboua/pi-codex-conversion/developer-messages"
95
+ );
96
+ return trySendCodexDeveloperMessage;
97
+ } catch (error) {
98
+ if (
99
+ isMissingCodexModule(error, DEVELOPER_MESSAGES_MODULE) ||
100
+ isUnavailableCodexSubpath(error, "developer-messages")
101
+ )
102
+ return undefined;
103
+ throw error;
104
+ }
105
+ }
106
+
44
107
  async function registerAskInCodeMode(
45
108
  pi: ExtensionAPI,
46
109
  ask: ReturnType<typeof createAskTool>,
@@ -50,14 +113,14 @@ async function registerAskInCodeMode(
50
113
  await import("@howaboua/pi-codex-conversion/code-mode");
51
114
  const registration = registerCodeModeExtensionTools(pi, () => [
52
115
  adaptToolForCodeMode(ask, {
53
- blocking: true,
116
+ blocking: (input) => !isSteeringAskInput(input),
54
117
  usage:
55
- "await tools.ask({ prompts: [{ title, body?, multiple?, choices?: [{ label, description? }] }], handoff? })",
118
+ 'await tools.ask({ prompts: [{ title, body?, multiple?, choices?: [{ label, description? }] }], delivery?: "wait"|"steer", handoff? })',
56
119
  }),
57
120
  ]);
58
121
  pi.on("session_shutdown", () => registration.unregister());
59
122
  } catch (error) {
60
- if (isMissingCodeModeExtension(error)) return;
123
+ if (isMissingCodexModule(error, CODE_MODE_MODULE)) return;
61
124
  if (isOutdatedCodeModeExtension(error)) {
62
125
  throw new Error(
63
126
  "Update " +
@@ -70,7 +133,7 @@ async function registerAskInCodeMode(
70
133
  }
71
134
  }
72
135
 
73
- function isMissingCodeModeExtension(error: unknown): boolean {
136
+ function isMissingCodexModule(error: unknown, moduleName: string): boolean {
74
137
  if (
75
138
  !error ||
76
139
  typeof error !== "object" ||
@@ -84,7 +147,29 @@ function isMissingCodeModeExtension(error: unknown): boolean {
84
147
  const missing = error.message.match(
85
148
  /Cannot find (?:package|module) ['"]([^'"]+)['"]/,
86
149
  )?.[1];
87
- return missing === CODE_MODE_PACKAGE || missing === CODE_MODE_MODULE;
150
+ const normalizedMissing = missing?.replaceAll("\\", "/").replace(/\.js$/, "");
151
+ return (
152
+ missing === CODE_MODE_PACKAGE ||
153
+ normalizedMissing === moduleName ||
154
+ normalizedMissing?.endsWith(`/${moduleName}`) === true
155
+ );
156
+ }
157
+
158
+ function isUnavailableCodexSubpath(error: unknown, subpath: string): boolean {
159
+ if (
160
+ !error ||
161
+ typeof error !== "object" ||
162
+ !("code" in error) ||
163
+ !("message" in error) ||
164
+ typeof error.message !== "string"
165
+ )
166
+ return false;
167
+ return (
168
+ (error.code === "ERR_PACKAGE_PATH_NOT_EXPORTED" ||
169
+ error.code === "ERR_UNSUPPORTED_DIR_IMPORT") &&
170
+ error.message.includes(`Package subpath './${subpath}'`) &&
171
+ error.message.includes(CODE_MODE_PACKAGE)
172
+ );
88
173
  }
89
174
 
90
175
  function isOutdatedCodeModeExtension(error: unknown): boolean {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@howaboua/pi-ask",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "description": "Interactive decisions, review triage, and human handoffs for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -45,7 +45,8 @@
45
45
  ]
46
46
  },
47
47
  "peerDependencies": {
48
- "@howaboua/pi-codex-conversion": ">=3.0.24",
48
+ "@howaboua/pi-codex-conversion": ">=3.0.30",
49
+ "@earendil-works/pi-ai": ">=0.84.3",
49
50
  "@earendil-works/pi-coding-agent": ">=0.84.3",
50
51
  "@earendil-works/pi-tui": ">=0.84.3",
51
52
  "typebox": "*"
@@ -56,10 +57,11 @@
56
57
  }
57
58
  },
58
59
  "devDependencies": {
59
- "@howaboua/pi-codex-conversion": "3.0.24",
60
+ "@howaboua/pi-codex-conversion": "3.0.30",
60
61
  "@biomejs/biome": "^2.5.10",
61
- "@earendil-works/pi-coding-agent": "0.84.4",
62
- "@earendil-works/pi-tui": "0.84.4",
62
+ "@earendil-works/pi-ai": "0.85.1",
63
+ "@earendil-works/pi-coding-agent": "0.85.1",
64
+ "@earendil-works/pi-tui": "0.85.1",
63
65
  "typebox": "1.3.18",
64
66
  "typescript": "^7.0.2"
65
67
  },