@howaboua/pi-ask 0.0.5 → 0.0.7

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 ADDED
@@ -0,0 +1,49 @@
1
+ # @howaboua/pi-ask
2
+
3
+ ## 0.0.7
4
+
5
+ - 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.
6
+
7
+ ## 0.0.6
8
+
9
+ - Added a Code and Notebook Mode bridge API. This allows extensions that use Pi TUI to run inside `exec`.
10
+
11
+ ## 0.0.5
12
+
13
+ ### Changes
14
+
15
+ - [#269](https://github.com/IgorWarzocha/howaboua-pi-stuff/pull/269) [`6138ffd`](https://github.com/IgorWarzocha/howaboua-pi-stuff/commit/6138ffd735bb4f7f80e451320dbfd0933a4acaa7) Thanks [@howaclawa](https://github.com/howaclawa)!:
16
+ - Add shared realtime voice prompts for ask prompts, Auto Trees, Shepherdr settlements, and review progress.
17
+ - Announce compaction and stream conversational Pi updates after two sentences.
18
+ - Keep silent tool-step summaries compatible without exposing Chat Completions thinking content.
19
+ - Configure delegation acknowledgements and deliver V3 delegations immediately.
20
+ - Preserve late delegations, calls after data-channel closure, prepared Code Mode prompts, and Codex cache continuity.
21
+ - Reduce LAN playback dropouts with one more jitter-buffer frame.
22
+
23
+ ## 0.0.4
24
+
25
+ ### Changes
26
+
27
+ - [#131](https://github.com/IgorWarzocha/howaboua-pi-stuff/pull/131) [`828f52e`](https://github.com/IgorWarzocha/howaboua-pi-stuff/commit/828f52eff1291629d73c3058173cff2fa60e3b28) Thanks [@IgorWarzocha](https://github.com/IgorWarzocha)!:
28
+ - Uses configured Pi keybindings for ask navigation and theme-native TUI colors.
29
+
30
+ ## 0.0.3
31
+
32
+ ### Changes
33
+
34
+ - [#128](https://github.com/IgorWarzocha/howaboua-pi-stuff/pull/128) [`9604ec3`](https://github.com/IgorWarzocha/howaboua-pi-stuff/commit/9604ec3505eff2d9ee789f42ef45038bc00da02e) Thanks [@IgorWarzocha](https://github.com/IgorWarzocha)!:
35
+ - Remove the duplicated ask tool name from the model-facing prompt inventory.
36
+
37
+ ## 0.0.2
38
+
39
+ ### Changes
40
+
41
+ - [#120](https://github.com/IgorWarzocha/howaboua-pi-stuff/pull/120) [`8f82078`](https://github.com/IgorWarzocha/howaboua-pi-stuff/commit/8f82078a8e733dbe770c7b5fd9ad1b20cd5a21af) Thanks [@IgorWarzocha](https://github.com/IgorWarzocha)!:
42
+ - Report open ask panels as blocked to Herdr's Pi integration.
43
+
44
+ ## 0.0.1
45
+
46
+ ### Changes
47
+
48
+ - [#113](https://github.com/IgorWarzocha/howaboua-pi-stuff/pull/113) [`f516f97`](https://github.com/IgorWarzocha/howaboua-pi-stuff/commit/f516f97c76ae27a354f80f85a5a34ecd56c4e9c4) Thanks [@IgorWarzocha](https://github.com/IgorWarzocha)!:
49
+ - Add interactive human input, review triage, and handoff prompts through the `ask` tool, plus configurable `/fold` and `/grill` workflows.
package/README.md CHANGED
@@ -14,13 +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.25 or newer is installed too, `ask` is available inside Code and Notebook Mode as `await tools.ask({ prompts, delivery?, handoff? })`.
18
+
17
19
  ## How it behaves
18
20
 
19
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.
20
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
+
21
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.
22
26
 
23
- 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.
24
28
 
25
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.
26
30
 
@@ -29,7 +33,7 @@ When Pi runs inside Herdr with its Pi integration installed, an open ask panel m
29
33
  - `/fold [report]` turns a long report or structured list into one interactive disposition prompt per item.
30
34
  - `/grill [idea]` investigates an idea, asks successive decisions, and keeps the agreed plan in `docs/`.
31
35
 
32
- Both templates are enabled by default. The extension creates `~/.pi/agent/ask.json` on first load:
36
+ Both templates are enabled by default. The extension creates `ask.json` under Pi's agent directory on first load. That is `~/.pi/agent/ask.json` by default. `PI_CODING_AGENT_DIR` changes the agent directory.
33
37
 
34
38
  ```json
35
39
  {
package/ask/contracts.ts CHANGED
@@ -1,13 +1,14 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
1
2
  import { type Static, Type } from "typebox";
2
3
 
3
- export { OTHER_OPTION_LABEL, REPHRASE_REQUEST_RESPONSE } from "./constants.js";
4
+ const ASK_DELIVERIES = ["wait", "steer"] as const;
4
5
 
5
- export const ChoiceSchema = Type.Object({
6
+ const ChoiceSchema = Type.Object({
6
7
  label: Type.String({ description: "Short choice." }),
7
8
  description: Type.Optional(Type.String({ description: "Optional detail." })),
8
9
  });
9
10
 
10
- export const PromptSchema = Type.Object({
11
+ const PromptSchema = Type.Object({
11
12
  title: Type.String({ description: "Short prompt." }),
12
13
  body: Type.Optional(Type.String({ description: "Context or evidence." })),
13
14
  multiple: Type.Optional(Type.Boolean({ description: "Allow multiple." })),
@@ -21,9 +22,14 @@ export const AskParameters = Type.Object({
21
22
  Type.Boolean({ description: "Wait for user action." }),
22
23
  ),
23
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
+ ),
24
31
  });
25
32
 
26
- export type AskInput = Static<typeof AskParameters>;
27
33
  export type PromptChoice = Static<typeof ChoiceSchema>;
28
34
 
29
35
  export interface AskPrompt {
@@ -34,6 +40,11 @@ export interface AskPrompt {
34
40
  choices: PromptChoice[];
35
41
  }
36
42
 
43
+ export interface PendingAsk {
44
+ id: string;
45
+ prompts: AskPrompt[];
46
+ }
47
+
37
48
  export interface AskResponse {
38
49
  id: string;
39
50
  selections: string[];
@@ -0,0 +1,161 @@
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
+ handoff?: boolean;
21
+ steering?: boolean;
22
+ signal?: AbortSignal;
23
+ }
24
+
25
+ function steerResponse(prompts: AskPrompt[], responses: unknown): string {
26
+ const normalized = normalizeResponses(prompts, responses);
27
+ return normalized
28
+ ? `Response to your earlier ask:\n${summarizeResponses(prompts, normalized)}`
29
+ : `I dismissed your earlier ask: ${prompts.map((prompt) => prompt.title).join("; ")}`;
30
+ }
31
+
32
+ export function createAskCoordinator({
33
+ askInComposer,
34
+ deliverSteer,
35
+ onPendingChange,
36
+ }: AskCoordinatorOptions = {}) {
37
+ let generation = 0;
38
+ let sessionAbort = new AbortController();
39
+ let presentationTail = Promise.resolve();
40
+ const activeSteers = new Set<string>();
41
+
42
+ const present = (
43
+ ctx: ExtensionContext,
44
+ prompts: AskPrompt[],
45
+ { handoff = false, steering = false, signal }: PresentAskOptions = {},
46
+ ): Promise<unknown> => {
47
+ const run = async () => {
48
+ if (signal?.aborted) return null;
49
+ return askInComposer
50
+ ? await askInComposer(prompts, signal)
51
+ : ctx.mode === "tui"
52
+ ? await askInTui(ctx, prompts, {
53
+ handoff,
54
+ steering,
55
+ ...(signal ? { signal } : {}),
56
+ })
57
+ : await askWithPiUi(ctx, prompts, {
58
+ handoff,
59
+ steering,
60
+ ...(signal ? { signal } : {}),
61
+ });
62
+ };
63
+ const result = presentationTail.then(run, run);
64
+ presentationTail = result.then(
65
+ () => undefined,
66
+ () => undefined,
67
+ );
68
+ return result;
69
+ };
70
+
71
+ const notifyFailure = (ctx: ExtensionContext, error: unknown) => {
72
+ const message = error instanceof Error ? error.message : String(error);
73
+ ctx.ui.notify(`Async ask failed: ${message}`, "error");
74
+ return message;
75
+ };
76
+
77
+ const deliver = (ctx: ExtensionContext, message: string): boolean => {
78
+ try {
79
+ deliverSteer?.(message);
80
+ return true;
81
+ } catch (error) {
82
+ notifyFailure(ctx, error);
83
+ return false;
84
+ }
85
+ };
86
+
87
+ const close = (ctx: ExtensionContext, id: string) => {
88
+ activeSteers.delete(id);
89
+ try {
90
+ onPendingChange?.({ version: 1, state: "closed", id });
91
+ } catch (error) {
92
+ notifyFailure(ctx, error);
93
+ }
94
+ };
95
+
96
+ const startSteer = (
97
+ request: PendingAsk,
98
+ ctx: ExtensionContext,
99
+ persist: boolean,
100
+ ) => {
101
+ if (activeSteers.has(request.id) || sessionAbort.signal.aborted) return;
102
+ if (persist) {
103
+ onPendingChange?.({
104
+ version: 1,
105
+ state: "pending",
106
+ id: request.id,
107
+ prompts: request.prompts,
108
+ });
109
+ }
110
+ activeSteers.add(request.id);
111
+ const requestGeneration = generation;
112
+ void present(ctx, request.prompts, {
113
+ steering: true,
114
+ signal: sessionAbort.signal,
115
+ }).then(
116
+ (responses) => {
117
+ if (requestGeneration !== generation || sessionAbort.signal.aborted)
118
+ return;
119
+ if (deliver(ctx, steerResponse(request.prompts, responses)))
120
+ close(ctx, request.id);
121
+ },
122
+ (error: unknown) => {
123
+ if (requestGeneration !== generation || sessionAbort.signal.aborted)
124
+ return;
125
+ const message = notifyFailure(ctx, error);
126
+ if (
127
+ deliver(
128
+ ctx,
129
+ `The asynchronous ask failed before I could answer: ${message}`,
130
+ )
131
+ )
132
+ close(ctx, request.id);
133
+ },
134
+ );
135
+ };
136
+
137
+ return {
138
+ canSteer: deliverSteer !== undefined,
139
+ present,
140
+ requestSteer(request: PendingAsk, ctx: ExtensionContext) {
141
+ startSteer(request, ctx, true);
142
+ },
143
+ restorePending(pending: readonly PendingAsk[], ctx: ExtensionContext) {
144
+ generation++;
145
+ sessionAbort.abort();
146
+ sessionAbort = new AbortController();
147
+ presentationTail = Promise.resolve();
148
+ activeSteers.clear();
149
+ if ((!askInComposer && !ctx.hasUI) || !deliverSteer) return;
150
+ for (const request of pending) startSteer(request, ctx, false);
151
+ },
152
+ shutdown() {
153
+ generation++;
154
+ sessionAbort.abort();
155
+ activeSteers.clear();
156
+ },
157
+ get sessionSignal(): AbortSignal {
158
+ return sessionAbort.signal;
159
+ },
160
+ };
161
+ }
@@ -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;
@@ -14,7 +9,7 @@ export function textContent(text: string): { type: "text"; text: string } {
14
9
  return { type: "text", text };
15
10
  }
16
11
 
17
- export function normalizePromptChoice(choice: unknown): PromptChoice | null {
12
+ function normalizePromptChoice(choice: unknown): PromptChoice | null {
18
13
  if (!isRecord(choice)) return null;
19
14
  const label =
20
15
  typeof choice["label"] === "string" ? choice["label"].trim() : "";
@@ -26,7 +21,7 @@ export function normalizePromptChoice(choice: unknown): PromptChoice | null {
26
21
  return { label, ...(description ? { description } : {}) };
27
22
  }
28
23
 
29
- export function normalizePrompt(
24
+ function normalizePrompt(
30
25
  input: unknown,
31
26
  index: number,
32
27
  handoff = false,
@@ -49,17 +44,31 @@ export 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
@@ -1,19 +1,17 @@
1
1
  import { defineTool } from "@earendil-works/pi-coding-agent";
2
2
  import { Text } from "@earendil-works/pi-tui";
3
- import { AskParameters, type AskPrompt } from "./contracts.js";
3
+ import { AskParameters } 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;
@@ -24,28 +22,36 @@ interface BlockedState {
24
22
 
25
23
  type OnBlockedChange = (state: BlockedState) => void;
26
24
 
27
- export function createAskTool({
25
+ interface AskToolOptions extends AskCoordinatorOptions {
26
+ onBlockedChange?: OnBlockedChange;
27
+ }
28
+
29
+ export function createAskRuntime({
28
30
  askInComposer,
31
+ deliverSteer,
29
32
  onBlockedChange,
30
- }: {
31
- askInComposer?: AskInComposer;
32
- onBlockedChange?: OnBlockedChange;
33
- } = {}) {
34
- return defineTool({
33
+ onPendingChange,
34
+ }: AskToolOptions = {}) {
35
+ const coordinator = createAskCoordinator({
36
+ ...(askInComposer ? { askInComposer } : {}),
37
+ ...(deliverSteer ? { deliverSteer } : {}),
38
+ ...(onPendingChange ? { onPendingChange } : {}),
39
+ });
40
+
41
+ const tool = defineTool({
35
42
  name: "ask",
36
43
  label: "Ask",
37
- description:
38
- "Request user input or action and return the response. Requires interactive UI.",
44
+ description: "Request user input or action. Requires interactive UI.",
39
45
  parameters: AskParameters,
40
46
  promptSnippet: "Request human input or action.",
41
47
  promptGuidelines: [
42
- "ask: Use for needed user decisions or input; set handoff true for a user-only action and state its completion signal.",
48
+ "ask: Use steer only while useful reversible work can continue; wait for gating answers and handoffs, stating a handoff's completion signal.",
43
49
  "ask: For reviews, make each finding a prompt with disposition choices; do not report first.",
44
50
  "ask: Do not add Other/rephrase; it is automatic.",
45
51
  ],
46
52
  executionMode: "sequential",
47
53
  async execute(toolCallId, params, signal, _onUpdate, ctx) {
48
- const { handoff, prompts } = normalizeAskInput(params);
54
+ const { delivery, handoff, prompts } = normalizeAskInput(params);
49
55
  if (prompts.length === 0) {
50
56
  throw new Error(
51
57
  "ask requires at least one prompt with a non-empty title.",
@@ -53,6 +59,22 @@ export function createAskTool({
53
59
  }
54
60
  if (!askInComposer && !ctx.hasUI)
55
61
  throw new Error("ask requires an interactive UI.");
62
+ if (handoff && delivery === "steer")
63
+ throw new Error("ask handoffs require delivery wait.");
64
+ if (delivery === "steer") {
65
+ if (!coordinator.canSteer)
66
+ throw new Error("ask steering delivery is unavailable.");
67
+ signal?.throwIfAborted();
68
+ coordinator.requestSteer({ id: toolCallId, prompts }, ctx);
69
+ return {
70
+ content: [
71
+ textContent(
72
+ "Question presented. Continue working; the response will arrive as steering.",
73
+ ),
74
+ ],
75
+ details: { kind: "prompt", pending: true, id: toolCallId },
76
+ };
77
+ }
56
78
 
57
79
  const blockedState = {
58
80
  id: toolCallId,
@@ -65,17 +87,13 @@ export function createAskTool({
65
87
  onBlockedChange?.(blockedState);
66
88
  let rawResponses: unknown;
67
89
  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
- });
90
+ const presentationSignal = signal
91
+ ? AbortSignal.any([signal, coordinator.sessionSignal])
92
+ : coordinator.sessionSignal;
93
+ rawResponses = await coordinator.present(ctx, prompts, {
94
+ handoff,
95
+ signal: presentationSignal,
96
+ });
79
97
  } finally {
80
98
  onBlockedChange?.({ ...blockedState, active: false });
81
99
  }
@@ -95,12 +113,20 @@ export function createAskTool({
95
113
  details: { kind: handoff ? "handoff" : "prompt", responses },
96
114
  };
97
115
  },
98
- renderCall(args, theme) {
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
- "toolTitle",
103
- theme.bold(args.handoff === true ? "ask handoff " : "ask "),
126
+ context && "isBlocked" in context && context.isBlocked === true
127
+ ? "warning"
128
+ : "toolTitle",
129
+ theme.bold(label),
104
130
  ) + theme.fg("muted", `${count} prompt${count === 1 ? "" : "s"}`),
105
131
  0,
106
132
  0,
@@ -112,4 +138,14 @@ export function createAskTool({
112
138
  return new Text(theme.fg("success", text), 0, 0);
113
139
  },
114
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;
115
151
  }