@zhushanwen/pi-permission 1.3.3 → 1.4.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/src/approval.ts CHANGED
@@ -6,12 +6,12 @@
6
6
  * G3:调 ctx.ui.custom 前先检查 signal.aborted 短路(AI 先于 UI factory 执行时
7
7
  * controller.abort() 触发但 comp 尚未创建,cancel 落空)。
8
8
  * - rpc:ctx.ui.select(GUI 对话框)。M2:接收 signal,abort 短路 + 透传给 ui.select。
9
- * - headless(json/print):无交互 UI。M1:返回永不主动 resolve 的 Promise(让 Racing 中的
10
- * AI 有机会赢),仅在 signal abort 时 fail-closed deny(避免 headless 下 auto 退化为 strict)。
9
+ * - headless(json/print):无交互 UI,立即 fail-closed deny(requestHeadless)。auto 模式
10
+ * Racing 不走此路径(pipeline runLayer3WithRacing 的 isHeadless 分支纯等 AI 判定)。
11
11
  *
12
12
  * ApprovalComponent 是简化版 TUI 组件(参考 ask-user AskUserComponent):
13
13
  * - 显示工具名 + 命令 + 触发原因 + 可选 AI 预分类。
14
- * - y/approve、n/deny、Esc/cancel。
14
+ * - Enter/approve、Esc/deny。
15
15
  * - signal abort → comp.cancel()(复用 _resolved 守卫,避免二次 done)。
16
16
  *
17
17
  * W6 T9 G3 Reject-with-Reason:用户拒绝时,若 ctx.ui.input 存在则弹出文本输入框
