@stibbons/pi-opensuperwhisper 0.1.0 → 0.2.0
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/README.md +7 -1
- package/package.json +2 -2
- package/src/hook.ts +12 -5
- package/src/index.ts +118 -13
- package/src/question.ts +194 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Answer [pi](https://github.com/badlogic/pi-mono) and [oh-my-pi](https://github.com/can1357/oh-my-pi) agents from the [OpenSuperWhisper](https://github.com/my-monkeys/OpenSuperWhisper) panel on macOS.
|
|
4
4
|
|
|
5
|
-
When a run is finished and waiting for you, OpenSuperWhisper's Agents panel opens with the agent's last message. Dictate (or type) your reply there. It shows up in the session as a normal user message and starts the next run. You don't have to go back to the terminal.
|
|
5
|
+
When a run is finished and waiting for you, OpenSuperWhisper's Agents panel opens with the agent's last message. Dictate (or type) your reply there. It shows up in the session as a normal user message and starts the next run. You don't have to go back to the terminal. On oh-my-pi, questions the agent asks with its `ask` tool open in the panel too.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -32,6 +32,8 @@ There's nothing to configure. Turn the panel on or off in OpenSuperWhisper's own
|
|
|
32
32
|
- **Your reply** is sent as the next user message, without the panel's "The user answered by voice…" prefix.
|
|
33
33
|
- **"Let it stop", or the panel timing out,** leaves the agent idle with no side effects.
|
|
34
34
|
- **The terminal always wins.** Typing in the terminal, starting a new run, switching or resuming sessions, or quitting cancels the panel request. Exactly one run happens, even if a panel reply arrives at the same moment.
|
|
35
|
+
- **oh-my-pi `ask` questions** open in the panel and in the terminal dialog at the same time. Whichever you answer first wins: a panel answer closes the terminal dialog and reaches the agent exactly as if you had picked it there (single choice, multi-select or free text); answering in the terminal closes the panel. Dismissing the panel, or letting it time out, leaves the terminal dialog open. Questions from subagents, and calls with two identical question texts, stay terminal-only.
|
|
36
|
+
- **pi has no `ask` tool.** For structured questions on pi, install a question plugin such as [`@mazli/pi-ask-user-question`](https://www.npmjs.com/package/@mazli/pi-ask-user-question) (`pi install npm:@mazli/pi-ask-user-question`). Its questions are answered in the terminal and don't open in the panel: pi gives extensions no `ctx.invokeTool`, so this extension can't run another extension's question tool and the panel side by side.
|
|
35
37
|
- **Fails open.** If OpenSuperWhisper isn't running, its files are missing or stale, or anything goes wrong, the extension does nothing and logs nothing. It never blocks the agent or the terminal.
|
|
36
38
|
- **No network.** The extension only talks to the local OpenSuperWhisper app.
|
|
37
39
|
|
|
@@ -39,6 +41,10 @@ There's nothing to configure. Turn the panel on or off in OpenSuperWhisper's own
|
|
|
39
41
|
|
|
40
42
|
The extension uses OpenSuperWhisper's own Claude Code integration path. It finds the running app through `~/Library/Application Support/fr.my-monkey.opensuperwhisper/agents/` (`app.pid`, `app.executable`), then runs `<app> agent-hook stop` with a Claude Code–style Stop request on stdin and `CLAUDE_PROJECT_DIR` set. If you answer, the app prints the reply on stdout. Cancelling a request kills that process, and the app closes the panel within about a second.
|
|
41
43
|
|
|
44
|
+
For oh-my-pi's `ask` tool, the extension registers its own `ask` (same description and parameters) when a session starts. Each call runs the native `ask` through `ctx.invokeTool` and, at the same time, `<app> agent-hook question` with the questions as a Claude Code `AskUserQuestion` request. The first answer wins and the other side is cancelled.
|
|
45
|
+
|
|
46
|
+
**Known cost:** because the extension replaces the built-in `ask`, oh-my-pi shows `ask` calls in the transcript like any other extension tool instead of with its built-in renderer. The interactive question dialog is unchanged.
|
|
47
|
+
|
|
42
48
|
## Development
|
|
43
49
|
|
|
44
50
|
The package ships TypeScript source; there's no build step. pi loads it through jiti, oh-my-pi through Bun.
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stibbons/pi-opensuperwhisper",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Answer pi and oh-my-pi agents from the OpenSuperWhisper panel on macOS",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Daniel Holzmann <daniel@danielholzmann.com>",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "git+https://github.com/
|
|
9
|
+
"url": "git+https://github.com/stibbons-ai/pi-opensuperwhisper.git"
|
|
10
10
|
},
|
|
11
11
|
"publishConfig": {
|
|
12
12
|
"access": "public"
|
package/src/hook.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import type { QuestionRequest } from "./question.ts";
|
|
3
|
+
|
|
4
|
+
/** The `agent-hook` subcommands this extension runs, each with the request it reads on stdin. */
|
|
5
|
+
interface HookRequests {
|
|
6
|
+
stop: StopRequest;
|
|
7
|
+
question: QuestionRequest;
|
|
8
|
+
}
|
|
2
9
|
|
|
3
10
|
/** The Claude Code–style Stop payload `agent-hook stop` reads from stdin. */
|
|
4
11
|
export interface StopRequest {
|
|
@@ -9,7 +16,7 @@ export interface StopRequest {
|
|
|
9
16
|
stop_hook_active: false;
|
|
10
17
|
}
|
|
11
18
|
|
|
12
|
-
export interface
|
|
19
|
+
export interface HookProcess {
|
|
13
20
|
/** Resolves with the hook's stdout when it exits 0, or nothing on any failure. */
|
|
14
21
|
readonly stdout: Promise<string | undefined>;
|
|
15
22
|
/** Stops the hook (SIGTERM). Safe to call after it has exited. */
|
|
@@ -17,12 +24,12 @@ export interface StopHook {
|
|
|
17
24
|
}
|
|
18
25
|
|
|
19
26
|
/**
|
|
20
|
-
* Spawns `<executable> agent-hook
|
|
21
|
-
* and collects its stdout. `CLAUDE_PROJECT_DIR` makes the app accept the request as Claude Code.
|
|
27
|
+
* Spawns `<executable> agent-hook <event>` without blocking, writes the request as JSON to its
|
|
28
|
+
* stdin and collects its stdout. `CLAUDE_PROJECT_DIR` makes the app accept the request as Claude Code.
|
|
22
29
|
*/
|
|
23
|
-
export function
|
|
30
|
+
export function runHook<E extends keyof HookRequests>(executable: string, event: E, request: HookRequests[E]): HookProcess {
|
|
24
31
|
const { promise, resolve } = Promise.withResolvers<string | undefined>();
|
|
25
|
-
const child = spawn(executable, ["agent-hook",
|
|
32
|
+
const child = spawn(executable, ["agent-hook", event], {
|
|
26
33
|
env: { ...process.env, CLAUDE_PROJECT_DIR: request.cwd },
|
|
27
34
|
stdio: ["pipe", "pipe", "ignore"],
|
|
28
35
|
});
|
package/src/index.ts
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
AgentEndEvent,
|
|
5
|
+
AgentToolResult,
|
|
6
|
+
ExtensionAPI,
|
|
7
|
+
ExtensionContext,
|
|
8
|
+
ToolDefinition,
|
|
9
|
+
ToolInfo,
|
|
10
|
+
} from "@earendil-works/pi-coding-agent";
|
|
4
11
|
import { locateApp } from "./app.ts";
|
|
5
|
-
import {
|
|
12
|
+
import { type HookProcess, runHook, type StopRequest } from "./hook.ts";
|
|
6
13
|
import { type AssistantMessageLike, extractText, parseReply } from "./message.ts";
|
|
14
|
+
import { type AskInput, parseQuestionAnswers, sanitizeAskInput, toQuestionRequest } from "./question.ts";
|
|
7
15
|
|
|
8
16
|
export interface ExtensionOptions {
|
|
9
17
|
/** OpenSuperWhisper's agent registry directory (holds `app.pid` and `app.executable`). */
|
|
10
18
|
agentsDir: string;
|
|
11
19
|
platform: NodeJS.Platform;
|
|
12
|
-
/** Safety net: kill a hook that outlives the app's own
|
|
20
|
+
/** Safety net: kill a hook that outlives the app's own wait (30–590 s). */
|
|
13
21
|
hardKillMs: number;
|
|
14
22
|
}
|
|
15
23
|
|
|
@@ -21,11 +29,26 @@ const DEFAULT_OPTIONS: ExtensionOptions = {
|
|
|
21
29
|
|
|
22
30
|
export function createExtension(overrides: Partial<ExtensionOptions> = {}): (pi: ExtensionAPI) => void {
|
|
23
31
|
const options = { ...DEFAULT_OPTIONS, ...overrides };
|
|
32
|
+
|
|
33
|
+
/** The panel is a macOS app, and only an interactive terminal session has a user to ask. */
|
|
34
|
+
function canShowPanel(ctx: ExtensionContext): boolean {
|
|
35
|
+
return options.platform === "darwin" && ctx.mode === "tui";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Calls `kill` once the hook has outlived `hardKillMs`. Returns the function that disarms it. */
|
|
39
|
+
function armHardKill(kill: () => void): () => void {
|
|
40
|
+
const timer = setTimeout(kill, options.hardKillMs);
|
|
41
|
+
timer.unref();
|
|
42
|
+
return () => clearTimeout(timer);
|
|
43
|
+
}
|
|
44
|
+
|
|
24
45
|
return (pi) => {
|
|
25
46
|
// At most one pending panel request per session. Bumping the generation orphans any reply
|
|
26
47
|
// still in flight, so typed input racing a panel reply produces exactly one run.
|
|
27
|
-
let pending:
|
|
48
|
+
let pending: HookProcess | undefined;
|
|
28
49
|
let generation = 0;
|
|
50
|
+
/** Panel requests of `ask` calls still in flight. */
|
|
51
|
+
const askHooks = new Set<HookProcess>();
|
|
29
52
|
|
|
30
53
|
function cancelPending(): void {
|
|
31
54
|
generation++;
|
|
@@ -36,27 +59,26 @@ export function createExtension(overrides: Partial<ExtensionOptions> = {}): (pi:
|
|
|
36
59
|
/** Opens the panel for a settled run, unless a guard says the user shouldn't be asked. */
|
|
37
60
|
function requestReply(ctx: ExtensionContext, message: AssistantMessageLike | undefined, sessionId: string): void {
|
|
38
61
|
cancelPending();
|
|
39
|
-
if (
|
|
62
|
+
if (!canShowPanel(ctx)) return;
|
|
40
63
|
if (!message || message.stopReason === "aborted" || message.stopReason === "error") return;
|
|
41
64
|
const text = extractText(message);
|
|
42
65
|
if (!text) return;
|
|
43
66
|
const executable = locateApp(options.agentsDir);
|
|
44
67
|
if (!executable) return;
|
|
45
68
|
const request = generation;
|
|
46
|
-
|
|
69
|
+
const stopRequest: StopRequest = {
|
|
47
70
|
hook_event_name: "Stop",
|
|
48
71
|
session_id: sessionId,
|
|
49
72
|
cwd: ctx.cwd,
|
|
50
73
|
last_assistant_message: text,
|
|
51
74
|
stop_hook_active: false,
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const
|
|
75
|
+
};
|
|
76
|
+
pending = runHook(executable, "stop", stopRequest);
|
|
77
|
+
const disarmHardKill = armHardKill(() => {
|
|
55
78
|
if (request === generation) cancelPending();
|
|
56
|
-
}
|
|
57
|
-
hardKill.unref();
|
|
79
|
+
});
|
|
58
80
|
void pending.stdout.then((stdout) => {
|
|
59
|
-
|
|
81
|
+
disarmHardKill();
|
|
60
82
|
if (request !== generation) return;
|
|
61
83
|
pending = undefined;
|
|
62
84
|
const reply = stdout && parseReply(stdout);
|
|
@@ -75,7 +97,10 @@ export function createExtension(overrides: Partial<ExtensionOptions> = {}): (pi:
|
|
|
75
97
|
if (event.source !== "extension") cancelPending();
|
|
76
98
|
});
|
|
77
99
|
pi.on("agent_start", cancelPending);
|
|
78
|
-
pi.on("session_shutdown",
|
|
100
|
+
pi.on("session_shutdown", () => {
|
|
101
|
+
cancelPending();
|
|
102
|
+
for (const hook of askHooks) hook.kill();
|
|
103
|
+
});
|
|
79
104
|
pi.on("session_before_switch", cancelPending);
|
|
80
105
|
|
|
81
106
|
// pi: fires once the run is fully settled (no retry, compaction or queued continuation left).
|
|
@@ -97,6 +122,73 @@ export function createExtension(overrides: Partial<ExtensionOptions> = {}): (pi:
|
|
|
97
122
|
if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
|
|
98
123
|
requestReply(ctx, lastAssistant(messages), ctx.sessionManager.getSessionId());
|
|
99
124
|
});
|
|
125
|
+
|
|
126
|
+
// omp: shadow the built-in `ask` and race its native dialog against the panel; the first
|
|
127
|
+
// answer wins. pi has no built-in `ask`.
|
|
128
|
+
let askRegistered = false;
|
|
129
|
+
|
|
130
|
+
pi.on("session_start", (_event, ctx) => {
|
|
131
|
+
if (askRegistered || !isOmp(ctx)) return;
|
|
132
|
+
const builtin = pi.getAllTools().find((tool) => tool.name === "ask" && tool.sourceInfo.source === "builtin");
|
|
133
|
+
if (!builtin) return;
|
|
134
|
+
askRegistered = true;
|
|
135
|
+
const ask: OmpToolDefinition = {
|
|
136
|
+
name: "ask",
|
|
137
|
+
description: builtin.description,
|
|
138
|
+
parameters: builtin.parameters,
|
|
139
|
+
// The native tool's traits (`tools/ask.ts`). Exclusive: the dialog is one shared UI
|
|
140
|
+
// surface, so two concurrent asks would clobber each other.
|
|
141
|
+
label: "Ask",
|
|
142
|
+
approval: "read",
|
|
143
|
+
strict: true,
|
|
144
|
+
loadMode: "discoverable",
|
|
145
|
+
concurrency: "exclusive",
|
|
146
|
+
execute: (_toolCallId, params, signal, _onUpdate, toolCtx) =>
|
|
147
|
+
answerAsk(params as Record<string, unknown>, signal, toolCtx as unknown as OmpToolContext),
|
|
148
|
+
};
|
|
149
|
+
pi.registerTool(ask);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
/** Runs the native `ask`, plus the panel when a guard doesn't rule it out. */
|
|
153
|
+
async function answerAsk(params: Record<string, unknown>, signal: AbortSignal | undefined, ctx: OmpToolContext) {
|
|
154
|
+
if (!ctx.invokeTool) throw new Error("omp offers no native `ask` to delegate to");
|
|
155
|
+
// The panel must show and answer the text native shows; native gets the call unchanged.
|
|
156
|
+
const input = sanitizeAskInput(params as unknown as AskInput);
|
|
157
|
+
const request =
|
|
158
|
+
canShowPanel(ctx) && ctx.agent.kind === "main" && !signal?.aborted
|
|
159
|
+
? toQuestionRequest(input, ctx.sessionManager.getSessionId(), ctx.cwd)
|
|
160
|
+
: undefined;
|
|
161
|
+
const executable = request && locateApp(options.agentsDir);
|
|
162
|
+
if (!request || !executable) return ctx.invokeTool(params);
|
|
163
|
+
|
|
164
|
+
const abortNativeDialog = new AbortController();
|
|
165
|
+
const native = ctx.invokeTool(params, {
|
|
166
|
+
signal: signal ? AbortSignal.any([signal, abortNativeDialog.signal]) : abortNativeDialog.signal,
|
|
167
|
+
});
|
|
168
|
+
const hook = runHook(executable, "question", request);
|
|
169
|
+
askHooks.add(hook);
|
|
170
|
+
const kill = () => hook.kill();
|
|
171
|
+
signal?.addEventListener("abort", kill, { once: true });
|
|
172
|
+
const disarmHardKill = armHardKill(kill);
|
|
173
|
+
try {
|
|
174
|
+
// Resolves with the panel's answer, or nothing once native settled or the panel closed
|
|
175
|
+
// without one. Handling native's rejection here also swallows the one the panel's win causes.
|
|
176
|
+
const answer = await Promise.race([
|
|
177
|
+
native.then(() => undefined, () => undefined),
|
|
178
|
+
hook.stdout.then((stdout) => (stdout === undefined ? undefined : parseQuestionAnswers(stdout, input))),
|
|
179
|
+
]);
|
|
180
|
+
if (answer) {
|
|
181
|
+
abortNativeDialog.abort();
|
|
182
|
+
return answer;
|
|
183
|
+
}
|
|
184
|
+
return await native;
|
|
185
|
+
} finally {
|
|
186
|
+
disarmHardKill();
|
|
187
|
+
signal?.removeEventListener("abort", kill);
|
|
188
|
+
askHooks.delete(hook);
|
|
189
|
+
hook.kill();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
100
192
|
};
|
|
101
193
|
}
|
|
102
194
|
|
|
@@ -106,6 +198,19 @@ interface OmpContext extends ExtensionContext {
|
|
|
106
198
|
agent: { kind: "main" | "sub" };
|
|
107
199
|
}
|
|
108
200
|
|
|
201
|
+
/** The `ctx` omp passes to a tool that re-registers a built-in: `invokeTool` runs the native one. */
|
|
202
|
+
interface OmpToolContext extends OmpContext {
|
|
203
|
+
invokeTool?(params: Record<string, unknown>, options?: { signal?: AbortSignal }): Promise<AgentToolResult<unknown>>;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** omp-only tool traits (`extensibility/extensions/types.ts`, proxied by `RegisteredToolAdapter`). */
|
|
207
|
+
interface OmpToolDefinition extends ToolDefinition {
|
|
208
|
+
approval: "read";
|
|
209
|
+
strict: boolean;
|
|
210
|
+
loadMode: "discoverable";
|
|
211
|
+
concurrency: "exclusive";
|
|
212
|
+
}
|
|
213
|
+
|
|
109
214
|
interface OmpAgentEndEvent extends AgentEndEvent {
|
|
110
215
|
/** True when omp will continue on its own (retry, compaction recovery, reminders, async wakes). */
|
|
111
216
|
willContinue?: boolean;
|
package/src/question.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/** The parts of an omp `ask` call this extension reads (`tools/ask.ts` input schema). */
|
|
2
|
+
export interface AskInput {
|
|
3
|
+
questions: AskQuestion[];
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface AskQuestion {
|
|
7
|
+
id: string;
|
|
8
|
+
question: string;
|
|
9
|
+
header?: string;
|
|
10
|
+
options: { label: string; description?: string; preview?: string }[];
|
|
11
|
+
multi?: boolean;
|
|
12
|
+
recommended?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** What `agent-hook question` reads on stdin: a Claude Code `AskUserQuestion` PreToolUse call. */
|
|
16
|
+
export interface QuestionRequest {
|
|
17
|
+
hook_event_name: "PreToolUse";
|
|
18
|
+
tool_name: "AskUserQuestion";
|
|
19
|
+
tool_input: { questions: PanelQuestion[] };
|
|
20
|
+
session_id: string;
|
|
21
|
+
cwd: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface PanelQuestion {
|
|
25
|
+
question: string;
|
|
26
|
+
header?: string;
|
|
27
|
+
options: { label: string; description?: string }[];
|
|
28
|
+
multiSelect: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Strips the `\r` runs models sometimes inject, as native `ask` does before it shows or answers a
|
|
33
|
+
* call (`sanitizeAskParams` in `tools/ask.ts` at v18.12.6), so the panel shows and answers the
|
|
34
|
+
* same text native would.
|
|
35
|
+
*/
|
|
36
|
+
export function sanitizeAskInput(input: AskInput): AskInput {
|
|
37
|
+
return {
|
|
38
|
+
...input,
|
|
39
|
+
questions: input.questions.map((q) => ({
|
|
40
|
+
...q,
|
|
41
|
+
id: sanitizeCarriageReturns(q.id),
|
|
42
|
+
question: sanitizeCarriageReturns(q.question),
|
|
43
|
+
...(q.header === undefined ? {} : { header: sanitizeCarriageReturns(q.header) }),
|
|
44
|
+
options: q.options.map((o) => ({
|
|
45
|
+
...o,
|
|
46
|
+
label: sanitizeCarriageReturns(o.label),
|
|
47
|
+
...(o.description === undefined ? {} : { description: sanitizeCarriageReturns(o.description) }),
|
|
48
|
+
...(o.preview === undefined ? {} : { preview: sanitizeCarriageReturns(o.preview) }),
|
|
49
|
+
})),
|
|
50
|
+
})),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** omp's `sanitizeCarriageReturns` (`pi-tui/render/render-utils.ts` at v18.12.6). */
|
|
55
|
+
function sanitizeCarriageReturns(text: string): string {
|
|
56
|
+
if (!text.includes("\r")) return text;
|
|
57
|
+
return text.replaceAll("\r\n", "\n").replace(/\r+/g, " ");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Maps an `ask` call to the stdin of `agent-hook question`. Returns nothing when the panel
|
|
62
|
+
* can't represent the call: it keys answers by question text, so two questions with the same
|
|
63
|
+
* text can't be told apart.
|
|
64
|
+
*/
|
|
65
|
+
export function toQuestionRequest(input: AskInput, sessionId: string, cwd: string): QuestionRequest | undefined {
|
|
66
|
+
const texts = new Set(input.questions.map((q) => q.question));
|
|
67
|
+
if (texts.size !== input.questions.length) return undefined;
|
|
68
|
+
return {
|
|
69
|
+
hook_event_name: "PreToolUse",
|
|
70
|
+
tool_name: "AskUserQuestion",
|
|
71
|
+
tool_input: {
|
|
72
|
+
questions: input.questions.map((q) => ({
|
|
73
|
+
question: q.question,
|
|
74
|
+
...(q.header === undefined ? {} : { header: q.header }),
|
|
75
|
+
options: q.options.map((o) => ({
|
|
76
|
+
label: o.label,
|
|
77
|
+
...(o.description === undefined ? {} : { description: o.description }),
|
|
78
|
+
})),
|
|
79
|
+
multiSelect: q.multi ?? false,
|
|
80
|
+
})),
|
|
81
|
+
},
|
|
82
|
+
session_id: sessionId,
|
|
83
|
+
cwd,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** One answered question, as native `ask` reports it in `details.results` (`AskToolDetails`). */
|
|
88
|
+
export interface QuestionResult {
|
|
89
|
+
id: string;
|
|
90
|
+
question: string;
|
|
91
|
+
options: string[];
|
|
92
|
+
multi: boolean;
|
|
93
|
+
selectedOptions: string[];
|
|
94
|
+
customInput?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The `ask` tool result: one question puts its answer in `details`, several in `details.results`. */
|
|
98
|
+
export interface AskResult {
|
|
99
|
+
content: [{ type: "text"; text: string }];
|
|
100
|
+
details: Partial<Omit<QuestionResult, "id">> & { results?: QuestionResult[] };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Maps the stdout of `agent-hook question` to the result omp's native `ask` returns for the same
|
|
105
|
+
* selections (`tools/ask.ts` at v18.12.6). Returns nothing unless the panel allowed the call with
|
|
106
|
+
* an answer for every question: dismissed, timed out, empty or malformed output.
|
|
107
|
+
*/
|
|
108
|
+
export function parseQuestionAnswers(stdout: string, input: AskInput): AskResult | undefined {
|
|
109
|
+
const answers = readAnswers(stdout);
|
|
110
|
+
if (answers === undefined) return undefined;
|
|
111
|
+
const results: QuestionResult[] = [];
|
|
112
|
+
for (const q of input.questions) {
|
|
113
|
+
const answer = answers[q.question];
|
|
114
|
+
if (typeof answer !== "string" || answer === "") return undefined;
|
|
115
|
+
results.push(toResult(q, answer));
|
|
116
|
+
}
|
|
117
|
+
const [only] = results;
|
|
118
|
+
if (only !== undefined && results.length === 1) {
|
|
119
|
+
const { id: _, ...details } = only;
|
|
120
|
+
return { content: [{ type: "text", text: formatSingleQuestionResponse(only) }], details };
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
content: [{ type: "text", text: `User answers:\n${results.map(formatQuestionResult).join("\n")}` }],
|
|
124
|
+
details: { results },
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The parts of `agent-hook question` stdout this extension reads; any of them may be missing or wrong. */
|
|
129
|
+
interface HookOutput {
|
|
130
|
+
hookSpecificOutput?: { permissionDecision?: unknown; updatedInput?: { answers?: unknown } };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The `updatedInput.answers` of an allowed `agent-hook question` call, keyed by question text. */
|
|
134
|
+
function readAnswers(stdout: string): Record<string, unknown> | undefined {
|
|
135
|
+
let json: HookOutput | null;
|
|
136
|
+
try {
|
|
137
|
+
json = JSON.parse(stdout);
|
|
138
|
+
} catch {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
const output = json?.hookSpecificOutput;
|
|
142
|
+
if (output?.permissionDecision !== "allow") return undefined;
|
|
143
|
+
const answers = output.updatedInput?.answers;
|
|
144
|
+
return typeof answers === "object" && answers !== null ? (answers as Record<string, unknown>) : undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The panel answers with one label, the picked labels joined with ", " in option order
|
|
149
|
+
* (multi-select), or free text. Anything that isn't a label (or labels) is custom input.
|
|
150
|
+
*/
|
|
151
|
+
function toResult(q: AskQuestion, answer: string): QuestionResult {
|
|
152
|
+
const labels = q.options.map((o) => o.label);
|
|
153
|
+
const multi = q.multi ?? false;
|
|
154
|
+
const selected = labels.includes(answer) ? [answer] : multi ? splitLabels(answer, labels, 0) : undefined;
|
|
155
|
+
const base = { id: q.id, question: q.question, options: labels, multi };
|
|
156
|
+
return selected === undefined
|
|
157
|
+
? { ...base, selectedOptions: [], customInput: answer }
|
|
158
|
+
: { ...base, selectedOptions: selected };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Labels from `labels[from..]`, in order, whose ", "-join is `value`; labels may contain ", " themselves. */
|
|
162
|
+
function splitLabels(value: string, labels: string[], from: number): string[] | undefined {
|
|
163
|
+
for (let i = from; i < labels.length; i++) {
|
|
164
|
+
const label = labels[i] as string;
|
|
165
|
+
if (value === label) return [label];
|
|
166
|
+
if (!value.startsWith(`${label}, `)) continue;
|
|
167
|
+
const rest = splitLabels(value.slice(label.length + 2), labels, i + 1);
|
|
168
|
+
if (rest !== undefined) return [label, ...rest];
|
|
169
|
+
}
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// The two formatters mirror omp's `tools/ask.ts` (v18.12.6) for the answers the panel can give:
|
|
174
|
+
// picked labels or custom input, never both, and no note or timeout.
|
|
175
|
+
|
|
176
|
+
function formatQuestionResult(result: QuestionResult): string {
|
|
177
|
+
if (result.customInput !== undefined) return `${result.id}: "${result.customInput}"`;
|
|
178
|
+
const picked = result.multi ? `[${result.selectedOptions.join(", ")}]` : result.selectedOptions[0];
|
|
179
|
+
return `${result.id}: ${picked}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function formatSingleQuestionResponse(result: QuestionResult): string {
|
|
183
|
+
if (result.customInput === undefined) {
|
|
184
|
+
return result.multi
|
|
185
|
+
? `User selected: ${result.selectedOptions.join(", ")}`
|
|
186
|
+
: `User selected: ${result.selectedOptions[0]}`;
|
|
187
|
+
}
|
|
188
|
+
return result.customInput.includes("\n")
|
|
189
|
+
? `User provided custom input:\n${result.customInput
|
|
190
|
+
.split("\n")
|
|
191
|
+
.map((line) => ` ${line}`)
|
|
192
|
+
.join("\n")}`
|
|
193
|
+
: `User provided custom input: ${result.customInput}`;
|
|
194
|
+
}
|