@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 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
@@ -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, _signal, _onUpdate, ctx) {
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
- registerReconciler(pi);
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.5.3",
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(pi: ExtensionAPI, ctx: ExtensionContext): void {
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
- } else if (ctx.hasUI && !present) {
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
- export function registerReconciler(pi: ExtensionAPI): void {
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;