@@ -123,7 +123,8 @@ async function requestRpc(
123
123
  ): Promise<UserDecision> {
124
124
  const title = formatTitle(req);
125
125
  // TODO: spec 未要求 session/always scope,当前仅支持 once。
126
- // 未来如需扩展,options 加 "Approve (session)" / "Approve (always)",UserDecision.scope 透传。
126
+ // 未来如需扩展,options 加 "Approve (session)" / "Approve (always)",并在 UserDecision
127
+ // 上新增 scope 字段承载(当前 UserDecision 只有 approved/reason)。
127
128
  const options = ["Approve (once)", "Deny"];
128
129
  // M2:与 requestTui 一致——调 select 前检查 signal.aborted 短路(AI 先于对话框弹出赢 race 时,
129
130
  // controller.abort() 已触发但 select 尚未发起,这里 fail-closed deny 避免弹出无意义对话框)。
@@ -137,7 +138,7 @@ async function requestRpc(
137
138
  return { approved: false, reason: "user dismissed the prompt" };
138
139
  }
139
140
  if (choice.startsWith("Approve")) {
140
- return { approved: true, reason: "approved via rpc", scope: "once" };
141
+ return { approved: true, reason: "approved via rpc" };
141
142
  }
142
143
  // W6 T9 G3:Reject-with-Reason。用户选 Deny 后,若 ctx.ui.input 存在则采集真实理由。
143
144
  // 「受阻」可观测条件:typeof ctx.ui.input === 'function'。
@@ -226,7 +227,7 @@ interface TuiLike {
226
227
  * TUI 审批组件(简化版,参考 ask-user AskUserComponent)。
227
228
  *
228
229
  * 显示:标题 + 工具名 + 命令 + 原因 + 可选 AI 预分类 + 操作提示。
229
- * 键位:y/Enter → approve;n/Esc → deny/cancel。
230
+ * 键位:Enter → approve;Esc → deny/cancel(y/n 快捷键已移除,测试钉住 no-op)。
230
231
  *
231
232
  * G4:implements Component,补 invalidate()(调 tui.requestRender())。
232
233
  * signal abort → cancel()(复用 _resolved 守卫)。
@@ -292,9 +293,10 @@ export class ApprovalComponent implements Component {
292
293
  private approve(): void {
293
294
  if (this._resolved) return;
294
295
  this._resolved = true;
295
- // TODO: spec 未要求 session/always scope,当前硬编码 "once"。未来如需扩展,
296
- // 增加 handleInput 对应键位(如 's' → session、'a' → always)并透传 UserDecision.scope。
297
- this.done({ approved: true, reason: "approved via tui", scope: "once" });
296
+ // TODO: spec 未要求 session/always scope,当前硬编码 "once"。未来如需扩展,
297
+ // 增加 handleInput 对应键位(如 's' → session、'a' → always),并在 UserDecision
298
+ // 上新增 scope 字段承载(当前 UserDecision 只有 approved/reason)。
299
+ this.done({ approved: true, reason: "approved via tui" });
298
300
  }
299
301
 
300
302
  private deny(): void {
@@ -29,6 +29,9 @@ const logger = getLogger("pi-permission/ast");
29
29
  /** 超过此长度的输入直接拒(防止 wasm 解析巨型 payload DoS)。 */
30
30
  const MAX_COMMAND_LENGTH = 65536;
31
31
 
32
+ /** 带引号 token 的最小长度(首尾引号各占 1 字符);满足才能安全剥引号。 */
33
+ const MIN_QUOTED_TOKEN_LENGTH = 2;
34
+
32
35
  /**
33
36
  * Codex ALLOWED_KINDS(11 个 named node)。
34
37
  * 遇到不在此集合的 named node 即判定为危险结构。
@@ -76,7 +79,7 @@ function parseDoubleQuotedString(node: Node): string | null {
76
79
  }
77
80
  const raw = node.text;
78
81
  // 去掉首尾的 `"`(raw 形如 `"hello world"`)
79
- if (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) {
82
+ if (raw.startsWith('"') && raw.endsWith('"') && raw.length >= MIN_QUOTED_TOKEN_LENGTH) {
80
83
  return raw.slice(1, -1);
81
84
  }
82
85
  // 异常 grammar(理论上不会到这)—— 原样返回
@@ -92,7 +95,7 @@ function parseRawString(node: Node): string | null {
92
95
  return null;
93
96
  }
94
97
  const raw = node.text;
95
- if (raw.startsWith("'") && raw.endsWith("'") && raw.length >= 2) {
98
+ if (raw.startsWith("'") && raw.endsWith("'") && raw.length >= MIN_QUOTED_TOKEN_LENGTH) {
96
99
  return raw.slice(1, -1);
97
100
  }
98
101
  return raw;
@@ -60,7 +60,7 @@ function okResult(content: string): CallLLMResult {
60
60
  }
61
61
 
62
62
  function failResult(error: string, stopReason?: "error" | "aborted"): CallLLMResult {
63
- return { ok: false, error, recoverable: true, ...(stopReason !== undefined ? { stopReason } : {}) };
63
+ return { ok: false, error, ...(stopReason !== undefined ? { stopReason } : {}) };
64
64
  }
65
65
 
66
66
  /** 构造「永不 resolve」的 mock callLLM(用于 timeout/abort 测试) */
@@ -8,7 +8,7 @@
8
8
  * 关键设计:
9
9
  * - 依赖注入 ClassifierDeps(callLLM 注入,便于测试 mock,不直接 import)
10
10
  * - C1a 收口:LLM 调用走 llm-shared callLLM(内部完成凭证 getApiKeyAndHeaders +
11
- * completeSimple + stopReason 归一化:error/aborted → {ok:false, recoverable:true,
11
+ * completeSimple + stopReason 归一化:error/aborted → {ok:false,
12
12
  * stopReason 透传}),不再走 production.ts 的 streamSimple
13
13
  * - G3 修正(由 callLLM 承接):completeSimple 的 EventStream.result() 只 resolve 不 reject,
14
14
  * error/aborted 也 resolve(带 stopReason)。callLLM 已把 error/aborted 归一为 ok:false +
@@ -90,7 +90,7 @@ function raceResultWithDeadline(
90
90
  (result) => ({ kind: "ok" as const, result }),
91
91
  (error) => ({
92
92
  kind: "ok" as const,
93
- result: { ok: false as const, error: error instanceof Error ? error.message : String(error), recoverable: true },
93
+ result: { ok: false as const, error: error instanceof Error ? error.message : String(error) },
94
94
  }),
95
95
  ),
96
96
  ];
@@ -64,14 +64,11 @@ export interface ResolvedModelEntry {
64
64
  * 「无可选模型」提示)。
65
65
  *
66
66
  * @param ctx 含 modelRegistry 的上下文(model 列表 + auth 判定)
67
- * @param onWarning 保留参数位(契约兼容;E2 后无文件读取,无警告源)
68
67
  * @returns Map<providerName, ResolvedModelEntry[]>
69
68
  */
70
69
  export function listAvailableModels(
71
70
  ctx: ListAvailableModelsCtx,
72
- onWarning?: (msg: string) => void,
73
71
  ): Map<string, ResolvedModelEntry[]> {
74
- void onWarning; // 无警告源(不再读文件),保留参数位兼容调用方
75
72
  const entries: ResolvedModelEntry[] = [];
76
73
  for (const m of ctx.modelRegistry.getAll()) {
77
74
  if (!ctx.modelRegistry.hasConfiguredAuth(m)) continue;
package/src/commands.ts CHANGED
@@ -55,6 +55,9 @@ export function handlePermissionCommand(
55
55
 
56
56
  // ──────────────────────── 消息格式化 ────────────────────────
57
57
 
58
+ /** 模式列表 mode 列的 padEnd 宽度(最长 'approve' 7 字符 + 1 间隔,保证描述列对齐)。 */
59
+ const MODE_COLUMN_WIDTH = 8;
60
+
58
61
  function formatStatusMessage(config: PermissionConfig): string {
59
62
  const lines: string[] = [];
60
63
  lines.push("[pi-permission] Current mode: " + modeHighlight(config.mode));
@@ -62,7 +65,7 @@ function formatStatusMessage(config: PermissionConfig): string {
62
65
  lines.push("Available modes (by strictness, low → high):");
63
66
  for (const mode of PERMISSION_MODES) {
64
67
  const marker = mode === config.mode ? "►" : " ";
65
- lines.push(` ${marker} ${mode.padEnd(8)} — ${MODE_DESCRIPTIONS[mode]}`);
68
+ lines.push(` ${marker} ${mode.padEnd(MODE_COLUMN_WIDTH)} — ${MODE_DESCRIPTIONS[mode]}`);
66
69
  }
67
70
  lines.push("");
68
71
  lines.push("Switch with: /permission <mode>");
package/src/config.ts CHANGED
@@ -116,8 +116,11 @@ function normalizeConfig(raw: unknown): PermissionConfig {
116
116
 
117
117
  // ──────────────────────── 默认配置文件创建 ────────────────────────
118
118
 
119
+ /** 默认配置文件 JSON 序列化的缩进空格数。 */
120
+ const CONFIG_JSON_INDENT_SPACES = 2;
121
+
119
122
  function createDefaultConfigContent(): string {
120
- return `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`;
123
+ return `${JSON.stringify(DEFAULT_CONFIG, null, CONFIG_JSON_INDENT_SPACES)}\n`;
121
124
  }
122
125
 
123
126
  function ensureConfigFile(configPath: string, onWarning?: (msg: string) => void): void {
package/src/index.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  type ExtensionContext,
19
19
  getAgentDir,
20
20
  } from "@earendil-works/pi-coding-agent";
21
+ import { oncePerProcess } from "@zhushanwen/pi-ext-guards";
21
22
  import { getLogger } from "@zhushanwen/pi-extension-logger";
22
23
  import { migrateLegacyConfig } from "@zhushanwen/pi-llm-shared";
23
24
 
@@ -44,8 +45,9 @@ import { paletteFromTheme, type PermissionPalette } from "./statusline-palette.j
44
45
  // Added in v1.0.0. Remove after v2.0.0 (one major past).
45
46
  // session_start 首次触发时把旧路径 permission-config.json 迁到 config/permission-ext-config.json。
46
47
  // 幂等、best-effort(migrateLegacyConfig 见 @zhushanwen/pi-llm-shared)。
47
- // 模块级 once flag 防同进程重复触发;agentDir 由 getAgentDir() 推导(尊重 PI_CODING_AGENT_DIR)。
48
- let configMigrationChecked = false;
48
+ // 写 agentDir 全局配置文件属跨 session 副作用——oncePerProcess 守卫防 factory 二调/handler
49
+ // 累积双跑(u-audit-fix 收编原内联 once flag;key 带包名前缀避撞,见 pi-ext-guards JSDoc)。
50
+ // agentDir 由 getAgentDir() 推导(尊重 PI_CODING_AGENT_DIR)。
49
51
 
50
52
  /** 默认配置 warning 回调(loadAndWatchConfig 共用,透传 logger.warn)。 */
51
53
  const defaultConfigWarn = (msg: string): void => logger.warn(msg);
@@ -70,6 +72,14 @@ interface ToolCallResult {
70
72
  reason?: string;
71
73
  }
72
74
 
75
+ /**
76
+ * ctx.ui.select / ctx.ui.input 的 opts 参数索引(第 3 个参数,0-based)。
77
+ * 类型位置提取 opts 时用 `Parameters<typeof ...>[typeof _UI_OPTIONS_PARAM_INDEX]`
78
+ * (字面量索引会触发 no-magic-numbers,typeof 常量索引语义等价)。
79
+ * 仅类型位置使用、运行时无值引用,按项目约定加 `_` 前缀豁免 no-unused-vars。
80
+ */
81
+ const _UI_OPTIONS_PARAM_INDEX = 2;
82
+
73
83
  // ──────────────────────── 扩展工厂 ────────────────────────
74
84
 
75
85
  /**
@@ -78,10 +88,8 @@ interface ToolCallResult {
78
88
  */
79
89
  export default function permissionExtension(pi: ExtensionAPI): void {
80
90
  // W7:注入 listAvailableModels 真实实现(model-picker.ts 默认返回空 Map)。
81
- // E2 签名:(ctx, onWarning?) → ctx.modelRegistry.getAll() + hasConfiguredAuth 过滤;warning 透传到 console.warn。
82
- setDefaultListAvailableModels((ctx, onWarning) =>
83
- listAvailableModels(ctx, onWarning ?? defaultConfigWarn),
84
- );
91
+ // E2 签名:(ctx) → ctx.modelRegistry.getAll() + hasConfiguredAuth 过滤。
92
+ setDefaultListAvailableModels((ctx) => listAvailableModels(ctx));
85
93
 
86
94
  // ──────────────────────── 配置读取(读时刷新,回归 llm-shared 框架) ────────────────────────
87
95
  // 不持有跨调用缓存:每次需要配置直接 loadAndWatchConfig(),llm-shared 内部 mtime+size 去重,
@@ -92,10 +100,9 @@ export default function permissionExtension(pi: ExtensionAPI): void {
92
100
  // ──────────────────────── session_start:迁移旧路径配置 ────────────────────────
93
101
  pi.on("session_start", (_event: unknown, ctx: ExtensionContext) => {
94
102
  // [MIGRATION] Added in v1.0.0. Remove after v2.0.0.
95
- if (!configMigrationChecked) {
96
- configMigrationChecked = true;
103
+ oncePerProcess("permission:migrate-legacy-config", () => {
97
104
  migrateLegacyConfig(getAgentDir(), "permission-config.json", "config/permission-ext-config.json");
98
- }
105
+ });
99
106
  // 无需手动刷新:去闭包后每次 loadAndWatchConfig 读时刷新;迁移改写文件后下次读取自动生效。
100
107
  // 注册 footer line renderer(consumer 端握手,pi-statusline 是 canonical owner)。
101
108
  // renderer 无状态:render 时用 statusline 传入的 theme + loadAndWatchConfig 读最新 config。
@@ -135,7 +142,7 @@ export default function permissionExtension(pi: ExtensionAPI): void {
135
142
  mode: ctx.mode,
136
143
  ui: {
137
144
  notify: (msg: string, type?: "info" | "warning" | "error") => ctx.ui.notify(msg, type),
138
- select: (title: string, options: string[], opts?: Parameters<typeof ctx.ui.select>[2]) =>
145
+ select: (title: string, options: string[], opts?: Parameters<typeof ctx.ui.select>[typeof _UI_OPTIONS_PARAM_INDEX]) =>
139
146
  ctx.ui.select(title, options, opts),
140
147
  custom: <T,>(
141
148
  factory: (tui: unknown, theme: unknown, kb: unknown, done: (result: T) => void) => unknown,
@@ -145,7 +152,7 @@ export default function permissionExtension(pi: ExtensionAPI): void {
145
152
  // 连接 ctx.ui.input(rule-editor custom 模板文本输入用)。
146
153
  // approval.ts 已声明可选 input(SDK 提供,mock 可能缺失)。
147
154
  ...(typeof ctx.ui.input === "function"
148
- ? { input: (title: string, placeholder?: string, opts?: Parameters<typeof ctx.ui.input>[2]) => ctx.ui.input(title, placeholder, opts) }
155
+ ? { input: (title: string, placeholder?: string, opts?: Parameters<typeof ctx.ui.input>[typeof _UI_OPTIONS_PARAM_INDEX]) => ctx.ui.input(title, placeholder, opts) }
149
156
  : {}),
150
157
  },
151
158
  },
@@ -172,7 +179,7 @@ export default function permissionExtension(pi: ExtensionAPI): void {
172
179
  modelRegistry: ctx.modelRegistry,
173
180
  ui: {
174
181
  notify: (msg: string, type?: "info" | "warning" | "error") => ctx.ui.notify(msg, type),
175
- select: (title: string, options: string[], opts?: Parameters<typeof ctx.ui.select>[2]) =>
182
+ select: (title: string, options: string[], opts?: Parameters<typeof ctx.ui.select>[typeof _UI_OPTIONS_PARAM_INDEX]) =>
176
183
  ctx.ui.select(title, options, opts),
177
184
  custom: <T,>(
178
185
  factory: (tui: unknown, theme: unknown, kb: unknown, done: (result: T) => void) => unknown,
@@ -181,13 +188,13 @@ export default function permissionExtension(pi: ExtensionAPI): void {
181
188
  ctx.ui.custom<T>(factory as Parameters<typeof ctx.ui.custom<T>>[0], options),
182
189
  // 连接 ctx.ui.input(与 rule handler 一致;model picker 当前不用,但保持 ctx 对称)。
183
190
  ...(typeof ctx.ui.input === "function"
184
- ? { input: (title: string, placeholder?: string, opts?: Parameters<typeof ctx.ui.input>[2]) => ctx.ui.input(title, placeholder, opts) }
191
+ ? { input: (title: string, placeholder?: string, opts?: Parameters<typeof ctx.ui.input>[typeof _UI_OPTIONS_PARAM_INDEX]) => ctx.ui.input(title, placeholder, opts) }
185
192
  : {}),
186
193
  },
187
194
  },
188
195
  config,
189
196
  {
190
- listModels: (pickerCtx) => listAvailableModels(pickerCtx, (m) => logger.warn(m)),
197
+ listModels: (pickerCtx) => listAvailableModels(pickerCtx),
191
198
  save: (newConfig) => {
192
199
  const r = saveConfig(newConfig);
193
200
  if (r.success) requestFooterRender();
@@ -315,7 +322,7 @@ async function processToolCall(
315
322
  mode: ctx.mode,
316
323
  ui: {
317
324
  notify: (msg: string, type?: "info" | "warning" | "error") => ctx.ui.notify(msg, type),
318
- select: (title: string, options: string[], opts?: Parameters<typeof ctx.ui.select>[2]) => ctx.ui.select(title, options, opts),
325
+ select: (title: string, options: string[], opts?: Parameters<typeof ctx.ui.select>[typeof _UI_OPTIONS_PARAM_INDEX]) => ctx.ui.select(title, options, opts),
319
326
  custom: <T,>(
320
327
  factory: (tui: unknown, theme: unknown, kb: unknown, done: (result: T) => void) => unknown,
321
328
  options?: { overlay?: boolean },
@@ -324,7 +331,7 @@ async function processToolCall(
324
331
  // W6 T9 G3:Reject-with-Reason。ctx.ui.input 存在则透传(采集真实拒绝理由)。
325
332
  // approval.ts 的 collectRejectReason 会用 typeof 判断是否可用,不可用则 fallback。
326
333
  ...(typeof ctx.ui.input === "function"
327
- ? { input: (title: string, placeholder?: string, opts?: Parameters<typeof ctx.ui.input>[2]) => ctx.ui.input(title, placeholder, opts) }
334
+ ? { input: (title: string, placeholder?: string, opts?: Parameters<typeof ctx.ui.input>[typeof _UI_OPTIONS_PARAM_INDEX]) => ctx.ui.input(title, placeholder, opts) }
328
335
  : {}),
329
336
  },
330
337
  };
@@ -23,6 +23,10 @@ import { type Component, Container, type SelectItem, SelectList, type SelectList
23
23
  import type { Api, Model } from "@earendil-works/pi-ai";
24
24
 
25
25
  import type { ResolvedModelEntry } from "./classifier/model-resolver.js";
26
+ import { DEFAULT_SELECT_THEME } from "./select-theme.js";
27
+
28
+ // 保持既有 public 名(model-picker.test.ts MPT7 直接消费此路径)。
29
+ export { DEFAULT_SELECT_THEME };
26
30
 
27
31
  // ──────────────────────── 类型 ────────────────────────
28
32
 
@@ -31,28 +35,15 @@ export type SelectionResult =
31
35
  | { kind: "auto" }
32
36
  | { kind: "specific"; provider: string; modelId: string };
33
37
 
34
- // ──────────────────────── DEFAULT_SELECT_THEME(G2 修正) ────────────────────────
35
-
36
- /**
37
- * SelectList 默认主题。
38
- *
39
- * G2/WR2 修正:selectedPrefix 实现为 `(t) => '▶ ' + t`(非 identity),
40
- * 选中行有视觉区分(▶ 前缀)。
41
- */
42
- export const DEFAULT_SELECT_THEME: SelectListTheme = {
43
- selectedPrefix: (t: string): string => "\u25B6 " + t,
44
- selectedText: (t: string): string => t,
45
- description: (t: string): string => t,
46
- scrollInfo: (t: string): string => t,
47
- noMatch: (t: string): string => t,
48
- };
49
-
50
38
  /** provider stage 的 'auto' 项 value(用特殊标记区分真实 provider 名)。 */
51
39
  const AUTO_VALUE = "__auto__";
52
40
 
53
41
  /** SelectList 最大可见行数(picker overlay 不超过终端高度)。 */
54
42
  const MAX_VISIBLE_ITEMS = 10;
55
43
 
44
+ /** box 边框左右各占用 1 列(│ × 2)。 */
45
+ const BORDER_OVERHEAD = 2;
46
+
56
47
  // ──────────────────────── 辅助组件(inline Component stub,避免依赖 Box 的 padding/bg) ────────────────────────
57
48
 
58
49
  /**
@@ -127,14 +118,11 @@ export class ProviderModelSelectorComponent extends Container {
127
118
  this.currentList?.handleInput(data);
128
119
  }
129
120
 
130
- /** box 边框左右各占用 1 列(│ × 2) */
131
- private static readonly BORDER_OVERHEAD = 2;
132
-
133
121
  /**
134
122
  * 重写 render:用 box 边框包裹 Container 子组件的输出。
135
123
  */
136
124
  override render(width: number): string[] {
137
- const innerWidth = Math.max(0, width - ProviderModelSelectorComponent.BORDER_OVERHEAD);
125
+ const innerWidth = Math.max(0, width - BORDER_OVERHEAD);
138
126
  const inner = super.render(innerWidth);
139
127
  const lines: string[] = [];
140
128
  lines.push(`\u250C${"\u2500".repeat(innerWidth)}\u2510`);
@@ -189,7 +177,7 @@ export class ProviderModelSelectorComponent extends Container {
189
177
  /** provider stage items:'Auto' + 各 provider(含 model 数量)。 */
190
178
  private buildProviderItems(): SelectItem[] {
191
179
  const items: SelectItem[] = [
192
- { value: AUTO_VALUE, label: "Auto", description: "auto-select cheapest available model" },
180
+ { value: AUTO_VALUE, label: "Auto", description: "select first available model" },
193
181
  ];
194
182
  for (const provider of this.providers) {
195
183
  const models = this.modelsByProvider.get(provider) ?? [];
@@ -297,7 +285,6 @@ export interface ModelPickerContext {
297
285
  /** listAvailableModels 注入签名(E2 改走 ctx.modelRegistry,签名带 ctx;便于测试 mock)。 */
298
286
  export type ListAvailableModelsFn = (
299
287
  ctx: ModelPickerContext,
300
- onWarning?: (msg: string) => void,
301
288
  ) => Map<string, ResolvedModelEntry[]>;
302
289
 
303
290
  /**