@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 +49 -0
- package/README.md +6 -2
- package/ask/contracts.ts +15 -4
- package/ask/coordinator.ts +161 -0
- package/ask/delivery.ts +23 -0
- package/ask/normalize.ts +22 -13
- package/ask/pending.ts +39 -0
- package/ask/pi-ui.ts +19 -8
- package/ask/tool.ts +68 -32
- package/ask/tui-input.ts +145 -0
- package/ask/tui-render.ts +276 -0
- package/ask/tui.ts +28 -419
- package/biome.json +1 -1
- package/changelog.ts +337 -0
- package/index.ts +151 -18
- package/package.json +20 -9
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,
|
|
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`
|
|
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
|
-
|
|
4
|
+
const ASK_DELIVERIES = ["wait", "steer"] as const;
|
|
4
5
|
|
|
5
|
-
|
|
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
|
-
|
|
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
|
+
}
|
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;
|
|
@@ -14,7 +9,7 @@ export function textContent(text: string): { type: "text"; text: string } {
|
|
|
14
9
|
return { type: "text", text };
|
|
15
10
|
}
|
|
16
11
|
|
|
17
|
-
|
|
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
|
-
|
|
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
|
|
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
|
@@ -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
|
|
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
|
-
|
|
25
|
+
interface AskToolOptions extends AskCoordinatorOptions {
|
|
26
|
+
onBlockedChange?: OnBlockedChange;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createAskRuntime({
|
|
28
30
|
askInComposer,
|
|
31
|
+
deliverSteer,
|
|
29
32
|
onBlockedChange,
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
|
|
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
|
|
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
|
-
|
|
69
|
-
?
|
|
70
|
-
:
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
"
|
|
103
|
-
|
|
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
|
}
|