@hank-warren/pi-ask-user-question 0.5.3 → 0.6.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/CHANGELOG.md +12 -0
- package/ask-user-question.ts +13 -4
- package/events.ts +8 -0
- package/index.ts +6 -1
- package/package.json +1 -1
- package/reconcile.ts +71 -4
- package/view/dialog.ts +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @hank-warren/pi-ask-user-question
|
|
2
2
|
|
|
3
|
+
## 0.6.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- c079c51: Announce tool availability on a new event, and stop mistaking a symlinked install for somebody else's package.
|
|
8
|
+
|
|
9
|
+
Every reconcile now emits `hank:ask-user:availability` (`ASK_USER_AVAILABILITY_EVENT`) with `{ available: boolean }`, letting a consumer such as pi-plan-mode decide whether its own fallback question tool is needed without racing hook order. Availability is read back from the host rather than assumed from the write, because Pi silently ignores a name excluded by `--tools` or a tool policy, and a false positive would leave an interactive session with no question tool at all.
|
|
10
|
+
|
|
11
|
+
The check for whether the registered `ask_user_question` is still backed by this package now canonicalizes both paths before comparing. `import.meta.url` is realpath-resolved by Node while Pi passes `sourceInfo.baseDir` through untouched, so every workspace, pnpm, and `npm link` install compared two spellings of the same directory, concluded the tool belonged to someone else, and never restored it after the first headless run. A path that cannot be resolved falls back to a lexical compare rather than failing closed.
|
|
12
|
+
|
|
13
|
+
`AbortSignal` is wired through the questionnaire with exactly-once listener cleanup, and the blocked signal is always cleared in a `finally`.
|
|
14
|
+
|
|
3
15
|
## 0.5.3
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/ask-user-question.ts
CHANGED
|
@@ -77,7 +77,7 @@ export function registerTool(pi: ExtensionAPI): void {
|
|
|
77
77
|
description: DESCRIPTION,
|
|
78
78
|
parameters: QuestionParamsSchema,
|
|
79
79
|
|
|
80
|
-
async execute(_toolCallId, params,
|
|
80
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
81
81
|
const typed = params as unknown as AskUserParams;
|
|
82
82
|
|
|
83
83
|
// Backstop only — reconcile.ts should already have stripped the tool
|
|
@@ -96,6 +96,11 @@ export function registerTool(pi: ExtensionAPI): void {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
const session = new QuestionnaireSession(typed);
|
|
99
|
+
if (signal?.aborted) return buildResponse(session.cancelledResult(), typed);
|
|
100
|
+
|
|
101
|
+
let dialog: QuestionnaireDialog | undefined;
|
|
102
|
+
const cancel = () => dialog?.cancel();
|
|
103
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
99
104
|
emitPrompt(pi, typed);
|
|
100
105
|
emitBlocked(pi, true);
|
|
101
106
|
try {
|
|
@@ -110,8 +115,8 @@ export function registerTool(pi: ExtensionAPI): void {
|
|
|
110
115
|
// pi-auto-permissions' approval prompt already puts `OptionSelector`.
|
|
111
116
|
// pi restores the input editor when `done` fires.
|
|
112
117
|
const result = await ctx.ui.custom<QuestionnaireResult | null>(
|
|
113
|
-
(tui, theme, _keybindings, done) =>
|
|
114
|
-
new QuestionnaireDialog({
|
|
118
|
+
(tui, theme, _keybindings, done) => {
|
|
119
|
+
dialog = new QuestionnaireDialog({
|
|
115
120
|
session,
|
|
116
121
|
theme,
|
|
117
122
|
// Reuse pi's own markdown theme so previews match the
|
|
@@ -119,7 +124,10 @@ export function registerTool(pi: ExtensionAPI): void {
|
|
|
119
124
|
markdownTheme: getMarkdownTheme(),
|
|
120
125
|
done,
|
|
121
126
|
requestRender: () => tui.requestRender(),
|
|
122
|
-
})
|
|
127
|
+
});
|
|
128
|
+
if (signal?.aborted) queueMicrotask(() => dialog?.cancel());
|
|
129
|
+
return dialog;
|
|
130
|
+
},
|
|
123
131
|
);
|
|
124
132
|
|
|
125
133
|
// `custom()` resolving undefined means the host reported hasUI but
|
|
@@ -133,6 +141,7 @@ export function registerTool(pi: ExtensionAPI): void {
|
|
|
133
141
|
}
|
|
134
142
|
return buildResponse(result, typed);
|
|
135
143
|
} finally {
|
|
144
|
+
signal?.removeEventListener("abort", cancel);
|
|
136
145
|
// In `finally` so listeners are never left believing we are still
|
|
137
146
|
// blocked on a human after a throw.
|
|
138
147
|
emitBlocked(pi, false);
|
package/events.ts
CHANGED
|
@@ -53,3 +53,11 @@ export interface AskUserBlockedEventPayload {
|
|
|
53
53
|
/** True while input is awaited; false when the wait ends (answer, cancel, or error). */
|
|
54
54
|
active: boolean;
|
|
55
55
|
}
|
|
56
|
+
|
|
57
|
+
/** Emitted after the active tool set has been reconciled for the current run. */
|
|
58
|
+
export const ASK_USER_AVAILABILITY_EVENT = "hank:ask-user:availability" as const;
|
|
59
|
+
|
|
60
|
+
export interface AskUserAvailabilityEventPayload {
|
|
61
|
+
/** True only when an interactive global question tool is active and usable. */
|
|
62
|
+
available: boolean;
|
|
63
|
+
}
|
package/index.ts
CHANGED
|
@@ -11,13 +11,16 @@
|
|
|
11
11
|
* reconcile.ts.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
14
15
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
16
|
import { registerTool } from "./ask-user-question.ts";
|
|
16
17
|
import { registerReconciler } from "./reconcile.ts";
|
|
17
18
|
|
|
18
19
|
export {
|
|
20
|
+
ASK_USER_AVAILABILITY_EVENT,
|
|
19
21
|
ASK_USER_BLOCKED_EVENT,
|
|
20
22
|
ASK_USER_PROMPT_EVENT,
|
|
23
|
+
type AskUserAvailabilityEventPayload,
|
|
21
24
|
type AskUserBlockedEventPayload,
|
|
22
25
|
type AskUserPromptEventPayload,
|
|
23
26
|
type AskUserPromptOption,
|
|
@@ -26,5 +29,7 @@ export {
|
|
|
26
29
|
|
|
27
30
|
export default function askUserQuestionExtension(pi: ExtensionAPI): void {
|
|
28
31
|
registerTool(pi);
|
|
29
|
-
|
|
32
|
+
// Our own location, resolved without touching a runtime action method:
|
|
33
|
+
// nothing on `pi` may be *called* during extension loading.
|
|
34
|
+
registerReconciler(pi, fileURLToPath(import.meta.url));
|
|
30
35
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hank-warren/pi-ask-user-question",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Structured questionnaire tool for Pi with numbered options, digit hotkeys and Tab-to-comment, composed from the shared permission-selector component.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
package/reconcile.ts
CHANGED
|
@@ -22,7 +22,10 @@
|
|
|
22
22
|
* `before_agent_start` runs.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
+
import { realpathSync } from "node:fs";
|
|
26
|
+
import { dirname, resolve } from "node:path";
|
|
25
27
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
28
|
+
import { ASK_USER_AVAILABILITY_EVENT } from "./events.ts";
|
|
26
29
|
import { TOOL_NAME } from "./tool/schema.ts";
|
|
27
30
|
|
|
28
31
|
/**
|
|
@@ -30,18 +33,82 @@ import { TOOL_NAME } from "./tool/schema.ts";
|
|
|
30
33
|
* already in the right state, the active set (and every sibling tool in it) is
|
|
31
34
|
* left untouched.
|
|
32
35
|
*/
|
|
33
|
-
export function reconcileTool(
|
|
36
|
+
export function reconcileTool(
|
|
37
|
+
pi: ExtensionAPI,
|
|
38
|
+
ctx: ExtensionContext,
|
|
39
|
+
ownSourcePath?: string,
|
|
40
|
+
): boolean {
|
|
34
41
|
const active = pi.getActiveTools();
|
|
35
42
|
const present = active.includes(TOOL_NAME);
|
|
43
|
+
let wrote = false;
|
|
36
44
|
if (!ctx.hasUI && present) {
|
|
37
45
|
pi.setActiveTools(active.filter((name) => name !== TOOL_NAME));
|
|
38
|
-
|
|
46
|
+
wrote = true;
|
|
47
|
+
} else if (ctx.hasUI && !present && ownsRegisteredTool(pi, ownSourcePath)) {
|
|
39
48
|
pi.setActiveTools([...active, TOOL_NAME]);
|
|
49
|
+
wrote = true;
|
|
40
50
|
}
|
|
51
|
+
// Availability is read back from the host, never assumed from the list we
|
|
52
|
+
// asked for: Pi silently ignores a name that is excluded by --tools or a
|
|
53
|
+
// tool policy, so an accepted write is not proof the tool is there. Plan
|
|
54
|
+
// mode drops its own fallback on this signal, and a false positive would
|
|
55
|
+
// leave an interactive session with no question tool at all.
|
|
56
|
+
const available =
|
|
57
|
+
ctx.hasUI && (wrote ? pi.getActiveTools() : active).includes(TOOL_NAME);
|
|
58
|
+
pi.events?.emit(ASK_USER_AVAILABILITY_EVENT, { available });
|
|
59
|
+
return available;
|
|
41
60
|
}
|
|
42
61
|
|
|
43
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Whether the name is still backed by *this* package's registration.
|
|
64
|
+
*
|
|
65
|
+
* Compared by directory, not by exact path: Pi records the extension path as
|
|
66
|
+
* it was configured (relative, npm-resolved, or synthetic) while `baseDir` is
|
|
67
|
+
* the resolved directory, and only the directory is stable across all three.
|
|
68
|
+
* Anything unknowable fails open to "ours", which preserves the original
|
|
69
|
+
* always-restore behaviour on hosts that report no source information.
|
|
70
|
+
*/
|
|
71
|
+
function ownsRegisteredTool(pi: ExtensionAPI, ownSourcePath?: string): boolean {
|
|
72
|
+
if (!ownSourcePath || typeof pi.getAllTools !== "function") return true;
|
|
73
|
+
const effective = pi.getAllTools().find((tool) => tool.name === TOOL_NAME);
|
|
74
|
+
if (!effective) return true;
|
|
75
|
+
const info = effective.sourceInfo as { path?: string; baseDir?: string } | undefined;
|
|
76
|
+
const ownDir = dirname(ownSourcePath);
|
|
77
|
+
if (info?.baseDir) return sameDirectory(info.baseDir, ownDir);
|
|
78
|
+
if (info?.path) return sameDirectory(dirname(info.path), ownDir);
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Directory equality that survives a symlink on either side.
|
|
84
|
+
*
|
|
85
|
+
* `resolve()` alone is not enough, and the asymmetry is guaranteed rather than
|
|
86
|
+
* unlucky: `ownSourcePath` comes from `import.meta.url`, which Node hands back
|
|
87
|
+
* already realpath-resolved, while Pi passes `sourceInfo.baseDir` through
|
|
88
|
+
* untouched (`core/source-info.js`). Any workspace, pnpm, or npm-link install
|
|
89
|
+
* reaches the package through a symlink, so the two spellings differ for the
|
|
90
|
+
* same directory — and the caller treats a mismatch as "not ours", which would
|
|
91
|
+
* strip `ask_user_question` on the first headless run and never restore it,
|
|
92
|
+
* leaving an interactive session with no question tool at all.
|
|
93
|
+
*
|
|
94
|
+
* A `realpathSync` throw (a deleted or unreadable path) falls back to the
|
|
95
|
+
* lexical form rather than propagating, matching the fail-open posture of
|
|
96
|
+
* every other branch in `ownsRegisteredTool`.
|
|
97
|
+
*/
|
|
98
|
+
function sameDirectory(a: string, b: string): boolean {
|
|
99
|
+
const canonical = (path: string): string => {
|
|
100
|
+
const absolute = resolve(path);
|
|
101
|
+
try {
|
|
102
|
+
return realpathSync(absolute);
|
|
103
|
+
} catch {
|
|
104
|
+
return absolute;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
return canonical(a) === canonical(b);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function registerReconciler(pi: ExtensionAPI, ownSourcePath?: string): void {
|
|
44
111
|
pi.on("before_agent_start", (_event, ctx) => {
|
|
45
|
-
reconcileTool(pi, ctx);
|
|
112
|
+
reconcileTool(pi, ctx, ownSourcePath);
|
|
46
113
|
});
|
|
47
114
|
}
|
package/view/dialog.ts
CHANGED
|
@@ -215,6 +215,11 @@ export class QuestionnaireDialog {
|
|
|
215
215
|
this.opts.done(result);
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
/** Cancel from an external lifecycle signal. Safe to call after user completion. */
|
|
219
|
+
cancel(): void {
|
|
220
|
+
this.finish(this.session.cancelledResult());
|
|
221
|
+
}
|
|
222
|
+
|
|
218
223
|
/** True while the free-text custom-answer editor is open. */
|
|
219
224
|
isTypingCustom(): boolean {
|
|
220
225
|
return this.tab?.customText !== undefined;
|