@zhushanwen/pi-permission 1.4.2 → 1.4.4

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/src/approval.ts CHANGED
@@ -20,7 +20,7 @@
20
20
  * TUI 分支因 pi-tui Input 组件集成成本较高,暂保留简化 deny(TODO 后续迭代)。
21
21
  */
22
22
 
23
- import { type Component, matchesKey, type SelectItem, truncateToWidth } from "@earendil-works/pi-tui";
23
+ import { type Component, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
24
24
 
25
25
  import type { ApprovalRequest } from "./pipeline.js";
26
26
  import type { ToolInvocationContext, UserDecision } from "./types.js";
@@ -198,20 +198,65 @@ function requestHeadless(req: ApprovalRequest, approvalCtx: ApprovalContext): Us
198
198
  return { approved: false, reason: `headless mode (${approvalCtx.mode}): cannot prompt, auto-deny` };
199
199
  }
200
200
 
201
- // ──────────────────────── 辅助:标题格式化 ────────────────────────
201
+ // ──────────────────────── 辅助:字段集内核 + 标题格式化 ────────────────────────
202
202
 
203
- /** 格式化审批标题(RPC select / TUI 顶部)。 */
203
+ /**
204
+ * E6(M6/D5):审批卡字段集(内核,唯一字段来源)。
205
+ *
206
+ * 「审批卡显示哪些内容」由 buildApprovalFieldSet 单点决定;两外壳只做排版——
207
+ * formatTitle(RPC)压紧凑行数组(AI 单行摘要 + reasoning 行),renderApprovalView
208
+ * (TUI)展开缩进块。排版是两外壳的形态差异,不属双写。
209
+ */
210
+ interface ApprovalFieldSet {
211
+ title: string;
212
+ tool: string;
213
+ command: string | undefined;
214
+ reason: string;
215
+ ai?: {
216
+ risk: string;
217
+ outcome: string;
218
+ confidence: number;
219
+ reasoning: string;
220
+ };
221
+ }
222
+
223
+ /** 构造审批卡字段集(title/tool/command/reason + 可选 AI 四元组含 reasoning)。 */
224
+ function buildApprovalFieldSet(req: ApprovalRequest): ApprovalFieldSet {
225
+ return {
226
+ title: "[pi-permission] Approval required",
227
+ tool: req.toolName,
228
+ // 空字符串与 undefined 同义(两外壳的 Command 行都只在非空时渲染)
229
+ command: req.command !== undefined && req.command.length > 0 ? req.command : undefined,
230
+ reason: req.reason,
231
+ ...(req.preClassification
232
+ ? {
233
+ ai: {
234
+ risk: req.preClassification.risk_level,
235
+ outcome: req.preClassification.outcome,
236
+ confidence: req.preClassification.confidence,
237
+ reasoning: req.preClassification.reasoning,
238
+ },
239
+ }
240
+ : {}),
241
+ };
242
+ }
243
+
244
+ /**
245
+ * 格式化审批标题(RPC select)。消费字段集,压成紧凑行数组(6 行)。
246
+ * M6 漂移修复:补 reasoning 行——此前 RPC 用户看不到 AI 理由(TUI 一直有)。
247
+ */
204
248
  function formatTitle(req: ApprovalRequest): string {
249
+ const fields = buildApprovalFieldSet(req);
205
250
  const lines: string[] = [];
206
- lines.push(`[pi-permission] Approval required`);
207
- lines.push(`Tool: ${req.toolName}`);
208
- if (req.command !== undefined && req.command.length > 0) {
209
- lines.push(`Command: ${req.command}`);
251
+ lines.push(fields.title);
252
+ lines.push(`Tool: ${fields.tool}`);
253
+ if (fields.command !== undefined) {
254
+ lines.push(`Command: ${fields.command}`);
210
255
  }
211
- lines.push(`Reason: ${req.reason}`);
212
- if (req.preClassification) {
213
- const pc = req.preClassification;
214
- lines.push(`AI: risk=${pc.risk_level} outcome=${pc.outcome} (conf=${pc.confidence})`);
256
+ lines.push(`Reason: ${fields.reason}`);
257
+ if (fields.ai) {
258
+ lines.push(`AI: risk=${fields.ai.risk} outcome=${fields.ai.outcome} (conf=${fields.ai.confidence})`);
259
+ lines.push(` reasoning: ${fields.ai.reasoning}`);
215
260
  }
216
261
  return lines.join("\n");
217
262
  }
@@ -260,11 +305,6 @@ export class ApprovalComponent implements Component {
260
305
  this.cachedLines = undefined;
261
306
  }
262
307
 
263
- private rerender(): void {
264
- this.invalidate();
265
- this.tui.requestRender();
266
- }
267
-
268
308
  render(width: number): string[] {
269
309
  if (this.cachedWidth === width && this.cachedLines) {
270
310
  return this.cachedLines;
@@ -316,25 +356,25 @@ export class ApprovalComponent implements Component {
316
356
  /** box 边框左右各占用 1 列(│ × 2) */
317
357
  const BORDER_OVERHEAD = 2;
318
358
 
319
- /** 渲染审批视图(纯函数,便于单测)。 */
359
+ /** 渲染审批视图(纯函数,便于单测)。消费字段集(E6 内核),排版为 TUI 展开块。 */
320
360
  export function renderApprovalView(req: ApprovalRequest, width: number): string[] {
361
+ const fields = buildApprovalFieldSet(req);
321
362
  const innerWidth = Math.max(0, width - BORDER_OVERHEAD); // 减去左右边框
322
363
  const inner: string[] = [];
323
- inner.push("[pi-permission] Approval required");
364
+ inner.push(fields.title);
324
365
  inner.push("");
325
- inner.push(`Tool: ${req.toolName}`);
326
- if (req.command !== undefined && req.command.length > 0) {
327
- inner.push(`Command: ${req.command}`);
366
+ inner.push(`Tool: ${fields.tool}`);
367
+ if (fields.command !== undefined) {
368
+ inner.push(`Command: ${fields.command}`);
328
369
  }
329
- inner.push(`Reason: ${req.reason}`);
330
- if (req.preClassification) {
331
- const pc = req.preClassification;
370
+ inner.push(`Reason: ${fields.reason}`);
371
+ if (fields.ai) {
332
372
  inner.push("");
333
373
  inner.push(`AI classification:`);
334
- inner.push(` risk: ${pc.risk_level}`);
335
- inner.push(` outcome: ${pc.outcome}`);
336
- inner.push(` confidence: ${pc.confidence}`);
337
- inner.push(` reasoning: ${pc.reasoning}`);
374
+ inner.push(` risk: ${fields.ai.risk}`);
375
+ inner.push(` outcome: ${fields.ai.outcome}`);
376
+ inner.push(` confidence: ${fields.ai.confidence}`);
377
+ inner.push(` reasoning: ${fields.ai.reasoning}`);
338
378
  }
339
379
  inner.push("");
340
380
  inner.push("[Enter] Approve [Esc] Deny");
@@ -349,6 +389,3 @@ export function renderApprovalView(req: ApprovalRequest, width: number): string[
349
389
  lines.push(`└${"─".repeat(innerWidth)}┘`);
350
390
  return lines;
351
391
  }
352
-
353
- // re-export SelectItem 仅为测试便利(mock ctx.ui.select 时构造选项)
354
- export type { SelectItem };
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import type { Node, Tree } from "web-tree-sitter";
22
+ import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
22
23
  import { getLogger } from "@zhushanwen/pi-extension-logger";
23
24
 
24
25
  import type { BashAnalysis } from "../types.js";
@@ -245,7 +246,7 @@ export async function analyzeBashStructure(command: string): Promise<BashAnalysi
245
246
  };
246
247
  } catch (err) {
247
248
  // 任何意外异常 → fail-closed
248
- const msg = err instanceof Error ? err.message : String(err);
249
+ const msg = toErrorMessage(err);
249
250
  logger.warn("analyzeBashStructure exception", { error: msg });
250
251
  return failClosed(msg || "EXCEPTION");
251
252
  } finally {
package/src/ast/loader.ts CHANGED
@@ -25,6 +25,7 @@ import { dirname, join } from "node:path";
25
25
  import { fileURLToPath } from "node:url";
26
26
 
27
27
  import { Language, Parser } from "web-tree-sitter";
28
+ import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
28
29
  import { getLogger } from "@zhushanwen/pi-extension-logger";
29
30
 
30
31
  const logger = getLogger("pi-permission/ast");
@@ -121,7 +122,7 @@ export function getBashParser(): Promise<Parser | null> {
121
122
  } catch (err) {
122
123
  // fail-closed:清空缓存让下次调用重试,warn 后返回 null。
123
124
  parserPromise = null;
124
- const msg = err instanceof Error ? err.message : String(err);
125
+ const msg = toErrorMessage(err);
125
126
  logger.warn("getBashParser init failed", { error: msg });
126
127
  return null;
127
128
  }
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import type { Api, Message, Model } from "@earendil-works/pi-ai";
21
+ import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
21
22
  import type { CallLLMOptions, CallLLMResult } from "@zhushanwen/pi-llm-shared";
22
23
 
23
24
  import type {
@@ -90,7 +91,7 @@ function raceResultWithDeadline(
90
91
  (result) => ({ kind: "ok" as const, result }),
91
92
  (error) => ({
92
93
  kind: "ok" as const,
93
- result: { ok: false as const, error: error instanceof Error ? error.message : String(error) },
94
+ result: { ok: false as const, error: toErrorMessage(error) },
94
95
  }),
95
96
  ),
96
97
  ];
@@ -229,7 +230,7 @@ export function createClassifier(deps: ClassifierDeps): {
229
230
  try {
230
231
  settled = await raceResultWithDeadline(callPromise, timeoutMs, signal);
231
232
  } catch (error) {
232
- const message = error instanceof Error ? error.message : String(error);
233
+ const message = toErrorMessage(error);
233
234
  onLog?.(`[pi-permission] classifier: callLLM race threw: ${message}`);
234
235
  return { ...CLASSIFY_FALLBACK_RESULT };
235
236
  }
@@ -1,22 +1,10 @@
1
1
  /**
2
- * AI Classifier barrel(层 3 入口)
2
+ * AI Classifier barrel(层 3 入口)——只暴露有真实消费的 2 符号(E10 收敛)。
3
3
  *
4
- * 对外暴露 createClassifier + ClassifierDeps + prompt/json-parser/model-resolver 辅助 API。
5
- * ClassifierResult / ClassifierConfig / ToolInvocationContext 从 ../types.js re-export,
6
- * 单一类型来源(types.ts)。
4
+ * 唯一生产消费者 production.ts:createClassifier(+ 装配所需的 ClassifierDeps 类型)。
5
+ * prompt / json-parser / model-resolver 的消费方与测试均走深路径 import;
6
+ * 类型一律直接从 ../types.js import,barrel 不做类型 re-export。
7
7
  */
8
8
 
9
9
  export type { ClassifierDeps } from "./classifier.js";
10
10
  export { createClassifier } from "./classifier.js";
11
- export { PARSE_FALLBACK_RESULT,parseClassifierResponse } from "./json-parser.js";
12
- export type { ListAvailableModelsCtx, ResolvedModelEntry } from "./model-resolver.js";
13
- export { listAvailableModels } from "./model-resolver.js";
14
- export { buildClassifierUserPrompt,CLASSIFIER_SYSTEM_PROMPT } from "./prompt.js";
15
-
16
- // 类型 re-export(单一来源:types.ts)
17
- export type {
18
- ClassifierConfig,
19
- ClassifierResult,
20
- RiskLevel,
21
- ToolInvocationContext,
22
- } from "../types.js";
@@ -8,6 +8,7 @@
8
8
  * hasConfiguredAuth() 过滤——用户只经 `pi auth login` 配的内置/OAuth provider 同样可见,
9
9
  * 不再自读 models.json(loadModelsJson / flattenModels 已删除)。
10
10
  * E1:ResolvedModelEntry.apiKey 死字段已删(P3 收口后凭证走 modelRegistry,无消费者)。
11
+ * E5:name/baseUrl/cost 死字段已删(picker 只读 id/api)。
11
12
  */
12
13
 
13
14
  import type { Api, Model } from "@earendil-works/pi-ai";
@@ -27,27 +28,17 @@ export interface ListAvailableModelsCtx {
27
28
 
28
29
  // ──────────────────────── 条目类型 ────────────────────────
29
30
 
30
- /** 单个 model 的 cost 结构(与 pi-ai Model.cost 同形;缺失时填零默认) */
31
- interface ModelCost {
32
- input: number;
33
- output: number;
34
- cacheRead: number;
35
- cacheWrite: number;
36
- }
37
-
38
31
  /**
39
32
  * 解析后的「扁平 model 条目」:附带其 provider 名。
40
33
  *
41
34
  * listAvailableModels 的中间表示,用于 picker 分组展示。
42
- * 注:hasApiKey 语义已不存在(E2 后用 hasConfiguredAuth 过滤,能进列表即已配 auth)。
35
+ * 注:hasApiKey 语义已不存在(E2 后用 hasConfiguredAuth 过滤,能进列表即已配 auth);
36
+ * E5:name/baseUrl/cost 死字段已删(picker 只读 id/api,生产零读者)。
43
37
  */
44
38
  export interface ResolvedModelEntry {
45
39
  provider: string;
46
40
  id: string;
47
- name: string;
48
41
  api: string;
49
- baseUrl?: string;
50
- cost: ModelCost;
51
42
  }
52
43
 
53
44
  // ──────────────────────── listAvailableModels(W7 model picker 用) ────────────────────────
@@ -75,11 +66,7 @@ export function listAvailableModels(
75
66
  entries.push({
76
67
  provider: m.provider,
77
68
  id: m.id,
78
- name: m.name ?? m.id,
79
69
  api: m.api,
80
- ...(m.baseUrl ? { baseUrl: m.baseUrl } : {}),
81
- // 缺失 cost 时填零默认(picker 展示稳定,不依赖真实成本)
82
- cost: m.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
83
70
  });
84
71
  }
85
72
 
package/src/commands.ts CHANGED
@@ -13,9 +13,9 @@
13
13
  * /permission status 显示当前配置详情
14
14
  */
15
15
 
16
- import type { ResolvedModelEntry } from "./classifier/model-resolver.js";
16
+ import { listAvailableModels } from "./classifier/model-resolver.js";
17
17
  import { type ModelPickerContext,pickModelViaOverlay } from "./model-picker.js";
18
- import { type RuleEditorContext,type RuleEditorResult } from "./rule-editor.js";
18
+ import { editRulesViaOverlay, type RuleEditorContext } from "./rule-editor.js";
19
19
  import { applyOps } from "./rule-templates.js";
20
20
  import {
21
21
  isValidPermissionMode,
@@ -118,34 +118,26 @@ function modeHighlight(mode: PermissionMode): string {
118
118
 
119
119
  // ──────────────────────── /permission model(W7) ────────────────────────
120
120
 
121
- /** handlePermissionModelCommand 的依赖(DI 便于测试 mock)。 */
122
- export interface PermissionModelCommandDeps {
123
- /** 列出可用模型(按 provider 分组)。E2 后走 ctx.modelRegistry(签名带 ctx)。 */
124
- listModels: (ctx: ModelPickerContext) => Map<string, ResolvedModelEntry[]>;
125
- /** 保存新配置,返回 {success, error?}。 */
126
- save: (config: PermissionConfig) => { success: boolean; error?: string };
127
- }
128
-
129
121
  /**
130
122
  * /permission model 命令:overlay 选择 classifier model(provider/model 或 auto)。
131
123
  *
132
124
  * 流程:
133
- * 1. listModels:拿可用模型 Map。空 → 降级 notify + return。
125
+ * 1. listAvailableModels(ctx.modelRegistry 数据源):拿可用模型 Map。空 → 降级 notify + return。
134
126
  * 2. pickModelViaOverlay:TUI/RPC/headless 三模式分发。
135
127
  * 3. 写回:save(newConfig)。失败 → notify error。
136
128
  *
137
- * 用依赖注入(deps 参数)便于测试 mock listModels/save。
138
- * ctx 用 ModelPickerContext 子集(mode + ui.notify/custom/select)。
129
+ * save 为普通函数参数(唯一真实变化轴:保存 + footer 重绘副作用)。
130
+ * ctx 用 ModelPickerContext 子集(mode + modelRegistry + ui.notify/custom/select)。
139
131
  */
140
132
  export async function handlePermissionModelCommand(
141
133
  ctx: ModelPickerContext,
142
134
  config: PermissionConfig,
143
- deps: PermissionModelCommandDeps,
135
+ save: (config: PermissionConfig) => { success: boolean; error?: string },
144
136
  ): Promise<void> {
145
137
  const current = config.classifier.model;
146
138
 
147
- // 1. listModels(E2:ctx 传 modelRegistry 数据源)
148
- const models = deps.listModels(ctx);
139
+ // 1. listAvailableModels(ctx.modelRegistry 数据源)
140
+ const models = listAvailableModels(ctx);
149
141
  if (models.size === 0) {
150
142
  ctx.ui.notify(
151
143
  "[pi-permission] No available models. Run `pi auth login` or configure provider API keys, then retry.",
@@ -168,7 +160,7 @@ export async function handlePermissionModelCommand(
168
160
  classifier: { ...config.classifier, model: selected },
169
161
  userRules: config.userRules.map((r) => ({ ...r })),
170
162
  };
171
- const result = deps.save(newConfig);
163
+ const result = save(newConfig);
172
164
  if (!result.success) {
173
165
  ctx.ui.notify(
174
166
  `[pi-permission] Failed to save: ${result.error ?? "unknown error"}`,
@@ -181,35 +173,24 @@ export async function handlePermissionModelCommand(
181
173
 
182
174
  // ──────────────────────── /permission rule(W8) ────────────────────────
183
175
 
184
- /** handlePermissionRuleCommand 的依赖(DI 便于测试 mock)。 */
185
- export interface PermissionRuleCommandDeps {
186
- /** 保存新配置,返回 {success, error?}。 */
187
- save: (config: PermissionConfig) => { success: boolean; error?: string };
188
- /** 规则编辑 overlay(注入便于测试 mock)。 */
189
- editRulesViaOverlay: (
190
- ctx: RuleEditorContext,
191
- initialRules: readonly import("./types.js").Rule[],
192
- sessionIdCounter: () => string,
193
- rpcDeps?: import("./rule-editor.js").RuleEditorRpcDeps,
194
- ) => Promise<RuleEditorResult>;
195
- }
196
-
197
176
  /**
198
177
  * /permission rule 命令:overlay CRUD 编辑 userRules。
199
178
  *
200
179
  * 流程:
201
- * 1. editRulesViaOverlay:TUI/RPC/headless 三模式分发。
180
+ * 1. editRulesViaOverlay(模块直调):TUI/RPC/headless 三模式分发。
202
181
  * 2. ops 非空 → applyOps + saveConfig + notify。
203
182
  * 3. 空 → notify no changes。
183
+ *
184
+ * save 为普通函数参数(唯一真实变化轴:保存 + footer 重绘副作用,与同步路径 onSave 同款)。
204
185
  */
205
186
  export async function handlePermissionRuleCommand(
206
187
  ctx: RuleEditorContext,
207
188
  config: PermissionConfig,
208
189
  sessionIdCounter: () => string,
209
- deps: PermissionRuleCommandDeps,
190
+ save: (config: PermissionConfig) => { success: boolean; error?: string },
210
191
  ): Promise<void> {
211
192
  // 1. editRulesViaOverlay
212
- const ops = await deps.editRulesViaOverlay(ctx, config.userRules, sessionIdCounter);
193
+ const ops = await editRulesViaOverlay(ctx, config.userRules, sessionIdCounter);
213
194
 
214
195
  if (ops === undefined || ops.length === 0) {
215
196
  ctx.ui.notify("[pi-permission] No changes applied.", "info");
@@ -224,7 +205,7 @@ export async function handlePermissionRuleCommand(
224
205
  classifier: { ...config.classifier },
225
206
  userRules: newUserRules,
226
207
  };
227
- const result = deps.save(newConfig);
208
+ const result = save(newConfig);
228
209
  if (!result.success) {
229
210
  ctx.ui.notify(
230
211
  `[pi-permission] Failed to save rules: ${result.error ?? "unknown error"}`,
package/src/config.ts CHANGED
@@ -17,11 +17,12 @@
17
17
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
18
18
  import { dirname, join } from "node:path";
19
19
 
20
- import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
21
20
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
21
+ import { isRecord, toErrorMessage } from "@zhushanwen/pi-ext-guards";
22
22
  import {
23
23
  clearConfigCache,
24
24
  getConfigPath as getLlmConfigPath,
25
+ isThinkingLevel,
25
26
  loadConfig,
26
27
  saveConfig as saveLlmConfig,
27
28
  } from "@zhushanwen/pi-llm-shared";
@@ -50,26 +51,8 @@ export function getConfigPath(): string {
50
51
 
51
52
  // ──────────────────────── 归一化 ────────────────────────
52
53
 
53
- function isPlainObject(value: unknown): value is Record<string, unknown> {
54
- return typeof value === "object" && value !== null && !Array.isArray(value);
55
- }
56
-
57
- const THINKING_LEVELS: ReadonlySet<string> = new Set([
58
- "off",
59
- "minimal",
60
- "low",
61
- "medium",
62
- "high",
63
- "xhigh",
64
- "max",
65
- ]);
66
-
67
- function isModelThinkingLevel(value: unknown): value is ModelThinkingLevel {
68
- return typeof value === "string" && THINKING_LEVELS.has(value);
69
- }
70
-
71
54
  function normalizeClassifierConfig(raw: unknown): ClassifierConfig {
72
- const record = isPlainObject(raw) ? raw : {};
55
+ const record = isRecord(raw) ? raw : {};
73
56
  const timeout = Number(record.timeout);
74
57
  // C3b:classifier.model 只接受 string('auto' 或 'provider/model-id')。对象形式(如
75
58
  // `{ "type": "available" }`)不受支持,此前会被静默忽略回落默认——现在显式 warn 消除静默。
@@ -77,7 +60,7 @@ function normalizeClassifierConfig(raw: unknown): ClassifierConfig {
77
60
  logger.warn("Ignoring invalid classifier.model (expected string 'auto' or 'provider/model-id'), using default auto");
78
61
  }
79
62
  // thinkingLevel 校验:合法值为 'off'|'minimal'|'low'|'medium'|'high'|'xhigh'|'max'
80
- const thinkingLevel = isModelThinkingLevel(record.thinkingLevel)
63
+ const thinkingLevel = isThinkingLevel(record.thinkingLevel)
81
64
  ? record.thinkingLevel
82
65
  : DEFAULT_CLASSIFIER_CONFIG.thinkingLevel;
83
66
  return {
@@ -91,7 +74,7 @@ function normalizeClassifierConfig(raw: unknown): ClassifierConfig {
91
74
  }
92
75
 
93
76
  function normalizeRule(raw: unknown, fallbackId: string): Rule | null {
94
- if (!isPlainObject(raw)) return null;
77
+ if (!isRecord(raw)) return null;
95
78
  const tool = typeof raw.tool === "string" ? raw.tool : "*";
96
79
  const pattern = typeof raw.pattern === "string" ? raw.pattern : "*";
97
80
  const action = raw.action;
@@ -103,7 +86,7 @@ function normalizeRule(raw: unknown, fallbackId: string): Rule | null {
103
86
  }
104
87
 
105
88
  function normalizeConfig(raw: unknown): PermissionConfig {
106
- const record = isPlainObject(raw) ? raw : {};
89
+ const record = isRecord(raw) ? raw : {};
107
90
  const mode = isValidPermissionMode(record.mode) ? record.mode : DEFAULT_CONFIG.mode;
108
91
  const enabled = record.enabled !== false;
109
92
  const classifier = normalizeClassifierConfig(record.classifier);
@@ -133,7 +116,7 @@ function ensureConfigFile(configPath: string, onWarning?: (msg: string) => void)
133
116
  mkdirSync(dirname(configPath), { recursive: true });
134
117
  writeFileSync(configPath, createDefaultConfigContent(), { encoding: "utf-8", mode: 0o600 });
135
118
  } catch (error) {
136
- const message = error instanceof Error ? error.message : String(error);
119
+ const message = toErrorMessage(error);
137
120
  onWarning?.(`[pi-permission] Failed to create default config at '${configPath}': ${message}`);
138
121
  }
139
122
  }
package/src/index.ts CHANGED
@@ -16,19 +16,17 @@ import {
16
16
  type ExtensionAPI,
17
17
  type ExtensionCommandContext,
18
18
  type ExtensionContext,
19
+ type ExtensionUIContext,
19
20
  getAgentDir,
20
21
  } from "@earendil-works/pi-coding-agent";
21
- import { oncePerProcess } from "@zhushanwen/pi-ext-guards";
22
+ import { oncePerProcess, toErrorMessage } from "@zhushanwen/pi-ext-guards";
22
23
  import { getLogger } from "@zhushanwen/pi-extension-logger";
23
24
  import { migrateLegacyConfig } from "@zhushanwen/pi-llm-shared";
24
25
 
25
26
  const logger = getLogger("pi-permission");
26
27
 
27
- import { listAvailableModels } from "./classifier/model-resolver.js";
28
28
  import { handlePermissionCommand, handlePermissionModelCommand, handlePermissionRuleCommand } from "./commands.js";
29
29
  import { loadAndWatchConfig, saveConfig } from "./config.js";
30
- import { setDefaultListAvailableModels } from "./model-picker.js";
31
- import { editRulesViaOverlay } from "./rule-editor.js";
32
30
  import { makeNextIdCounter } from "./rule-templates.js";
33
31
  import { checkPermission, type CheckPermissionDeps } from "./pipeline.js";
34
32
  import { createPipelineDeps } from "./production.js";
@@ -80,6 +78,39 @@ interface ToolCallResult {
80
78
  */
81
79
  const _UI_OPTIONS_PARAM_INDEX = 2;
82
80
 
81
+ // ──────────────────────── ctx.ui 适配(E8/M11 单源) ────────────────────────
82
+
83
+ /**
84
+ * E8(M11):pi ctx.ui → 本包局部 UI 上下文的统一适配(原三份闭包收敛为单源)。
85
+ *
86
+ * 三处消费:/permission rule(RuleEditorContext.ui)、/permission model
87
+ * (ModelPickerContext.ui)、tool_call 审批(ApprovalContext.ui)。三个目标接口的
88
+ * custom 泛型形态不同(前两者 factory 返回 unknown、后者返回 Component),统一靠
89
+ * `as Parameters<typeof ui.custom<T>>[0]` cast 吸收(T5)。
90
+ *
91
+ * select/input 的 opts 类型经 `Parameters<typeof ...>[typeof _UI_OPTIONS_PARAM_INDEX]`
92
+ * 索引提取(字面量索引会触发 no-magic-numbers,typeof 常量索引语义等价)。
93
+ * input 可选守卫展开:SDK 提供,mock/headless 可能缺失——rule-editor custom 模板
94
+ * 文本输入与 Reject-with-Reason(approval.ts collectRejectReason)都依赖此透传。
95
+ */
96
+ function makeUiAdapter(ui: ExtensionUIContext) {
97
+ return {
98
+ notify: (msg: string, type?: "info" | "warning" | "error") => ui.notify(msg, type),
99
+ select: (title: string, options: string[], opts?: Parameters<typeof ui.select>[typeof _UI_OPTIONS_PARAM_INDEX]) =>
100
+ ui.select(title, options, opts),
101
+ custom: <T,>(
102
+ factory: (tui: unknown, theme: unknown, kb: unknown, done: (result: T) => void) => unknown,
103
+ options?: { overlay?: boolean },
104
+ ): Promise<T> => ui.custom<T>(factory as Parameters<typeof ui.custom<T>>[0], options),
105
+ ...(typeof ui.input === "function"
106
+ ? {
107
+ input: (title: string, placeholder?: string, opts?: Parameters<typeof ui.input>[typeof _UI_OPTIONS_PARAM_INDEX]) =>
108
+ ui.input(title, placeholder, opts),
109
+ }
110
+ : {}),
111
+ };
112
+ }
113
+
83
114
  // ──────────────────────── 扩展工厂 ────────────────────────
84
115
 
85
116
  /**
@@ -87,10 +118,6 @@ const _UI_OPTIONS_PARAM_INDEX = 2;
87
118
  * (llm-shared mtime 去重零成本,详见 config.ts「热重载契约」)。
88
119
  */
89
120
  export default function permissionExtension(pi: ExtensionAPI): void {
90
- // W7:注入 listAvailableModels 真实实现(model-picker.ts 默认返回空 Map)。
91
- // E2 签名:(ctx) → ctx.modelRegistry.getAll() + hasConfiguredAuth 过滤。
92
- setDefaultListAvailableModels((ctx) => listAvailableModels(ctx));
93
-
94
121
  // ──────────────────────── 配置读取(读时刷新,回归 llm-shared 框架) ────────────────────────
95
122
  // 不持有跨调用缓存:每次需要配置直接 loadAndWatchConfig(),llm-shared 内部 mtime+size 去重,
96
123
  // 文件未变时零额外 IO(只 statSync)。这是 llm-shared config「热重载契约」的正确用法——
@@ -140,32 +167,14 @@ export default function permissionExtension(pi: ExtensionAPI): void {
140
167
  await handlePermissionRuleCommand(
141
168
  {
142
169
  mode: ctx.mode,
143
- ui: {
144
- notify: (msg: string, type?: "info" | "warning" | "error") => ctx.ui.notify(msg, type),
145
- select: (title: string, options: string[], opts?: Parameters<typeof ctx.ui.select>[typeof _UI_OPTIONS_PARAM_INDEX]) =>
146
- ctx.ui.select(title, options, opts),
147
- custom: <T,>(
148
- factory: (tui: unknown, theme: unknown, kb: unknown, done: (result: T) => void) => unknown,
149
- options?: { overlay?: boolean },
150
- ) =>
151
- ctx.ui.custom<T>(factory as Parameters<typeof ctx.ui.custom<T>>[0], options),
152
- // 连接 ctx.ui.input(rule-editor custom 模板文本输入用)。
153
- // approval.ts 已声明可选 input(SDK 提供,mock 可能缺失)。
154
- ...(typeof ctx.ui.input === "function"
155
- ? { input: (title: string, placeholder?: string, opts?: Parameters<typeof ctx.ui.input>[typeof _UI_OPTIONS_PARAM_INDEX]) => ctx.ui.input(title, placeholder, opts) }
156
- : {}),
157
- },
170
+ ui: makeUiAdapter(ctx.ui),
158
171
  },
159
172
  config,
160
173
  makeNextIdCounter(config.userRules),
161
- {
162
- save: (newConfig) => {
163
- const r = saveConfig(newConfig);
164
- if (r.success) requestFooterRender();
165
- return r;
166
- },
167
- editRulesViaOverlay: (ctx, initialRules, sessionIdCounter, rpcDeps) =>
168
- editRulesViaOverlay(ctx, initialRules, sessionIdCounter, rpcDeps),
174
+ (newConfig) => {
175
+ const r = saveConfig(newConfig);
176
+ if (r.success) requestFooterRender();
177
+ return r;
169
178
  },
170
179
  );
171
180
  return;
@@ -177,29 +186,14 @@ export default function permissionExtension(pi: ExtensionAPI): void {
177
186
  mode: ctx.mode,
178
187
  // E2:model picker 数据源(listAvailableModels 走 modelRegistry)
179
188
  modelRegistry: ctx.modelRegistry,
180
- ui: {
181
- notify: (msg: string, type?: "info" | "warning" | "error") => ctx.ui.notify(msg, type),
182
- select: (title: string, options: string[], opts?: Parameters<typeof ctx.ui.select>[typeof _UI_OPTIONS_PARAM_INDEX]) =>
183
- ctx.ui.select(title, options, opts),
184
- custom: <T,>(
185
- factory: (tui: unknown, theme: unknown, kb: unknown, done: (result: T) => void) => unknown,
186
- options?: { overlay?: boolean },
187
- ) =>
188
- ctx.ui.custom<T>(factory as Parameters<typeof ctx.ui.custom<T>>[0], options),
189
- // 连接 ctx.ui.input(与 rule handler 一致;model picker 当前不用,但保持 ctx 对称)。
190
- ...(typeof ctx.ui.input === "function"
191
- ? { input: (title: string, placeholder?: string, opts?: Parameters<typeof ctx.ui.input>[typeof _UI_OPTIONS_PARAM_INDEX]) => ctx.ui.input(title, placeholder, opts) }
192
- : {}),
193
- },
189
+ // input 透传保持 ctx 对称(model picker 当前不用,rule handler 用)
190
+ ui: makeUiAdapter(ctx.ui),
194
191
  },
195
192
  config,
196
- {
197
- listModels: (pickerCtx) => listAvailableModels(pickerCtx),
198
- save: (newConfig) => {
199
- const r = saveConfig(newConfig);
200
- if (r.success) requestFooterRender();
201
- return r;
202
- },
193
+ (newConfig) => {
194
+ const r = saveConfig(newConfig);
195
+ if (r.success) requestFooterRender();
196
+ return r;
203
197
  },
204
198
  );
205
199
  return;
@@ -320,20 +314,7 @@ async function processToolCall(
320
314
  // 装配 deps(每次 tool_call 重新装配,捕获当前 ctx.mode/ui;classifier 走 ctx.modelRegistry)
321
315
  const approvalCtx = {
322
316
  mode: ctx.mode,
323
- ui: {
324
- notify: (msg: string, type?: "info" | "warning" | "error") => ctx.ui.notify(msg, type),
325
- select: (title: string, options: string[], opts?: Parameters<typeof ctx.ui.select>[typeof _UI_OPTIONS_PARAM_INDEX]) => ctx.ui.select(title, options, opts),
326
- custom: <T,>(
327
- factory: (tui: unknown, theme: unknown, kb: unknown, done: (result: T) => void) => unknown,
328
- options?: { overlay?: boolean },
329
- ) =>
330
- ctx.ui.custom<T>(factory as Parameters<typeof ctx.ui.custom<T>>[0], options),
331
- // W6 T9 G3:Reject-with-Reason。ctx.ui.input 存在则透传(采集真实拒绝理由)。
332
- // approval.ts 的 collectRejectReason 会用 typeof 判断是否可用,不可用则 fallback。
333
- ...(typeof ctx.ui.input === "function"
334
- ? { input: (title: string, placeholder?: string, opts?: Parameters<typeof ctx.ui.input>[typeof _UI_OPTIONS_PARAM_INDEX]) => ctx.ui.input(title, placeholder, opts) }
335
- : {}),
336
- },
317
+ ui: makeUiAdapter(ctx.ui),
337
318
  };
338
319
  const deps: CheckPermissionDeps = createPipelineDeps(approvalCtx, ctx);
339
320
 
@@ -360,7 +341,7 @@ async function processToolCall(
360
341
  };
361
342
  } catch (error) {
362
343
  // fail-closed:异常 → block + reason(绝不放行)
363
- const msg = error instanceof Error ? error.message : String(error);
344
+ const msg = toErrorMessage(error);
364
345
  logger.warn("tool_call handler exception", { toolName, error: msg });
365
346
  return {
366
347
  block: true,