@hank-warren/pi-ask-user-question 0.5.3 → 0.6.1

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,30 @@
1
1
  # @hank-warren/pi-ask-user-question
2
2
 
3
+ ## 0.6.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 1035138: Report a waiting question to Herdr as `blocked`.
8
+
9
+ `ask_user_question` and Plan Mode's `plan_mode_question` now emit the same
10
+ `herdr:blocked` event pi-auto-permissions emits for an approval prompt, labelled
11
+ `question` / `plan question` and cleared in a `finally`, so Herdr's pi
12
+ integration shows a session waiting on a question as `blocked` rather than
13
+ `working`. A supervising agent in another pane can wait on that state and answer
14
+ the dialog. No-op outside Herdr.
15
+
16
+ ## 0.6.0
17
+
18
+ ### Minor Changes
19
+
20
+ - c079c51: Announce tool availability on a new event, and stop mistaking a symlinked install for somebody else's package.
21
+
22
+ 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.
23
+
24
+ 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.
25
+
26
+ `AbortSignal` is wired through the questionnaire with exactly-once listener cleanup, and the blocked signal is always cleared in a `finally`.
27
+
3
28
  ## 0.5.3
4
29
 
5
30
  ### Patch Changes
package/README.md CHANGED
@@ -123,6 +123,10 @@ pi.events.on("hank:ask-user:prompt", ({ questions }) => {
123
123
  Channel names are immutable and payloads are append-only — see
124
124
  [`events.ts`](./events.ts) for the full stability policy.
125
125
 
126
+ ## Changelog
127
+
128
+ See [CHANGELOG.md](CHANGELOG.md) for release history.
129
+
126
130
  ## License
127
131
 
128
132
  MIT
@@ -65,9 +65,23 @@ function emitPrompt(pi: ExtensionAPI, params: AskUserParams): void {
65
65
  pi.events.emit(ASK_USER_PROMPT_EVENT, payload);
66
66
  }
67
67
 
68
+ /**
69
+ * Label Herdr shows for a pane waiting on this dialog. A supervising agent
70
+ * reads it to tell a question from an Auto Permissions prompt (whose label
71
+ * is the gate name, e.g. `shell command`).
72
+ */
73
+ export const HERDR_BLOCKED_LABEL = "question";
74
+
68
75
  function emitBlocked(pi: ExtensionAPI, active: boolean): void {
69
76
  const payload: AskUserBlockedEventPayload = { active };
70
77
  pi.events.emit(ASK_USER_BLOCKED_EVENT, payload);
78
+ // Herdr's pi integration turns `herdr:blocked` into `agent_status: "blocked"`,
79
+ // which is how an orchestrator in another pane learns this session is
80
+ // waiting on a human rather than working. Same contract pi-auto-permissions
81
+ // uses (`setHerdrBlocked`); duplicated rather than imported, because a
82
+ // questionnaire must not pull in the permissions engine. No-op outside Herdr.
83
+ if (process.env.HERDR_ENV !== "1") return;
84
+ pi.events.emit("herdr:blocked", active ? { active: true, label: HERDR_BLOCKED_LABEL } : { active: false });
71
85
  }
72
86
 
73
87
  export function registerTool(pi: ExtensionAPI): void {
@@ -77,7 +91,7 @@ export function registerTool(pi: ExtensionAPI): void {
77
91
  description: DESCRIPTION,
78
92
  parameters: QuestionParamsSchema,
79
93
 
80
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
94
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
81
95
  const typed = params as unknown as AskUserParams;
82
96
 
83
97
  // Backstop only — reconcile.ts should already have stripped the tool
@@ -96,6 +110,11 @@ export function registerTool(pi: ExtensionAPI): void {
96
110
  }
97
111
 
98
112
  const session = new QuestionnaireSession(typed);
113
+ if (signal?.aborted) return buildResponse(session.cancelledResult(), typed);
114
+
115
+ let dialog: QuestionnaireDialog | undefined;
116
+ const cancel = () => dialog?.cancel();
117
+ signal?.addEventListener("abort", cancel, { once: true });
99
118
  emitPrompt(pi, typed);
100
119
  emitBlocked(pi, true);
101
120
  try {
@@ -110,8 +129,8 @@ export function registerTool(pi: ExtensionAPI): void {
110
129
  // pi-auto-permissions' approval prompt already puts `OptionSelector`.
111
130
  // pi restores the input editor when `done` fires.
112
131
  const result = await ctx.ui.custom<QuestionnaireResult | null>(
113
- (tui, theme, _keybindings, done) =>
114
- new QuestionnaireDialog({
132
+ (tui, theme, _keybindings, done) => {
133
+ dialog = new QuestionnaireDialog({
115
134
  session,
116
135
  theme,
117
136
  // Reuse pi's own markdown theme so previews match the
@@ -119,7 +138,10 @@ export function registerTool(pi: ExtensionAPI): void {
119
138
  markdownTheme: getMarkdownTheme(),
120
139
  done,
121
140
  requestRender: () => tui.requestRender(),
122
- }),
141
+ });
142
+ if (signal?.aborted) queueMicrotask(() => dialog?.cancel());
143
+ return dialog;
144
+ },
123
145
  );
124
146
 
125
147
  // `custom()` resolving undefined means the host reported hasUI but
@@ -133,6 +155,7 @@ export function registerTool(pi: ExtensionAPI): void {
133
155
  }
134
156
  return buildResponse(result, typed);
135
157
  } finally {
158
+ signal?.removeEventListener("abort", cancel);
136
159
  // In `finally` so listeners are never left believing we are still
137
160
  // blocked on a human after a throw.
138
161
  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.1",
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": [
@@ -24,7 +24,7 @@
24
24
  },
25
25
  "homepage": "https://github.com/hank-warren/pi-extensions/tree/main/packages/pi-ask-user-question#readme",
26
26
  "engines": {
27
- "node": ">=18.0.0"
27
+ "node": ">=22.19.0"
28
28
  },
29
29
  "pi": {
30
30
  "extensions": [
package/questionnaire.ts CHANGED
@@ -25,7 +25,7 @@ import {
25
25
  type QuestionnaireResult,
26
26
  } from "./tool/schema.ts";
27
27
 
28
- export interface SelectableRow {
28
+ interface SelectableRow {
29
29
  value: string;
30
30
  label: string;
31
31
  description?: string;
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/tool/envelope.ts CHANGED
@@ -14,7 +14,7 @@ export const DECLINE_MESSAGE = "User declined to answer questions";
14
14
  export const ENVELOPE_PREFIX = "User has answered your questions:";
15
15
  export const ENVELOPE_SUFFIX = "You can now continue with the user's answers in mind.";
16
16
 
17
- export interface ToolResult {
17
+ interface ToolResult {
18
18
  content: Array<{ type: "text"; text: string }>;
19
19
  details: QuestionnaireResult;
20
20
  }
@@ -28,7 +28,7 @@ export function buildToolResult(text: string, details: QuestionnaireResult): Too
28
28
  * preview and the user's note appended when present. Order and wording match
29
29
  * rpiv's envelope and are pinned by tests.
30
30
  */
31
- export function buildAnswerSegment(a: QuestionAnswer): string {
31
+ function buildAnswerSegment(a: QuestionAnswer): string {
32
32
  const parts: string[] = [`"${a.question}"="${a.answer}"`];
33
33
  if (a.preview && a.preview.length > 0) parts.push(`selected preview: ${a.preview}`);
34
34
  if (a.notes && a.notes.length > 0) parts.push(`user notes: ${a.notes}`);
package/tool/schema.ts CHANGED
@@ -12,13 +12,13 @@ import { Type } from "typebox";
12
12
  export const TOOL_NAME = "ask_user_question";
13
13
 
14
14
  export const MIN_OPTIONS = 2;
15
- export const MAX_OPTIONS = 4;
15
+ const MAX_OPTIONS = 4;
16
16
  /**
17
17
  * Multi-select questions get a larger cap. Checkboxes are a shortlist UI, not a
18
18
  * pick-one UI, so six is where a list stops fitting comfortably above the input
19
19
  * dock — not where it stops being a decision.
20
20
  */
21
- export const MAX_MULTI_OPTIONS = 6;
21
+ const MAX_MULTI_OPTIONS = 6;
22
22
  export const MIN_QUESTIONS = 1;
23
23
  export const MAX_QUESTIONS = 4;
24
24
 
@@ -42,7 +42,7 @@ export const CUSTOM_ANSWER_VALUE = "\u0000custom-answer";
42
42
  /** Separator joining a multi-select answer's labels into `answer` (spec §5.4). */
43
43
  export const MULTI_SELECT_JOIN = ", ";
44
44
 
45
- export const OptionSchema = Type.Object({
45
+ const OptionSchema = Type.Object({
46
46
  label: Type.String({
47
47
  description:
48
48
  "The display text for this option that the user will see and select. Aim for 1-5 words, but there is no hard limit: a longer label is fine when it genuinely helps, and long labels wrap in the dialog rather than being rejected.",
@@ -59,7 +59,7 @@ export const OptionSchema = Type.Object({
59
59
  ),
60
60
  });
61
61
 
62
- export const QuestionSchema = Type.Object({
62
+ const QuestionSchema = Type.Object({
63
63
  question: Type.String({
64
64
  description:
65
65
  "The complete question to ask the user. Should be clear, specific, and end with a question mark.",
@@ -91,13 +91,13 @@ export const QuestionParamsSchema = Type.Object({
91
91
  }),
92
92
  });
93
93
 
94
- export interface OptionParams {
94
+ interface OptionParams {
95
95
  label: string;
96
96
  description: string;
97
97
  preview?: string;
98
98
  }
99
99
 
100
- export interface QuestionParams {
100
+ interface QuestionParams {
101
101
  question: string;
102
102
  header: string;
103
103
  /** Checkbox mode: the user may check several options. Default false. */
package/tool/validate.ts CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  RESERVED_LABELS,
25
25
  } from "./schema.ts";
26
26
 
27
- export type ValidationCode =
27
+ type ValidationCode =
28
28
  | "bad_question_count"
29
29
  | "bad_option_count"
30
30
  | "header_too_long"
@@ -33,7 +33,7 @@ export type ValidationCode =
33
33
  | "duplicate_label"
34
34
  | "empty_question";
35
35
 
36
- export interface ValidationError {
36
+ interface ValidationError {
37
37
  code: ValidationCode;
38
38
  message: string;
39
39
  }
package/view/dialog.ts CHANGED
@@ -64,11 +64,11 @@ import type { QuestionnaireSession } from "../questionnaire.ts";
64
64
  import type { QuestionnaireResult } from "../tool/schema.ts";
65
65
 
66
66
  /** Structural subset of pi's Theme used here; keeps the view unit-testable. */
67
- export interface DialogTheme {
67
+ interface DialogTheme {
68
68
  fg(role: string, text: string): string;
69
69
  }
70
70
 
71
- export interface DialogOptions {
71
+ interface DialogOptions {
72
72
  session: QuestionnaireSession;
73
73
  theme?: DialogTheme;
74
74
  /** Called exactly once with the final outcome. */
@@ -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;