@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 +8 -0
- package/README.md +4 -2
- package/ask/contracts.ts +14 -1
- package/ask/coordinator.ts +172 -0
- package/ask/delivery.ts +23 -0
- package/ask/normalize.ts +20 -11
- package/ask/pending.ts +39 -0
- package/ask/pi-ui.ts +19 -8
- package/ask/tool.ts +69 -35
- package/ask/tui-render.ts +5 -2
- package/ask/tui.ts +13 -2
- package/index.ts +102 -17
- package/package.json +7 -5
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.
|
|
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,
|
|
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
|
+
}
|
package/ask/delivery.ts
ADDED
|
@@ -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
|
|
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
|
|
57
|
-
const
|
|
58
|
-
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
27
|
+
interface AskToolOptions extends AskCoordinatorOptions {
|
|
28
|
+
onBlockedChange?: OnBlockedChange;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createAskRuntime({
|
|
28
32
|
askInComposer,
|
|
33
|
+
deliverSteer,
|
|
29
34
|
onBlockedChange,
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
|
|
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
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
{
|
|
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({
|
|
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 {
|
|
3
|
+
import type {
|
|
4
|
+
ExtensionAPI,
|
|
5
|
+
ExtensionContext,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
4
7
|
import { loadAskConfig } from "./ask/config.js";
|
|
5
|
-
import {
|
|
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
|
|
28
|
+
const trySendCodexDeveloperMessage = await loadCodexDeveloperMessageSender();
|
|
29
|
+
const askRuntime = createAskRuntime({
|
|
30
|
+
deliverSteer: createSteerDelivery(pi, trySendCodexDeveloperMessage),
|
|
19
31
|
onBlockedChange: (state) => {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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:
|
|
116
|
+
blocking: (input) => !isSteeringAskInput(input),
|
|
54
117
|
usage:
|
|
55
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
60
|
+
"@howaboua/pi-codex-conversion": "3.0.30",
|
|
60
61
|
"@biomejs/biome": "^2.5.10",
|
|
61
|
-
"@earendil-works/pi-
|
|
62
|
-
"@earendil-works/pi-
|
|
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
|
},
|