@zhushanwen/pi-ask-user 7.1.3 → 7.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/ARCHITECTURE.md CHANGED
@@ -1,8 +1,10 @@
1
1
  # ask-user — Architecture
2
2
 
3
+ > 功能分级:P0(依据见 [docs/FEATURE-PRIORITIES.md](../../../docs/FEATURE-PRIORITIES.md) §6 边界判例 #3——agent↔用户交互闭环的唯一通道,2026-09-12 用户裁决升 P0)。
4
+
3
5
  Internals reference for maintainers. For the usage contract (what the tool does, when an agent should call it), see [README.md](./README.md). This document covers how the code is structured, the state machine, the defensive execute flow, and where each design invariant is enforced — so a change does not silently break an invariant.
4
6
 
5
- Source: 10 files in `src/`, ~1970 lines total.
7
+ Source: 10 files in `src/`, ~2085 lines total.
6
8
 
7
9
  ## File dependency graph
8
10
 
@@ -61,7 +63,17 @@ Serialization happens **once, at the protocol boundary**: `encodeAnswer(value, {
61
63
  - 多选:`answers[key] = JSON.stringify(selected)`
62
64
  - Other:`answers[`${key}__other`] = other`(仅在 other 非空时写入)
63
65
 
64
- Encoding is **one-way** — the old `parseAnswerParts` text reverse-parsing was deleted with the double model; there is no decode counterpart inside the extension. `channel-handler.ts` reads `AnswerValue` directly and calls `encodeAnswer` when forwarding to subagents.
66
+ Encoding is **one-way** — the old `parseAnswerParts` text reverse-parsing was deleted with the double model; there is no decode counterpart inside the extension. `channel-handler.ts` reads `AnswerValue` directly and calls `encodeAnswer` when forwarding to subagents. (`encodeAnswer` is the only encode implementation **inside this extension**; the renderer frontend components cannot import extension packages and implement the encoding independently, aligned to the same decode contract.)
67
+
68
+ ## Channel registry handshake (subagent passthrough)
69
+
70
+ `channel-handler.ts` + `channel-registry-register.ts` implement the subagent passthrough: the host-process ask-user extension registers an `"ask_user"` channel handler through a versioned `globalThis[Symbol.for]` slot handshake (`CHANNEL_HANDSHAKE_KEY`). The skeleton (versioned slot + pending/flush; this side never creates the registry instance) is deliberate and must not be simplified away — the M4 incident (a simplified self-made registry hijacking the canonical slot) is the anchor, see the header comment of `channel-registry-register.ts`. A second consumer of the same handshake *pattern* exists: `extensions/universal/permission/src/footer-provider.ts` (own slot key `FOOTER_HANDSHAKE_KEY`, same versioned-slot shape).
71
+
72
+ Registry outreach facts (ext-simplify-11, D4):
73
+
74
+ - **Single registrant on this slot.** The `CHANNEL_HANDSHAKE_KEY` slot currently has exactly one registrant: ask-user (`"ask_user"`). The permission footer-provider handshake above uses its own separate slot — same pattern, different slot, not a registrant here.
75
+ - **`"gui_widget"` route reserved but vacant.** The engine-sdk ui-channels parsing (`parseChannel`, re-exported via `packages/subagent-core/src/execution/ui-channels.ts`) reserves a `"gui_widget"` channel name, but no package registers a handler for it; the core-side `ui-request-handler-factory` special-cases the unregistered `"gui_widget"` request to `{ack:true}` without forwarding. A future registrant needs no ask-user change — registration is keyed by channel name and orthogonal per extension.
76
+ - **Known gap, accepted (version-mismatch slot overwrite).** On `slot.version !== 1`, `readSlot` discards the slot and `registerAskUserChannelHandler` rebuilds it via `ensureSlot` — if a hypothetical v2 registry ever held the slot, this would drop its reference. Ruled not-worth-fixing (ext-simplify-11 finding 6): both sides pin `HANDSHAKE_VERSION = 1`, so the mismatch path is unreachable today; fixing it would be defensive code for an imagined v2. Re-review trigger: any PR that bumps either side's handshake version.
65
77
 
66
78
  ## `execute` defensive flow (6 steps)
67
79
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-ask-user",
3
- "version": "7.1.3",
3
+ "version": "7.2.0",
4
4
  "description": "Inline adaptive ask_user tool for Pi — single/multi-question structured input with split-pane preview and an inline free-text editor.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -29,7 +29,8 @@
29
29
  "ARCHITECTURE.md"
30
30
  ],
31
31
  "dependencies": {
32
- "@xyz-agent/extension-protocol": "0.9.0",
32
+ "@xyz-agent/extension-protocol": "0.11.0",
33
+ "@zhushanwen/pi-ext-guards": "0.4.0",
33
34
  "@zhushanwen/pi-extension-logger": "0.6.0"
34
35
  },
35
36
  "devDependencies": {
@@ -166,6 +166,33 @@ describe("validateInput", () => {
166
166
  expect(result).toContain("Correct:");
167
167
  expect(result).toContain('"header"');
168
168
  });
169
+
170
+ // V-20/M23: label 恰为保留字 "Other" → 拦截。Other 自由输入行由 extension 自动追加,
171
+ // LLM 自带会渲染出同名双行;文案含 reserved 与改名指引(rename + e.g. 示例)。
172
+ it("rejects option label 'Other' as reserved with rename guidance", () => {
173
+ const result = validateInput([
174
+ q({ options: [{ label: "Postgres" }, { label: "Other" }] }),
175
+ ]);
176
+ expect(result).not.toBeNull();
177
+ expect(result).toContain("reserved");
178
+ expect(result).toContain("rename");
179
+ });
180
+
181
+ // V-21/D2: 空白变体 " Other " 与合成 Other 行视觉不可分辨(仅差不可见空白),同样拦截
182
+ it("rejects whitespace variants of the reserved label (' Other ')", () => {
183
+ const result = validateInput([
184
+ q({ options: [{ label: "Postgres" }, { label: " Other " }] }),
185
+ ]);
186
+ expect(result).toContain("reserved");
187
+ });
188
+
189
+ // V-22/D2: 含 Other 子串的合法标签不被拦截(精确匹配不误杀)——catch-all 语义可经命名传达
190
+ it("allows labels containing 'Other' as a substring (e.g. 'Other database')", () => {
191
+ const result = validateInput([
192
+ q({ options: [{ label: "Postgres" }, { label: "Other database" }] }),
193
+ ]);
194
+ expect(result).toBeNull();
195
+ });
169
196
  });
170
197
 
171
198
  // ── options 字符串「下沉」机制集成证明 ──────────────────
@@ -13,7 +13,7 @@
13
13
  // (key=header/question,单选=string,多选=JSON 数组,Other→__other),让子进程 decode 一致。
14
14
  //
15
15
  // handler 收到的 req.channelPayload = {questions: AskUserQuestion[], allowCancel}(proto 格式,
16
- // 由子进程 askUserInteract 编码、packages/subagent-core 的 parseChannel(execution/ui-channels.ts)
16
+ // 由子进程 askUserInteract 编码、packages/subagent-core 的 parseChannel(execution/ui/ui-channels.ts)
17
17
  // 解析 options[0] JSON 得到)。
18
18
 
19
19
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -29,13 +29,13 @@ import { type AnswerValue, type Option, type Question, type Result, type ThemeLi
29
29
 
30
30
  /**
31
31
  * channel handler 签名——与 packages/subagent-core 的 UiChannelRegistry.ChannelHandler 一致
32
- *(execution/ui-channels.ts 定义,(req: unknown) => Promise<unknown>)。本文件不静态 import
32
+ *(execution/ui/ui-channels.ts 定义,(req: unknown) => Promise<unknown>)。本文件不静态 import
33
33
  * packages/subagent-core(host 侧包,非本包依赖——两侧扩展经 globalThis slot 握手协作,
34
34
  * 见 index.ts 工厂注释);handler 签名用本地等价类型,运行时结构兼容。
35
35
  */
36
36
  export type ChannelHandler = (req: unknown) => Promise<unknown>;
37
37
 
38
- /** handler 返回给 packages/subagent-core 的 UiResponse 形状(execution/dialog-queue.ts 定义)。
38
+ /** handler 返回给 packages/subagent-core 的 UiResponse 形状(execution/ui/dialog-queue.ts 定义)。
39
39
  * - {value}: select 的回传值(子进程 JSON.parse(value) 得 answers)
40
40
  * - {cancelled}: 用户取消 / 子进程 close / handler 抛错 */
41
41
  type ChannelResponse = { value: string } | { cancelled: true };
@@ -77,7 +77,8 @@ function protoToInternalQuestions(protoQuestions: AskUserQuestion[]): Question[]
77
77
  * - 多选:value = JSON.stringify(选中项 label 数组)
78
78
  * - Other 自由文本:单独 key `${header}__other`
79
79
  *
80
- * 序列化走 encodeAnswer(answer-codec.ts 是唯一 encode 实现,与协议包解码 helper 对齐)。
80
+ * 序列化走 encodeAnswer(answer-codec.ts 是本扩展内的唯一 encode 实现,与协议包解码
81
+ * helper 对齐;renderer 前端组件无法 import extension 包,独立实现对齐同一解码契约)。
81
82
  */
82
83
  function encodeTuiResultToProto(
83
84
  protoQuestions: AskUserQuestion[],
@@ -4,7 +4,7 @@
4
4
  //
5
5
  // 设计动机(PR #85 #M4 修复):原实现 ask-user 先 session_start 时会自建简化 Map-based
6
6
  // registry 占据 canonical 槽位,劫持后续 getOrCreateChannelRegistry 拿到的实例
7
- // (packages/subagent-core 的 createUiChannelRegistry(execution/ui-channels.ts)才是
7
+ // (packages/subagent-core 的 createUiChannelRegistry(execution/ui/ui-channels.ts)才是
8
8
  // canonical——它带排队、dialog 队列等完整能力)。修复后的握手协议改为「带 version 的 slot」:
9
9
  // - 承载 subagent-core 的 subagent-workflow 扩展 session_start 时往 slot 写
10
10
  // {version, registry, pending:[]}(registry 由 subagent-core 创建)
@@ -23,7 +23,7 @@ const logger = getLogger("ask-user");
23
23
  /**
24
24
  * 进程级 channel registry 握手的 globalThis key(Symbol.for 跨模块共享)。
25
25
  *
26
- * ⚠️ 必须与 packages/subagent-core/src/execution/channel-registry-access.ts 的字面量
26
+ * ⚠️ 必须与 packages/subagent-core/src/execution/assembly/channel-registry-access.ts 的字面量
27
27
  * 完全一致——两边用同一字符串确保拿到同一 slot 实例。改名必须两侧同步。
28
28
  */
29
29
  export const CHANNEL_HANDSHAKE_KEY = Symbol.for(
@@ -38,7 +38,7 @@ const ASK_USER_CHANNEL = "ask_user";
38
38
 
39
39
  /**
40
40
  * channel registry 的本地等价接口(与 packages/subagent-core 的 UiChannelRegistry 形状一致,
41
- * execution/ui-channels.ts 定义)。本模块不静态 import packages/subagent-core(host 侧包,
41
+ * execution/ui/ui-channels.ts 定义)。本模块不静态 import packages/subagent-core(host 侧包,
42
42
  * 非本包依赖——两侧经 globalThis slot 握手协作);运行时结构兼容即可。
43
43
  */
44
44
  interface ChannelRegistry {
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  getAskUserAnswer,
10
10
  getAskUserOther,
11
11
  } from "@xyz-agent/extension-protocol";
12
+ import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
12
13
 
13
14
  import { createAskUserChannelHandler } from "./channel-handler";
14
15
  import { registerAskUserChannelHandler } from "./channel-registry-register";
@@ -211,7 +212,7 @@ export default function (pi: ExtensionAPI): void {
211
212
  // 注册 ask_user channel handler:把 subagent 子进程的 ask_user 请求透传到主进程 UI。
212
213
  //
213
214
  // 跨扩展握手协议(PR #85 #M4):通过 globalThis Symbol.for 约定 slot 形状
214
- //(CHANNEL_HANDSHAKE_KEY,与 packages/subagent-core/src/execution/channel-registry-access.ts
215
+ //(CHANNEL_HANDSHAKE_KEY,与 packages/subagent-core/src/execution/assembly/channel-registry-access.ts
215
216
  // 用同一字符串 key),不依赖 dynamic import npm 包名(两个扩展都通过
216
217
  // ~/.pi/agent/extensions/ symlink 加载,互相之间无法用 npm 包名 import)。
217
218
  //
@@ -220,7 +221,7 @@ export default function (pi: ExtensionAPI): void {
220
221
  // 2. slot.registry 就绪(承载 packages/subagent-core 的 subagent-workflow 扩展先到)→ 直接调 registry.register
221
222
  // 3. slot.registry 未就绪 → handler 入 pending,等 subagent-core flush
222
223
  // ask-user 永不创建 registry 实例——canonical registry 仅 packages/subagent-core 创建
223
- //(execution/ui-channels.ts 的 createUiChannelRegistry)。
224
+ //(execution/ui/ui-channels.ts 的 createUiChannelRegistry)。
224
225
  pi.on("session_start", (_event, ctx) => {
225
226
  registerAskUserChannelHandler(createAskUserChannelHandler(ctx));
226
227
  });
@@ -307,7 +308,7 @@ Don't:
307
308
  // RPC 通道不可用(真 headless / select 缺失)→ 禁用工具(spec FR-8)。
308
309
  // TUI 分支不禁用——custom 抛错通常是组件临时故障,允许 LLM 重试。
309
310
  // 禁用收尾先行,再 throw(W4):pi catch 后文案原样成为 toolResult content。
310
- const message = err instanceof Error ? err.message : String(err);
311
+ const message = toErrorMessage(err);
311
312
  if (useRpc) disableAskUser(pi);
312
313
  throw new Error(
313
314
  useRpc
package/src/validate.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/validate.ts
2
- import { HEADER_MAX_CHARS, type InputQuestion, QUESTION_MAX_CHARS } from "./types";
2
+ import { HEADER_MAX_CHARS, type InputQuestion, OTHER_LABEL, QUESTION_MAX_CHARS } from "./types";
3
3
 
4
4
  /** 控制字符(含 \n \r \t 等):question 文本禁止包含,避免 answers key 含不可见字符(spec FR-2) */
5
5
  const CONTROL_CHAR_RE = /[\x00-\x1f\x7f]/;
@@ -18,6 +18,8 @@ const ERROR_PREVIEW_CHARS = 20;
18
18
  * - options 元素必须是 {label, description} 对象,不能是 string(弱模型高频误用)
19
19
  * - question 文本长度上限与无控制字符(保证 answers key 有界、可预测)
20
20
  * - question 文本在数组内唯一
21
+ * - option label 不得为保留字 Other(trim 后精确匹配)——Other 自由输入行由 extension
22
+ * 自动追加,LLM 自带会在渲染层产生同名双行(M23)
21
23
  * - 同问题内 option label 唯一
22
24
  * - 多问题(questions.length > 1)时每个 question 必须有非空 header
23
25
  *
@@ -77,6 +79,12 @@ function checkOptionLabels(qt: string, options: InputQuestion["options"]): strin
77
79
  if (opt.label.trim() === "") {
78
80
  return `Option label must not be empty in question "${qt}". Give every option a distinct, descriptive label.`;
79
81
  }
82
+ // 保留字 Other(trim 口径:带空白变体与合成 Other 行视觉不可分辨,同样要拦)——
83
+ // 两条渲染路径都会无条件追加合成 Other 自由输入行,LLM 自带的 "Other" 选项会与它
84
+ // 同名并存:一行是普通选项、一行是自由输入,UI 自相矛盾且答案通道歧义(M23)。
85
+ if (opt.label.trim() === OTHER_LABEL) {
86
+ return `Option label "${OTHER_LABEL}" is reserved in question "${qt}" — the Other free-text option is added automatically. Remove it; to offer a catch-all choice, rely on the built-in Other, or rename the option (e.g. "Other database").`;
87
+ }
80
88
  if (seenLabels.has(opt.label)) {
81
89
  return `Duplicate option label "${opt.label}" in question "${qt}". Options must be mutually exclusive — reword one so each label maps to a distinct choice.`;
82
90
  }