@zhushanwen/pi-permission 1.3.4 → 1.4.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.
@@ -102,90 +102,100 @@ function parseRawString(node: Node): string | null {
102
102
  }
103
103
 
104
104
  /**
105
- * 从一个 command 节点提取 argv(对应 Rust parse_plain_command_from_node)。
106
- *
107
- * 5 分支处理 named children:
108
- * 1. command_name → 其第一个 named child 必须是 word,取文本
109
- * 2. word | number → 直接取文本
110
- * 3. string → parseDoubleQuotedString(含 expansion 则 null)
111
- * 4. raw_string → parseRawString
112
- * 5. concatenation(如 -g"*.py")→ 递归拼接各 part(word/number/string/raw_string)
113
- *
114
- * 任一分支失败 → 返回 null(该 command 被跳过,但 clean 已在 DFS 阶段被置 false)。
105
+ * command_name 分支:其第一个 named child 必须是 word,取文本;否则 null。
115
106
  */
116
- function parsePlainCommand(cmd: Node): string[] | null {
117
- if (cmd.type !== "command") {
107
+ function parseCommandName(child: Node): string | null {
108
+ const wordNode = child.namedChild(0);
109
+ if (!wordNode || wordNode.type !== "word") {
118
110
  return null;
119
111
  }
112
+ return wordNode.text;
113
+ }
120
114
 
121
- const words: string[] = [];
122
- for (const child of cmd.namedChildren) {
123
- switch (child.type) {
124
- case "command_name": {
125
- const wordNode = child.namedChild(0);
126
- if (!wordNode || wordNode.type !== "word") {
127
- return null;
128
- }
129
- words.push(wordNode.text);
130
- break;
131
- }
115
+ /**
116
+ * concatenation 分支(如 -g"*.py" 或 '/usr'"/"'local'/bin):
117
+ * 递归拼接各 part(word/number/string/raw_string),空拼接结果 → null。
118
+ */
119
+ function parseConcatenation(child: Node): string | null {
120
+ let concatenated = "";
121
+ for (const part of child.namedChildren) {
122
+ switch (part.type) {
132
123
  case "word":
133
- case "number": {
134
- words.push(child.text);
124
+ case "number":
125
+ concatenated += part.text;
135
126
  break;
136
- }
137
127
  case "string": {
138
- const parsed = parseDoubleQuotedString(child);
139
- if (parsed === null) {
140
- return null;
141
- }
142
- words.push(parsed);
128
+ const parsed = parseDoubleQuotedString(part);
129
+ if (parsed === null) return null;
130
+ concatenated += parsed;
143
131
  break;
144
132
  }
145
133
  case "raw_string": {
146
- const parsed = parseRawString(child);
147
- if (parsed === null) {
148
- return null;
149
- }
150
- words.push(parsed);
151
- break;
152
- }
153
- case "concatenation": {
154
- // 拼接如 -g"*.py" 或 '/usr'"/"'local'/bin
155
- let concatenated = "";
156
- for (const part of child.namedChildren) {
157
- switch (part.type) {
158
- case "word":
159
- case "number":
160
- concatenated += part.text;
161
- break;
162
- case "string": {
163
- const parsed = parseDoubleQuotedString(part);
164
- if (parsed === null) return null;
165
- concatenated += parsed;
166
- break;
167
- }
168
- case "raw_string": {
169
- const parsed = parseRawString(part);
170
- if (parsed === null) return null;
171
- concatenated += parsed;
172
- break;
173
- }
174
- default:
175
- return null;
176
- }
177
- }
178
- if (concatenated.length === 0) {
179
- return null;
180
- }
181
- words.push(concatenated);
134
+ const parsed = parseRawString(part);
135
+ if (parsed === null) return null;
136
+ concatenated += parsed;
182
137
  break;
183
138
  }
184
139
  default:
185
- // 未知 named child(不应发生 —— DFS 阶段已拒,但保险起见 fail-closed)
186
140
  return null;
187
141
  }
188
142
  }
143
+ if (concatenated.length === 0) {
144
+ return null;
145
+ }
146
+ return concatenated;
147
+ }
148
+
149
+ /**
150
+ * 解析 command 的单个 named child 为一个 argv token(分发表)。
151
+ *
152
+ * 5 分支:
153
+ * 1. command_name → parseCommandName(第一个 named child 必须是 word)
154
+ * 2. word | number → 直接取文本
155
+ * 3. string → parseDoubleQuotedString(含 expansion 则 null)
156
+ * 4. raw_string → parseRawString
157
+ * 5. concatenation → parseConcatenation
158
+ *
159
+ * 未知 named child(不应发生 —— DFS 阶段已拒,但保险起见 fail-closed)→ null。
160
+ * 失败 → 调用方让整个 command 返回 null(该 command 被跳过,但 clean 已在 DFS 阶段被置 false)。
161
+ */
162
+ function parseChildToken(child: Node): string | null {
163
+ switch (child.type) {
164
+ case "command_name":
165
+ return parseCommandName(child);
166
+ case "word":
167
+ case "number":
168
+ return child.text;
169
+ case "string":
170
+ return parseDoubleQuotedString(child);
171
+ case "raw_string":
172
+ return parseRawString(child);
173
+ case "concatenation":
174
+ return parseConcatenation(child);
175
+ default:
176
+ return null;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * 从一个 command 节点提取 argv(对应 Rust parse_plain_command_from_node)。
182
+ *
183
+ * 主函数编排:类型守卫 → 逐 named child 分发(parseChildToken)→ 组装 words。
184
+ * 任一 child 解析失败 → 返回 null。
185
+ */
186
+ function parsePlainCommand(cmd: Node): string[] | null {
187
+ if (cmd.type !== "command") {
188
+ return null;
189
+ }
190
+
191
+ const words: string[] = [];
192
+ for (const child of cmd.namedChildren) {
193
+ const token = parseChildToken(child);
194
+ if (token === null) {
195
+ return null;
196
+ }
197
+ words.push(token);
198
+ }
189
199
  return words;
190
200
  }
191
201
 
@@ -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
  ];
@@ -119,6 +119,78 @@ function raceResultWithDeadline(
119
119
  });
120
120
  }
121
121
 
122
+ // ──────────────────────── classifyRisk 分步 helper ────────────────────────
123
+
124
+ /**
125
+ * 构造 CallLLMOptions(timeout 秒→毫秒的换算由调用方完成后传入;
126
+ * signal 透传 callLLM → completeSimple,abort 时 reject 或 resolve(aborted)
127
+ * 都会归一为 ok:false)。
128
+ */
129
+ function buildCallOptions(
130
+ model: Model<Api>,
131
+ ctx: ToolInvocationContext,
132
+ config: ClassifierConfig,
133
+ timeoutMs: number | undefined,
134
+ signal: AbortSignal | undefined,
135
+ ): CallLLMOptions {
136
+ return {
137
+ model,
138
+ systemPrompt: CLASSIFIER_SYSTEM_PROMPT,
139
+ messages: buildMessages(ctx),
140
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
141
+ ...(signal !== undefined ? { signal } : {}),
142
+ // thinkingLevel 直接透传(含 "off");llm-shared 内部会把 "off" 映射为不传 reasoning(provider 默认)
143
+ reasoning: config.thinkingLevel,
144
+ };
145
+ }
146
+
147
+ /** 记录 LLM 调用失败日志(G3 语义:abort 与 error 分开记,即使行为上两者都 fallback 无差别)。 */
148
+ function logFailedCall(
149
+ result: Extract<CallLLMResult, { ok: false }>,
150
+ onLog?: (msg: string) => void,
151
+ ): void {
152
+ if (result.stopReason === "aborted") {
153
+ onLog?.(`[pi-permission] classifier: LLM call aborted (stopReason=aborted), returning fallback`);
154
+ } else {
155
+ const stopDetail = result.stopReason !== undefined ? ` (stopReason=${result.stopReason})` : "";
156
+ onLog?.(`[pi-permission] classifier: LLM call failed: ${result.error}${stopDetail}, returning fallback`);
157
+ }
158
+ }
159
+
160
+ /** raceWithDeadline 已收窄的结果形态(timeout/aborted 无 result 字段)。 */
161
+ type SettledRace =
162
+ | { kind: "ok"; result: CallLLMResult }
163
+ | { kind: "timeout" }
164
+ | { kind: "aborted" };
165
+
166
+ /** settleRacedResult 的输出:fallback = fail-closed ask;ok = 继续解析文本。 */
167
+ type SettledOutcome =
168
+ | { kind: "fallback"; result: ClassifierResult }
169
+ | { kind: "ok"; result: Extract<CallLLMResult, { ok: true }> };
170
+
171
+ /**
172
+ * race 结果归一:timeout / aborted / ok:false → fail-closed fallback(保留日志区分),
173
+ * 成功原样透传(调用方收窄后取 content)。
174
+ */
175
+ function settleRacedResult(settled: SettledRace, timeoutMs: number | undefined, onLog?: (msg: string) => void): SettledOutcome {
176
+ if (settled.kind === "timeout") {
177
+ onLog?.(`[pi-permission] classifier: timed out after ${timeoutMs}ms`);
178
+ return { kind: "fallback", result: { ...CLASSIFY_FALLBACK_RESULT } };
179
+ }
180
+ if (settled.kind === "aborted") {
181
+ onLog?.(`[pi-permission] classifier: aborted by signal`);
182
+ return { kind: "fallback", result: { ...CLASSIFY_FALLBACK_RESULT } };
183
+ }
184
+
185
+ // ok:false → fallback(fail-closed)。stopReason 独立透传字段保留日志区分
186
+ const result = settled.result;
187
+ if (!result.ok) {
188
+ logFailedCall(result, onLog);
189
+ return { kind: "fallback", result: { ...CLASSIFY_FALLBACK_RESULT } };
190
+ }
191
+ return { kind: "ok", result };
192
+ }
193
+
122
194
  // ──────────────────────── createClassifier ────────────────────────
123
195
 
124
196
  /**
@@ -148,21 +220,12 @@ export function createClassifier(deps: ClassifierDeps): {
148
220
  // 实证 classifier 真实用到 OAuth/配置的模型(而非 fail-closed 降级)。
149
221
  onLog?.(`[pi-permission] classifier: using model ${model.id}`);
150
222
 
151
- // 2. 构造 CallLLMOptions(timeout 秒→毫秒;signal 透传 callLLM → completeSimple,
152
- // abort 时 reject 或 resolve(aborted) 都会归一为 ok:false)
223
+ // 2. 构造 CallLLMOptions
153
224
  const timeoutMs = config.timeout > 0 ? config.timeout * MILLIS_PER_SECOND : undefined;
154
- const callPromise = callLLM({
155
- model,
156
- systemPrompt: CLASSIFIER_SYSTEM_PROMPT,
157
- messages: buildMessages(ctx),
158
- ...(timeoutMs !== undefined ? { timeoutMs } : {}),
159
- ...(signal !== undefined ? { signal } : {}),
160
- // thinkingLevel 直接透传(含 "off");llm-shared 内部会把 "off" 映射为不传 reasoning(provider 默认)
161
- reasoning: config.thinkingLevel,
162
- });
225
+ const callPromise = callLLM(buildCallOptions(model, ctx, config, timeoutMs, signal));
163
226
 
164
227
  // 3. 等待 callLLM + 外层超时/中止兜底(provider 不支持 timeoutMs/signal 时防永挂)
165
- let settled: { kind: "ok"; result: CallLLMResult } | { kind: "timeout" } | { kind: "aborted" };
228
+ let settled: SettledRace;
166
229
  try {
167
230
  settled = await raceResultWithDeadline(callPromise, timeoutMs, signal);
168
231
  } catch (error) {
@@ -171,30 +234,14 @@ export function createClassifier(deps: ClassifierDeps): {
171
234
  return { ...CLASSIFY_FALLBACK_RESULT };
172
235
  }
173
236
 
174
- if (settled.kind === "timeout") {
175
- onLog?.(`[pi-permission] classifier: timed out after ${timeoutMs}ms`);
176
- return { ...CLASSIFY_FALLBACK_RESULT };
177
- }
178
- if (settled.kind === "aborted") {
179
- onLog?.(`[pi-permission] classifier: aborted by signal`);
180
- return { ...CLASSIFY_FALLBACK_RESULT };
181
- }
182
-
183
- // 4. ok:false → fallback(fail-closed)。stopReason 独立透传字段保留日志区分
184
- // (G3 语义:abort 与 error 分开记,即使行为上两者都 fallback 无差别)。
185
- const result = settled.result;
186
- if (!result.ok) {
187
- if (result.stopReason === "aborted") {
188
- onLog?.(`[pi-permission] classifier: LLM call aborted (stopReason=aborted), returning fallback`);
189
- } else {
190
- const stopDetail = result.stopReason !== undefined ? ` (stopReason=${result.stopReason})` : "";
191
- onLog?.(`[pi-permission] classifier: LLM call failed: ${result.error}${stopDetail}, returning fallback`);
192
- }
193
- return { ...CLASSIFY_FALLBACK_RESULT };
237
+ // 4. timeout / aborted / ok:false → fail-closed fallback;成功 → 收窄透传
238
+ const settledOutcome = settleRacedResult(settled, timeoutMs, onLog);
239
+ if (settledOutcome.kind === "fallback") {
240
+ return settledOutcome.result;
194
241
  }
195
242
 
196
243
  // 5. 提取文本 → 三层容错解析
197
- return parseClassifierResponse(result.content);
244
+ return parseClassifierResponse(settledOutcome.result.content);
198
245
  }
199
246
 
200
247
  return { classifyRisk };
@@ -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/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);
@@ -86,10 +88,8 @@ const _UI_OPTIONS_PARAM_INDEX = 2;
86
88
  */
87
89
  export default function permissionExtension(pi: ExtensionAPI): void {
88
90
  // W7:注入 listAvailableModels 真实实现(model-picker.ts 默认返回空 Map)。
89
- // E2 签名:(ctx, onWarning?) → ctx.modelRegistry.getAll() + hasConfiguredAuth 过滤;warning 透传到 console.warn。
90
- setDefaultListAvailableModels((ctx, onWarning) =>
91
- listAvailableModels(ctx, onWarning ?? defaultConfigWarn),
92
- );
91
+ // E2 签名:(ctx) → ctx.modelRegistry.getAll() + hasConfiguredAuth 过滤。
92
+ setDefaultListAvailableModels((ctx) => listAvailableModels(ctx));
93
93
 
94
94
  // ──────────────────────── 配置读取(读时刷新,回归 llm-shared 框架) ────────────────────────
95
95
  // 不持有跨调用缓存:每次需要配置直接 loadAndWatchConfig(),llm-shared 内部 mtime+size 去重,
@@ -100,10 +100,9 @@ export default function permissionExtension(pi: ExtensionAPI): void {
100
100
  // ──────────────────────── session_start:迁移旧路径配置 ────────────────────────
101
101
  pi.on("session_start", (_event: unknown, ctx: ExtensionContext) => {
102
102
  // [MIGRATION] Added in v1.0.0. Remove after v2.0.0.
103
- if (!configMigrationChecked) {
104
- configMigrationChecked = true;
103
+ oncePerProcess("permission:migrate-legacy-config", () => {
105
104
  migrateLegacyConfig(getAgentDir(), "permission-config.json", "config/permission-ext-config.json");
106
- }
105
+ });
107
106
  // 无需手动刷新:去闭包后每次 loadAndWatchConfig 读时刷新;迁移改写文件后下次读取自动生效。
108
107
  // 注册 footer line renderer(consumer 端握手,pi-statusline 是 canonical owner)。
109
108
  // renderer 无状态:render 时用 statusline 传入的 theme + loadAndWatchConfig 读最新 config。
@@ -195,7 +194,7 @@ export default function permissionExtension(pi: ExtensionAPI): void {
195
194
  },
196
195
  config,
197
196
  {
198
- listModels: (pickerCtx) => listAvailableModels(pickerCtx, (m) => logger.warn(m)),
197
+ listModels: (pickerCtx) => listAvailableModels(pickerCtx),
199
198
  save: (newConfig) => {
200
199
  const r = saveConfig(newConfig);
201
200
  if (r.success) requestFooterRender();
@@ -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,22 +35,6 @@ 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
 
@@ -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
  /**