@zhushanwen/pi-ask-user 7.0.16 → 7.1.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 +7 -7
- package/README.md +17 -20
- package/package.json +4 -4
- package/src/__tests__/e2e.test.ts +1 -1
- package/src/__tests__/index.test.ts +2 -11
- package/src/channel-handler.ts +9 -8
- package/src/channel-registry-register.ts +15 -13
- package/src/editor-ops.ts +0 -2
- package/src/index.ts +7 -8
- package/src/types.ts +6 -7
package/ARCHITECTURE.md
CHANGED
|
@@ -69,14 +69,14 @@ Encoding is **one-way** — the old `parseAnswerParts` text reverse-parsing was
|
|
|
69
69
|
|
|
70
70
|
| Step | Check | Returns | Why this order |
|
|
71
71
|
|------|-------|---------|----------------|
|
|
72
|
-
| 1 | `validateInput(questions)` | `isError:true` + fix hint | Pure function, no side effects — cheapest gate. Reject before any UI/state work. |
|
|
73
|
-
| 2 |
|
|
72
|
+
| 1 | `validateInput(questions)` | `throw` (Pi marks `isError:true`, empty `details`) + fix hint | Pure function, no side effects — cheapest gate. Reject before any UI/state work. |
|
|
73
|
+
| 2 | `ctx.mode !== "tui" && ctx.mode !== "rpc"` (headless) | `throw` + **`setActiveTools` removes ask_user** | Must run before the agent can retry. Physically removing the tool breaks a function-calling retry loop that a thrown error cannot. |
|
|
74
74
|
| 3 | `signal?.aborted` | `cancelled:true` | O(1) short-circuit before the expensive blocking `ctx.ui.custom` call. |
|
|
75
|
-
| 4 | `try { ctx.ui.custom(...) } catch` | `
|
|
75
|
+
| 4 | `try { ctx.ui.custom(...) } catch` | `throw` (Pi marks `isError:true`, empty `details`) | `ctx.ui.custom` is the only call that runs user interaction / editor construction / theme reads — the largest blast radius, so it is the only thing wrapped. |
|
|
76
76
|
| 5 | `result === null \|\| result.cancelled` | `cancelled:true` | Component resolved to cancel. |
|
|
77
77
|
| 6 | normal | `{ answers }` | Compose the summary. |
|
|
78
78
|
|
|
79
|
-
**The order is load-bearing**: swapping 1↔2 wastes a UI check on invalid params; swapping 2↔3 lets an aborted agent enter a blocking UI; moving 4's try/catch wider catches nothing extra. The headless branch's `setActiveTools` is the key insight —
|
|
79
|
+
**The order is load-bearing**: swapping 1↔2 wastes a UI check on invalid params; swapping 2↔3 lets an aborted agent enter a blocking UI; moving 4's try/catch wider catches nothing extra. Business outcomes (answers / cancellation) are returned as normal results — only validation failures and unexpected exceptions `throw` (Pi converts a throw into an `isError:true` tool result with empty `details`). The headless branch's `setActiveTools` is the key insight — even an `isError` tool result does not stop an LLM from calling the tool again in the same turn, so the tool is removed from the session's active set and the error text says "do not retry".
|
|
80
80
|
|
|
81
81
|
## `QuestionState` machine
|
|
82
82
|
|
|
@@ -122,7 +122,7 @@ If you add a new path that changes the answer set, audit both directions of this
|
|
|
122
122
|
|
|
123
123
|
### `autoConfirmIfAnswered` trigger
|
|
124
124
|
|
|
125
|
-
Called only from `gotoTab()` — when the user navigates between tabs via Tab/Shift+Tab
|
|
125
|
+
Called only from `gotoTab()` — when the user navigates between tabs via `←/→` (Tab is not a tab-navigation key: on the Submit tab it toggles Submit/Cancel focus, and `Shift+Tab` is deliberately unused because Pi's global `app.thinking.cycle` intercepts it). It promotes an implicitly-answered tab (toggled but not confirmed) to `confirmed`. A navigation intent should not force a confirm prompt — only the Enter path confirms via `afterConfirm`.
|
|
126
126
|
|
|
127
127
|
## Race guards
|
|
128
128
|
|
|
@@ -132,7 +132,7 @@ Three independent guards protect against three different races. They are dimensi
|
|
|
132
132
|
|-------|------|----------|----------|-----------|
|
|
133
133
|
| `_resolved` | `boolean` field | `component.ts` | **Double `done()`**: user already submitted/cancelled, then a signal-abort listener or a late keypress fires `done` again → Pi receives two resolves. | `submit()`/`cancel()` set `_resolved = true` before `done(...)`; **both `handleInput` and `cancel()` itself early-return if already set** — so a signal-abort firing after resolution (the listener calls `comp.cancel()`) is a no-op (see `execute` step 4). |
|
|
134
134
|
| `pendingCancel` | `boolean` field | `component.ts` | **Accidental cancel losing answers**: Esc on the first question (or single question) cancelling outright would discard everything. | Two-step confirm: first Esc sets `pendingCancel = true` and shows an overlay; a second Esc truly cancels; any other key exits the overlay and keeps the form. The Submit-tab Cancel button bypasses this (already at the terminus). |
|
|
135
|
-
| `autoConfirmIfAnswered` | **method** (not a field) | `component.ts` | **Zombie unanswered tab**: in multi-question mode, toggling an option then
|
|
135
|
+
| `autoConfirmIfAnswered` | **method** (not a field) | `component.ts` | **Zombie unanswered tab**: in multi-question mode, toggling an option then navigating away (`←/→`) leaves a tab "answered but not confirmed", so the Submit gate (`allConfirmed()`) stays false and the user cannot tell why Submit is blocked. | `gotoTab()` calls it before switching; if the current state has an answer but `!confirmed`, it sets `confirmed = true`. |
|
|
136
136
|
|
|
137
137
|
## Three-layer rendering
|
|
138
138
|
|
|
@@ -164,7 +164,7 @@ Constants: `SPLIT_PANE_MIN_WIDTH = 84`, `SPLIT_PANE_LEFT_MIN = 32`, `SPLIT_PANE_
|
|
|
164
164
|
|
|
165
165
|
## Spec cross-reference
|
|
166
166
|
|
|
167
|
-
|
|
167
|
+
The original spec files (`.xyz-harness/2026-06-15-ask-user/`) are no longer in the repo — this table is self-contained (FR = functional requirement from the original spec). Implementation anchors:
|
|
168
168
|
|
|
169
169
|
| Spec | Implemented in |
|
|
170
170
|
|------|----------------|
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @zhushanwen/pi-ask-user
|
|
2
2
|
|
|
3
|
-
Inline adaptive `ask_user` tool for the Pi coding agent. Resolves ambiguity the agent cannot resolve itself — a single question (no tab bar) or 1-4 questions (tabbed view + submit), with split-pane option preview on wide terminals
|
|
3
|
+
Inline adaptive `ask_user` tool for the Pi coding agent. Resolves ambiguity the agent cannot resolve itself — a single question (no tab bar) or 1-4 questions (tabbed view + submit), with split-pane option preview on wide terminals and an inline free-text editor. There is no comment feature: the free-text "Other" editor is the only free-form input.
|
|
4
4
|
|
|
5
5
|
The tool's primary caller is the LLM. This README covers both **how an agent should use it** (top sections) and **how a maintainer reads the code** (File structure → Design notes).
|
|
6
6
|
|
|
@@ -46,7 +46,6 @@ If you recommend an option, prefix its label with `(Recommended)` and list it fi
|
|
|
46
46
|
description?: string; // short rationale shown under the label and in the preview pane
|
|
47
47
|
}>;
|
|
48
48
|
multiSelect?: boolean; // default false; true only when several options can validly apply
|
|
49
|
-
allowComment?: boolean; // default false; lets the user append a free-text note after selecting
|
|
50
49
|
}>
|
|
51
50
|
} // questions: 1-4 entries
|
|
52
51
|
```
|
|
@@ -61,7 +60,7 @@ If you recommend an option, prefix its label with `(Recommended)` and list it fi
|
|
|
61
60
|
| `header` | ≤12 chars; required when `questions.length > 1` | `validate.ts` (length + non-empty) |
|
|
62
61
|
| `options[].label` | non-empty, unique within the question | `validate.ts` |
|
|
63
62
|
|
|
64
|
-
Validation
|
|
63
|
+
Validation failures make the tool `throw` with a message that names the violation and tells you how to fix it — correct the parameters and retry. (Pi converts a thrown error into an `isError: true` tool result; business outcomes like answers and cancellation are returned normally.)
|
|
65
64
|
|
|
66
65
|
## Result format
|
|
67
66
|
|
|
@@ -69,8 +68,7 @@ On success the tool returns the answers joined as `"question" = "answer"` lines.
|
|
|
69
68
|
|
|
70
69
|
- **Single-select**: the chosen `label`.
|
|
71
70
|
- **Multi-select**: selected labels joined with `, ` (e.g. `A, B`).
|
|
72
|
-
- **Free-text (Other)**: whatever the user typed.
|
|
73
|
-
- **Comment**: if `allowComment` was set, the user's note is appended after ` — ` (e.g. `Postgres — needs TLS`).
|
|
71
|
+
- **Free-text (Other)**: whatever the user typed (appended to the selection with `, ` when both exist).
|
|
74
72
|
|
|
75
73
|
A question with no answer reports as `(no answer)`.
|
|
76
74
|
|
|
@@ -78,11 +76,13 @@ A question with no answer reports as `(no answer)`.
|
|
|
78
76
|
|
|
79
77
|
| Situation | Return | What the agent should do |
|
|
80
78
|
|-----------|--------|--------------------------|
|
|
81
|
-
| Parameter validation fails | `isError: true` + fix hint | Correct params and retry |
|
|
82
|
-
| No interactive UI (headless) | `
|
|
79
|
+
| Parameter validation fails | `throw` (Pi shows it as `isError: true`) + fix hint | Correct params and retry |
|
|
80
|
+
| No interactive UI (headless) | `throw`, tool **disabled for the session** | Proceed with a defensible decision stated in text, or wait for the user — **do not retry** |
|
|
83
81
|
| Agent aborted (goal cancelled / context compacted) | `cancelled: true` | The text identifies it as an agent abort, not a user cancel. Do not assume an answer; do not retry ask_user — propagate the abort, or wait for new instructions if the decision is still required. |
|
|
84
82
|
| User cancels (Esc → confirm, or Cancel button) | `cancelled: true` | Wait for new instructions, or re-ask with refined options if the decision is still required |
|
|
85
|
-
| Unexpected error | `
|
|
83
|
+
| Unexpected error during interaction | `throw` (Pi shows it as `isError: true`) | Retry once with corrected parameters, or proceed with a defensible decision |
|
|
84
|
+
|
|
85
|
+
Business outcomes (answers / cancellation) are returned as normal results; only validation failures and unexpected exceptions `throw` — Pi marks a thrown error `isError: true` with empty `details`.
|
|
86
86
|
|
|
87
87
|
The headless branch physically removes the tool from the session (`setActiveTools`) — this is deliberate, so a function-calling loop cannot keep retrying `ask_user` in a non-interactive context.
|
|
88
88
|
|
|
@@ -90,11 +90,10 @@ The headless branch physically removes the tool from the session (`setActiveTool
|
|
|
90
90
|
|
|
91
91
|
- **Adaptive layout**: single question → no tab bar; 1-4 questions → tabbed view + Submit tab.
|
|
92
92
|
- **Split-pane preview** (≥84 cols): option list left, selected option detail right. The right pane is **plain-text** option detail (label + description), not a Markdown renderer.
|
|
93
|
-
- **Inline free-text editor**: select "Other" → Enter → type a custom answer. Multi-line aware, soft-wrapped.
|
|
94
|
-
- **Optional comments**: `allowComment: true` → after selecting, the user may append a short note.
|
|
93
|
+
- **Inline free-text editor**: select "Other" → Enter → type a custom answer. Multi-line aware, soft-wrapped. No comment mode — this editor is the only free-form input.
|
|
95
94
|
- **Multi-select**: `multiSelect: true` → toggle checkboxes with Space, Enter to confirm.
|
|
96
95
|
- **Esc confirm-to-cancel**: Esc on the first question opens a confirm overlay (a second Esc cancels; any other key stays).
|
|
97
|
-
- **Headless-safe**: disables the tool and
|
|
96
|
+
- **Headless-safe**: disables the tool and throws when no UI is available.
|
|
98
97
|
|
|
99
98
|
## File structure
|
|
100
99
|
|
|
@@ -111,7 +110,11 @@ extensions/universal/ask-user/
|
|
|
111
110
|
├── validate.ts # pure parameter validation; error messages aimed at LLM fixability
|
|
112
111
|
├── component.ts # AskUserComponent: state machine, input routing, race guards
|
|
113
112
|
├── question-view.ts # pure render: option list, split-pane, inline editor
|
|
114
|
-
|
|
113
|
+
├── submit-view.ts # pure render: Submit tab, answer summary, buildResult
|
|
114
|
+
├── answer-codec.ts # pure encode: AnswerValue → proto answers entries (protocol boundary)
|
|
115
|
+
├── channel-handler.ts # subagent channel passthru: RPC forward / TUI re-render → proto answers
|
|
116
|
+
├── channel-registry-register.ts # globalThis Symbol slot handshake (handler registration)
|
|
117
|
+
└── editor-ops.ts # pure editor ops: insert/delete/cursor/paste (UTF-16 surrogate aware)
|
|
115
118
|
```
|
|
116
119
|
|
|
117
120
|
`types.ts` is intentionally the shared dependency leaf — it holds `QuestionState`/`ThemeLike` (not `component.ts`) so the two pure-render views depend only on the leaf, breaking a would-be `component → view → component` cycle. See ARCHITECTURE.md for the full graph.
|
|
@@ -130,18 +133,12 @@ All three are consistent and point the same direction. If you tune behavior, edi
|
|
|
130
133
|
|
|
131
134
|
- **Why inline, not overlay** (`execute` → `ctx.ui.custom` without `options`): the question belongs in the conversation flow, not a modal that obscures context.
|
|
132
135
|
- **Why `Other` is auto-appended, not in the schema**: free-text input is the user's escape hatch and must not be something the LLM can omit or mislabel. Keeping it out of `options` guarantees it is always present and always last.
|
|
133
|
-
- **Why `←/→` does not
|
|
136
|
+
- **Why `←/→` switches tabs but `Tab` does not**: arrow keys move between question tabs (and wrap on the Submit tab), so tab navigation never conflicts with the option-list cursor (`↑/↓`) or text editing. On the Submit tab, `Tab` alone toggles focus between Submit and Cancel — a deliberate single-key bidirectional toggle; `Shift+Tab` is intentionally unused because Pi's global `app.thinking.cycle` intercepts it.
|
|
134
137
|
- **Why validation messages are verbose**: every message names the violation and gives a fix path, because the reader is an LLM that will retry.
|
|
135
138
|
|
|
136
139
|
## Spec reference
|
|
137
140
|
|
|
138
|
-
The original design spec,
|
|
139
|
-
|
|
140
|
-
- `spec.md` — requirements + functional/acceptance criteria
|
|
141
|
-
- `e2e-test-cases.md` — end-to-end scenarios
|
|
142
|
-
- `clarification.md` / `plan.md` — design rationale
|
|
143
|
-
|
|
144
|
-
Cross-references between these and the implementation are in ARCHITECTURE.md.
|
|
141
|
+
The original design spec (requirements, functional requirements FR-x, acceptance criteria AC-x, E2E test cases) predates this repository — the original spec files are no longer in the repo. The FR cross-reference table in ARCHITECTURE.md is self-contained: each entry names the behavior and where it is implemented in this codebase.
|
|
145
142
|
|
|
146
143
|
## License
|
|
147
144
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-ask-user",
|
|
3
|
-
"version": "7.0
|
|
4
|
-
"description": "Inline adaptive ask_user tool for Pi — single/multi-question structured input with split-pane preview
|
|
3
|
+
"version": "7.1.0",
|
|
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",
|
|
7
7
|
"xyz-agent": {
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
"ARCHITECTURE.md"
|
|
30
30
|
],
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@xyz-agent/extension-protocol": "0.
|
|
33
|
-
"@zhushanwen/pi-extension-logger": "0.
|
|
32
|
+
"@xyz-agent/extension-protocol": "0.8.0",
|
|
33
|
+
"@zhushanwen/pi-extension-logger": "0.4.0"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"@earendil-works/pi-tui": "^0.84.4",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/__tests__/e2e.test.ts
|
|
2
2
|
// E2E test cases for ask_user. Drives tool.execute() → real AskUserComponent
|
|
3
3
|
// → simulated keypresses → asserts on final execute() result contract.
|
|
4
|
-
//
|
|
4
|
+
// (原始 spec 文件已不在仓内——用例自包含,编号 E2E-x 沿用原始 spec 命名)
|
|
5
5
|
|
|
6
6
|
import { describe, expect, it } from "vitest";
|
|
7
7
|
|
|
@@ -453,17 +453,8 @@ describe("renderCall / renderResult (FR-9)", () => {
|
|
|
453
453
|
expect(renderText(node)).toContain("Cancelled");
|
|
454
454
|
});
|
|
455
455
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
const node = tool.renderResult(
|
|
459
|
-
{ details: { error: "something broke" } },
|
|
460
|
-
{ expanded: false },
|
|
461
|
-
stubTheme,
|
|
462
|
-
) as unknown as { render(width: number): string[] };
|
|
463
|
-
const text = renderText(node);
|
|
464
|
-
expect(text).toContain("✗");
|
|
465
|
-
expect(text).toContain("something broke");
|
|
466
|
-
});
|
|
456
|
+
// (I-19 已随 ErrorDetails 死类型删除:错误路径全部 throw(W4),pi 置 isError 后
|
|
457
|
+
// details 为空对象,renderResult 不存在 {error} details 分支可渲染)
|
|
467
458
|
|
|
468
459
|
// S-3: options.expanded 展开 —— 显示全部选项 + ●/○ 选中标记(spec FR-9)
|
|
469
460
|
it("I-20: renderResult expanded shows all options with ●/○ marks", () => {
|
package/src/channel-handler.ts
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
// (key=header/question,单选=string,多选=JSON 数组,Other→__other),让子进程 decode 一致。
|
|
14
14
|
//
|
|
15
15
|
// handler 收到的 req.channelPayload = {questions: AskUserQuestion[], allowCancel}(proto 格式,
|
|
16
|
-
// 由子进程 askUserInteract 编码、subagent-
|
|
16
|
+
// 由子进程 askUserInteract 编码、packages/subagent-core 的 parseChannel(execution/ui-channels.ts)
|
|
17
|
+
// 解析 options[0] JSON 得到)。
|
|
17
18
|
|
|
18
19
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
19
20
|
import {
|
|
@@ -27,20 +28,20 @@ import { encodeAnswer } from "./answer-codec";
|
|
|
27
28
|
import { type AnswerValue, type Option, type Question, type Result, type ThemeLike } from "./types";
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
|
-
* channel handler 签名——与 subagent-
|
|
31
|
-
*((req: unknown) => Promise<unknown>)。本文件不静态 import
|
|
32
|
-
*
|
|
33
|
-
*
|
|
31
|
+
* channel handler 签名——与 packages/subagent-core 的 UiChannelRegistry.ChannelHandler 一致
|
|
32
|
+
*(execution/ui-channels.ts 定义,(req: unknown) => Promise<unknown>)。本文件不静态 import
|
|
33
|
+
* packages/subagent-core(host 侧包,非本包依赖——两侧扩展经 globalThis slot 握手协作,
|
|
34
|
+
* 见 index.ts 工厂注释);handler 签名用本地等价类型,运行时结构兼容。
|
|
34
35
|
*/
|
|
35
36
|
export type ChannelHandler = (req: unknown) => Promise<unknown>;
|
|
36
37
|
|
|
37
|
-
/** handler 返回给 subagent-
|
|
38
|
+
/** handler 返回给 packages/subagent-core 的 UiResponse 形状(execution/dialog-queue.ts 定义)。
|
|
38
39
|
* - {value}: select 的回传值(子进程 JSON.parse(value) 得 answers)
|
|
39
40
|
* - {cancelled}: 用户取消 / 子进程 close / handler 抛错 */
|
|
40
41
|
type ChannelResponse = { value: string } | { cancelled: true };
|
|
41
42
|
|
|
42
43
|
/** handler 收到的 req 形状收窄(ChannelHandler 签名是 unknown,按形状 as 收窄)。
|
|
43
|
-
* channelPayload 由 subagent-
|
|
44
|
+
* channelPayload 由 packages/subagent-core 的 parseChannel 填充。 */
|
|
44
45
|
interface ChannelRequest {
|
|
45
46
|
channelPayload?: { questions?: AskUserQuestion[]; allowCancel?: boolean };
|
|
46
47
|
}
|
|
@@ -138,7 +139,7 @@ async function runTuiProtoInteraction(
|
|
|
138
139
|
*/
|
|
139
140
|
export function createAskUserChannelHandler(ctx: ExtensionContext): ChannelHandler {
|
|
140
141
|
return async (req: unknown): Promise<unknown> => {
|
|
141
|
-
// req 正常是 subagent-
|
|
142
|
+
// req 正常是 packages/subagent-core 构造的 UiRequest 对象;防御性收窄 null/undefined/
|
|
142
143
|
// 非 object(handler 抛错会被 dialog-queue 兜底为 {cancelled:true},但这里直接返回更干净)
|
|
143
144
|
if (req === null || typeof req !== "object") {
|
|
144
145
|
return { cancelled: true } satisfies ChannelResponse;
|
|
@@ -3,14 +3,16 @@
|
|
|
3
3
|
// ask-user 侧的 channel handler 握手注册纯函数。
|
|
4
4
|
//
|
|
5
5
|
// 设计动机(PR #85 #M4 修复):原实现 ask-user 先 session_start 时会自建简化 Map-based
|
|
6
|
-
// registry 占据 canonical
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// - subagent-workflow session_start 时往 slot 写
|
|
6
|
+
// registry 占据 canonical 槽位,劫持后续 getOrCreateChannelRegistry 拿到的实例
|
|
7
|
+
// (packages/subagent-core 的 createUiChannelRegistry(execution/ui-channels.ts)才是
|
|
8
|
+
// canonical——它带排队、dialog 队列等完整能力)。修复后的握手协议改为「带 version 的 slot」:
|
|
9
|
+
// - 承载 subagent-core 的 subagent-workflow 扩展 session_start 时往 slot 写
|
|
10
|
+
// {version, registry, pending:[]}(registry 由 subagent-core 创建)
|
|
10
11
|
// - ask-user session_start 时只读 slot,registry 就绪则调 registry.register,未就绪则 push pending
|
|
11
12
|
// - ask-user **永不**创建 registry 实例,**永不**写 slot.registry
|
|
12
13
|
//
|
|
13
|
-
// 这是 ask-user 侧的注册入口;canonical registry 由 subagent-
|
|
14
|
+
// 这是 ask-user 侧的注册入口;canonical registry 由 packages/subagent-core 创建(经
|
|
15
|
+
// channel-registry-access.ts 的 getOrCreateChannelRegistry + flush)。本模块永不创建
|
|
14
16
|
// registry 实例——仅往 slot 写 pending 或调 slot.registry.register。
|
|
15
17
|
|
|
16
18
|
import type { ChannelHandler } from "./channel-handler";
|
|
@@ -21,7 +23,7 @@ const logger = getLogger("ask-user");
|
|
|
21
23
|
/**
|
|
22
24
|
* 进程级 channel registry 握手的 globalThis key(Symbol.for 跨模块共享)。
|
|
23
25
|
*
|
|
24
|
-
* ⚠️ 必须与
|
|
26
|
+
* ⚠️ 必须与 packages/subagent-core/src/execution/channel-registry-access.ts 的字面量
|
|
25
27
|
* 完全一致——两边用同一字符串确保拿到同一 slot 实例。改名必须两侧同步。
|
|
26
28
|
*/
|
|
27
29
|
export const CHANNEL_HANDSHAKE_KEY = Symbol.for(
|
|
@@ -35,9 +37,9 @@ const HANDSHAKE_VERSION = 1;
|
|
|
35
37
|
const ASK_USER_CHANNEL = "ask_user";
|
|
36
38
|
|
|
37
39
|
/**
|
|
38
|
-
* channel registry 的本地等价接口(与 subagent-
|
|
39
|
-
*
|
|
40
|
-
*
|
|
40
|
+
* channel registry 的本地等价接口(与 packages/subagent-core 的 UiChannelRegistry 形状一致,
|
|
41
|
+
* execution/ui-channels.ts 定义)。本模块不静态 import packages/subagent-core(host 侧包,
|
|
42
|
+
* 非本包依赖——两侧经 globalThis slot 握手协作);运行时结构兼容即可。
|
|
41
43
|
*/
|
|
42
44
|
interface ChannelRegistry {
|
|
43
45
|
register(channel: string, handler: ChannelHandler): void;
|
|
@@ -45,7 +47,7 @@ interface ChannelRegistry {
|
|
|
45
47
|
list(): string[];
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
/** pending 队列元素:channel + handler。subagent-
|
|
50
|
+
/** pending 队列元素:channel + handler。packages/subagent-core flush(channel-registry-access.ts)时遍历调用 registry.register。 */
|
|
49
51
|
interface PendingEntry {
|
|
50
52
|
channel: string;
|
|
51
53
|
handler: ChannelHandler;
|
|
@@ -55,9 +57,9 @@ interface PendingEntry {
|
|
|
55
57
|
* globalThis slot 的形状。
|
|
56
58
|
*
|
|
57
59
|
* - `version`:握手协议版本(运行时校验,不兼容则丢弃重建)
|
|
58
|
-
* - `registry`:canonical 实例,**仅 subagent-
|
|
60
|
+
* - `registry`:canonical 实例,**仅 packages/subagent-core 创建**;缺失表示 registry 未就绪,
|
|
59
61
|
* ask-user 把 handler 入 pending 队列等待 flush
|
|
60
|
-
* - `pending`:未消费的注册请求(registry 就绪后由 subagent-
|
|
62
|
+
* - `pending`:未消费的注册请求(registry 就绪后由 packages/subagent-core 一次性 flush)
|
|
61
63
|
*/
|
|
62
64
|
export interface ChannelRegistryHandshake {
|
|
63
65
|
version: 1;
|
|
@@ -98,7 +100,7 @@ function ensureSlot(): ChannelRegistryHandshake {
|
|
|
98
100
|
* 3. slot 存在且 registry 就绪 → 直接调 registry.register("ask_user", handler)
|
|
99
101
|
*
|
|
100
102
|
* 多次调用幂等:registry 就绪时 register 同名覆盖;未就绪时 pending.length 增长
|
|
101
|
-
* (subagent-
|
|
103
|
+
* (packages/subagent-core flush 时一次性消费所有 pending)。
|
|
102
104
|
*
|
|
103
105
|
* @param handler ask_user channel handler(createAskUserChannelHandler 产出)
|
|
104
106
|
*/
|
package/src/editor-ops.ts
CHANGED
|
@@ -10,8 +10,6 @@ import { isHighSurrogate, SURROGATE_PAIR_LEN, type QuestionState } from "./types
|
|
|
10
10
|
|
|
11
11
|
// ── 编辑器纯操作 ──
|
|
12
12
|
|
|
13
|
-
// ── 编辑器纯操作 ──
|
|
14
|
-
|
|
15
13
|
/** 在光标处插入文本,光标前移 text.length。 */
|
|
16
14
|
export function insertAtCursor(state: QuestionState, text: string): void {
|
|
17
15
|
state.draftText = state.draftText.slice(0, state.cursorIndex) + text + state.draftText.slice(state.cursorIndex);
|
package/src/index.ts
CHANGED
|
@@ -211,15 +211,16 @@ export default function (pi: ExtensionAPI): void {
|
|
|
211
211
|
// 注册 ask_user channel handler:把 subagent 子进程的 ask_user 请求透传到主进程 UI。
|
|
212
212
|
//
|
|
213
213
|
// 跨扩展握手协议(PR #85 #M4):通过 globalThis Symbol.for 约定 slot 形状
|
|
214
|
-
//(CHANNEL_HANDSHAKE_KEY,与 subagent-
|
|
214
|
+
//(CHANNEL_HANDSHAKE_KEY,与 packages/subagent-core/src/execution/channel-registry-access.ts
|
|
215
215
|
// 用同一字符串 key),不依赖 dynamic import npm 包名(两个扩展都通过
|
|
216
216
|
// ~/.pi/agent/extensions/ symlink 加载,互相之间无法用 npm 包名 import)。
|
|
217
217
|
//
|
|
218
218
|
// 握手流程(registerAskUserChannelHandler 内部完成):
|
|
219
219
|
// 1. 读 slot;不存在或 version 不兼容 → 建 slot(仅 pending,**永不建 registry**)
|
|
220
|
-
// 2. slot.registry
|
|
221
|
-
// 3. slot.registry 未就绪 → handler 入 pending,等 subagent-
|
|
222
|
-
// ask-user 永不创建 registry 实例——canonical registry 仅 subagent-
|
|
220
|
+
// 2. slot.registry 就绪(承载 packages/subagent-core 的 subagent-workflow 扩展先到)→ 直接调 registry.register
|
|
221
|
+
// 3. slot.registry 未就绪 → handler 入 pending,等 subagent-core flush
|
|
222
|
+
// ask-user 永不创建 registry 实例——canonical registry 仅 packages/subagent-core 创建
|
|
223
|
+
//(execution/ui-channels.ts 的 createUiChannelRegistry)。
|
|
223
224
|
pi.on("session_start", (_event, ctx) => {
|
|
224
225
|
registerAskUserChannelHandler(createAskUserChannelHandler(ctx));
|
|
225
226
|
});
|
|
@@ -351,10 +352,8 @@ Don't:
|
|
|
351
352
|
theme: ThemeLike,
|
|
352
353
|
) {
|
|
353
354
|
const details = result.details;
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
}
|
|
357
|
-
// details 现已排除 ErrorDetails 分支,收窄为 Result | undefined
|
|
355
|
+
// 错误路径已全部改 throw(W4):pi 置 isError:true 后 details 为空对象,
|
|
356
|
+
// 不存在 ErrorDetails 形态——这里 details 要么 undefined 要么 Result
|
|
358
357
|
const d = details as Result | undefined;
|
|
359
358
|
if (!d || d.cancelled) {
|
|
360
359
|
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
package/src/types.ts
CHANGED
|
@@ -109,13 +109,12 @@ export const ResultSchema = Type.Object({
|
|
|
109
109
|
|
|
110
110
|
export type Result = Static<typeof ResultSchema>;
|
|
111
111
|
|
|
112
|
-
/**
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
export type AskUserDetails = Result | ErrorDetails;
|
|
112
|
+
/**
|
|
113
|
+
* execute 返回的 details 形状:正常/取消/校验失败都复用 Result。
|
|
114
|
+
* 错误路径已全部改为 throw(W4)——pi 对 throw 生成 isError:true 且 details 为空对象,
|
|
115
|
+
* 不存在「错误 details」形态;曾有的 ErrorDetails 死类型随不可达分支一并删除。
|
|
116
|
+
*/
|
|
117
|
+
export type AskUserDetails = Result;
|
|
119
118
|
|
|
120
119
|
// ── 跨模块共享的交互状态类型 ─────────────────────────
|
|
121
120
|
// 放这里(而非 component.ts)是为了让 question-view.ts / submit-view.ts
|