@zhushanwen/pi-permission 1.3.4 → 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.
@@ -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/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
  /**
package/src/pipeline.ts CHANGED
@@ -203,95 +203,140 @@ export function applyAutoApproveOverrides(
203
203
 
204
204
  // ──────────────────────── runLayer3WithRacing(G3 abort 时序) ────────────────────────
205
205
 
206
+ /** 外层 abort 传播到内层 controller(session abort 时级联取消 AI + 用户审批)。 */
207
+ function linkOuterSignal(controller: AbortController, outerSignal: AbortSignal | undefined): void {
208
+ if (outerSignal) {
209
+ if (outerSignal.aborted) controller.abort();
210
+ else outerSignal.addEventListener("abort", () => controller.abort(), { once: true });
211
+ }
212
+ }
213
+
206
214
  /**
207
- * 层 3:AI Classifier + 用户审批竞速(移植 pi-permission-system classifier-racing.ts)。
208
- *
209
- * 竞速语义:
210
- * - 启动 AI 分类(aiPromise)+ 用户审批(userPromise)。
211
- * - 用户先返回 → controller.abort() 取消 AI(已无用的 AI 计算尽早终止)。
212
- * - AI 先返回:
213
- * - AI outcome='allow' → resolveUser({approved:false}) 关闭用户对话框,返回 allow。
214
- * - AI outcome='deny' → resolveUser({approved:false}) 关闭用户对话框,返回 deny。
215
- * - AI outcome='ask' → 不 resolveUser,等用户最终决策(AI 不确定时转人工)。
216
- *
217
- * G3 修正:requestUserApproval(TUI 分支)在调 ctx.ui.custom 前先检查 signal.aborted,
218
- * 避免 AI 先于 UI factory 执行时 controller.abort() 触发但 comp 尚未创建导致 cancel 落空。
219
- * 此处再叠一层:AI 赢且 outcome 是 allow/deny 时,先 abort 再 resolveUser,
220
- * 保证 UI factory 看到 aborted 状态短路。
221
- *
222
- * @param deps 注入依赖(classifier + requestUserApproval)
223
- * @param ctx 工具调用上下文
224
- * @param config classifier 配置
225
- * @param outerSignal 外层 signal(session abort 时传播)
226
- * @param trigger 审批触发原因(m2:AST 检测到的具体危险结构等;默认通用文案)
227
- * @returns PermissionDecision(source='ai' 或 'user')
215
+ * 启动 AI 分类 promise。
216
+ * M4:aiPromise 挂 .catch 转 fallback(确保永不 reject)。
217
+ * classifier 内部有 try/catch,但 resolveModel/buildModel/buildContext 在 try 外,
218
+ * 用户先返回 abort 后 classifier 晚 reject → race 已 settle → unhandledRejection。
228
219
  */
229
- export async function runLayer3WithRacing(
220
+ function startAiClassification(
230
221
  deps: CheckPermissionDeps,
231
222
  ctx: ToolInvocationContext,
232
223
  config: ClassifierConfig,
233
- outerSignal: AbortSignal | undefined,
234
- trigger: string = "awaiting approval (auto mode: AI classifier racing with user prompt)",
224
+ signal: AbortSignal,
225
+ ): Promise<ClassifierResult> {
226
+ return deps.classifier.classifyRisk(ctx, config, signal).catch((err: unknown) => {
227
+ const msg = err instanceof Error ? err.message : String(err);
228
+ return {
229
+ outcome: "ask" as const,
230
+ risk_level: "medium" as const,
231
+ reasoning: `classifier error: ${msg}`,
232
+ confidence: 0,
233
+ };
234
+ });
235
+ }
236
+
237
+ /** AI 判定 → PermissionDecision 信封(source='ai',透传 overridden 的 reasoning/风险/置信度)。 */
238
+ function aiDecision(action: PermissionAction, overridden: ClassifierResult): PermissionDecision {
239
+ return {
240
+ action,
241
+ reason: overridden.reasoning,
242
+ source: "ai",
243
+ riskLevel: overridden.risk_level,
244
+ confidence: overridden.confidence,
245
+ };
246
+ }
247
+
248
+ /**
249
+ * M1:headless 模式(json/print)下纯等 AI,不启动 user promise。
250
+ * 否则 requestHeadless 立即 deny 会抢占 race → AI 永远没机会赢 → auto 退化为 strict。
251
+ * AI 返回 allow/deny → 按结果返回;AI 返回 ask/超时/fail → fail-closed deny。
252
+ */
253
+ async function settleHeadlessAi(
254
+ aiPromise: Promise<ClassifierResult>,
255
+ controller: AbortController,
256
+ config: ClassifierConfig,
235
257
  ): Promise<PermissionDecision> {
236
- const controller = new AbortController();
237
- // 外层 abort 传播到内层
238
- if (outerSignal) {
239
- if (outerSignal.aborted) controller.abort();
240
- else outerSignal.addEventListener("abort", () => controller.abort(), { once: true });
258
+ const aiResult = await aiPromise;
259
+ const overridden = applyAutoApproveOverrides(aiResult, config);
260
+ if (overridden.outcome === "allow") {
261
+ controller.abort();
262
+ return aiDecision("allow", overridden);
263
+ }
264
+ if (overridden.outcome === "deny") {
265
+ controller.abort();
266
+ return aiDecision("deny", overridden);
241
267
  }
268
+ // AI ask → headless 无 UI 可问 → fail-closed deny
269
+ controller.abort();
270
+ return {
271
+ action: "deny",
272
+ reason: "headless mode: AI inconclusive (ask), fail-closed deny",
273
+ source: "ai",
274
+ riskLevel: overridden.risk_level,
275
+ confidence: overridden.confidence,
276
+ };
277
+ }
242
278
 
243
- // M4:aiPromise 挂 .catch 转 fallback(确保永不 reject)。
244
- // classifier 内部有 try/catch,但 resolveModel/buildModel/buildContext 在 try 外,
245
- // 用户先返回 abort 后 classifier 晚 reject → race 已 settle → unhandledRejection。
246
- const aiPromise = deps.classifier
247
- .classifyRisk(ctx, config, controller.signal)
248
- .catch((err: unknown) => {
249
- const msg = err instanceof Error ? err.message : String(err);
250
- return {
251
- outcome: "ask" as const,
252
- risk_level: "medium" as const,
253
- reasoning: `classifier error: ${msg}`,
254
- confidence: 0,
255
- };
256
- });
257
-
258
- // M1:headless 模式(json/print)下纯等 AI,不启动 user promise。
259
- // 否则 requestHeadless 立即 deny 会抢占 race → AI 永远没机会赢 → auto 退化为 strict。
260
- // AI 返回 allow/deny → 按结果返回;AI 返回 ask/超时/fail → fail-closed deny。
261
- if (deps.isHeadless()) {
262
- const aiResult = await aiPromise;
263
- const overridden = applyAutoApproveOverrides(aiResult, config);
264
- if (overridden.outcome === "allow") {
265
- controller.abort();
266
- return {
267
- action: "allow",
268
- reason: overridden.reasoning,
269
- source: "ai",
270
- riskLevel: overridden.risk_level,
271
- confidence: overridden.confidence,
272
- };
273
- }
274
- if (overridden.outcome === "deny") {
275
- controller.abort();
276
- return {
277
- action: "deny",
278
- reason: overridden.reasoning,
279
- source: "ai",
280
- riskLevel: overridden.risk_level,
281
- confidence: overridden.confidence,
282
- };
283
- }
284
- // AI ask → headless 无 UI 可问 → fail-closed deny
279
+ /**
280
+ * AI 赢(非 ask)分支:先 abort 再 resolveUser 关闭用户对话框,返回 AI 决策
281
+ * (G3:先 abort 再 resolveUser,保证 UI factory 看到 aborted 状态短路)。
282
+ * 返回 null = AI ask → 转人工(不 resolveUser,等用户最终决策)。
283
+ */
284
+ function settleAiWin(
285
+ overridden: ClassifierResult,
286
+ controller: AbortController,
287
+ resolveUser: (d: UserDecision) => void,
288
+ ): PermissionDecision | null {
289
+ if (overridden.outcome === "allow") {
290
+ // AI 放行 → 关闭用户对话框(若已弹出),返回 allow
285
291
  controller.abort();
286
- return {
287
- action: "deny",
288
- reason: "headless mode: AI inconclusive (ask), fail-closed deny",
289
- source: "ai",
290
- riskLevel: overridden.risk_level,
291
- confidence: overridden.confidence,
292
- };
292
+ resolveUser({ approved: false, reason: "AI classifier allowed; dialog dismissed" });
293
+ return aiDecision("allow", overridden);
294
+ }
295
+ if (overridden.outcome === "deny") {
296
+ controller.abort();
297
+ resolveUser({ approved: false, reason: "AI classifier denied; dialog dismissed" });
298
+ return aiDecision("deny", overridden);
293
299
  }
300
+ return null;
301
+ }
294
302
 
303
+ /**
304
+ * AI ask → 转 human(等用户最终决策,不关闭对话框)。
305
+ * M3:超时兜底防止永久挂起。若 AI 返回 ask 后用户恰好与 AI 在同一 tick 操作,
306
+ * comp.cancel() 因 _resolved 守卫不调 done → requestUserApproval promise 永不
307
+ * resolve → realUserPromise pending → 永久挂起(G5 串行化放大为全链卡死)。
308
+ * 5 分钟超时后 fail-closed 拒绝(不静默放行)。
309
+ */
310
+ async function awaitUserAfterAiAsk(
311
+ realUserPromise: Promise<UserDecision>,
312
+ ): Promise<PermissionDecision> {
313
+ const APPROVAL_TIMEOUT_MS = 300_000;
314
+ const userFinal = await Promise.race<UserDecision>([
315
+ realUserPromise,
316
+ new Promise<UserDecision>((resolve) =>
317
+ setTimeout(
318
+ () => resolve({ approved: false, reason: "approval dialog timeout (fail-closed)" }),
319
+ APPROVAL_TIMEOUT_MS,
320
+ ),
321
+ ),
322
+ ]);
323
+ const action: PermissionAction = userFinal.approved ? "allow" : "deny";
324
+ return {
325
+ action,
326
+ reason: userFinal.reason ?? (userFinal.approved ? "approved by user (after AI ask)" : "denied by user (after AI ask)"),
327
+ source: "user",
328
+ };
329
+ }
330
+
331
+ /** 竞速主体(TUI 形态):AI vs 用户并行,race 后按先 settle 的一侧出决策。 */
332
+ async function raceAiWithUser(
333
+ deps: CheckPermissionDeps,
334
+ ctx: ToolInvocationContext,
335
+ config: ClassifierConfig,
336
+ aiPromise: Promise<ClassifierResult>,
337
+ controller: AbortController,
338
+ trigger: string,
339
+ ): Promise<PermissionDecision> {
295
340
  // 用户审批 promise:可被 resolveUser 外部 resolve(AI 赢时关闭对话框)
296
341
  let resolveUser: (d: UserDecision) => void = () => {
297
342
  /* 占位,下方立即覆写 */
@@ -339,50 +384,60 @@ export async function runLayer3WithRacing(
339
384
 
340
385
  // AI 赢
341
386
  const overridden = applyAutoApproveOverrides(aiSettled.result, config);
342
- if (overridden.outcome === "allow") {
343
- // AI 放行 → 关闭用户对话框(若已弹出),返回 allow
344
- controller.abort();
345
- resolveUser({ approved: false, reason: "AI classifier allowed; dialog dismissed" });
346
- return {
347
- action: "allow",
348
- reason: overridden.reasoning,
349
- source: "ai",
350
- riskLevel: overridden.risk_level,
351
- confidence: overridden.confidence,
352
- };
387
+ const decided = settleAiWin(overridden, controller, resolveUser);
388
+ if (decided !== null) return decided;
389
+ return await awaitUserAfterAiAsk(realUserPromise);
390
+ }
391
+
392
+ /**
393
+ * 层 3:AI Classifier + 用户审批竞速(移植 pi-permission-system classifier-racing.ts)。
394
+ *
395
+ * 竞速语义:
396
+ * - 启动 AI 分类(aiPromise)+ 用户审批(userPromise)。
397
+ * - 用户先返回 → controller.abort() 取消 AI(已无用的 AI 计算尽早终止)。
398
+ * - AI 先返回:
399
+ * - AI outcome='allow' → resolveUser({approved:false}) 关闭用户对话框,返回 allow。
400
+ * - AI outcome='deny' → resolveUser({approved:false}) 关闭用户对话框,返回 deny。
401
+ * - AI outcome='ask' → 不 resolveUser,等用户最终决策(AI 不确定时转人工)。
402
+ *
403
+ * G3 修正:requestUserApproval(TUI 分支)在调 ctx.ui.custom 前先检查 signal.aborted,
404
+ * 避免 AI 先于 UI factory 执行时 controller.abort() 触发但 comp 尚未创建导致 cancel 落空。
405
+ * 此处再叠一层:AI 赢且 outcome 是 allow/deny 时,先 abort 再 resolveUser,
406
+ * 保证 UI factory 看到 aborted 状态短路。
407
+ *
408
+ * classifier.enabled=false:AI 层被用户显式关闭 → 跳过 classifier 与 racing,直接人工审批
409
+ * (复用 askUser 路径)。headless 下 requestUserApproval 走 requestHeadless 立即 fail-closed
410
+ * deny,与 AI 判定 ask 时的短路 deny 语义一致。
411
+ *
412
+ * @param deps 注入依赖(classifier + requestUserApproval)
413
+ * @param ctx 工具调用上下文
414
+ * @param config classifier 配置
415
+ * @param outerSignal 外层 signal(session abort 时传播)
416
+ * @param trigger 审批触发原因(m2:AST 检测到的具体危险结构等;默认通用文案)
417
+ * @returns PermissionDecision(source='ai' 或 'user')
418
+ */
419
+ export async function runLayer3WithRacing(
420
+ deps: CheckPermissionDeps,
421
+ ctx: ToolInvocationContext,
422
+ config: ClassifierConfig,
423
+ outerSignal: AbortSignal | undefined,
424
+ trigger: string = "awaiting approval (auto mode: AI classifier racing with user prompt)",
425
+ ): Promise<PermissionDecision> {
426
+ // classifier.enabled=false → AI 层关闭,直接人工审批(不启动 AI promise,不进入 racing)
427
+ if (config.enabled === false) {
428
+ return await askUser(deps, ctx, "AI classifier disabled (classifier.enabled=false)", outerSignal);
353
429
  }
354
- if (overridden.outcome === "deny") {
355
- controller.abort();
356
- resolveUser({ approved: false, reason: "AI classifier denied; dialog dismissed" });
357
- return {
358
- action: "deny",
359
- reason: overridden.reasoning,
360
- source: "ai",
361
- riskLevel: overridden.risk_level,
362
- confidence: overridden.confidence,
363
- };
430
+ const controller = new AbortController();
431
+ // 外层 abort 传播到内层
432
+ linkOuterSignal(controller, outerSignal);
433
+
434
+ const aiPromise = startAiClassification(deps, ctx, config, controller.signal);
435
+
436
+ // M1:headless 模式下纯等 AI(不启动 user promise)
437
+ if (deps.isHeadless()) {
438
+ return await settleHeadlessAi(aiPromise, controller, config);
364
439
  }
365
- // AI ask → 转 human(等用户最终决策,不关闭对话框)
366
- // M3:超时兜底防止永久挂起。若 AI 返回 ask 后用户恰好与 AI 在同一 tick 操作,
367
- // comp.cancel() 因 _resolved 守卫不调 done → requestUserApproval promise 永不
368
- // resolve → realUserPromise pending → 永久挂起(G5 串行化放大为全链卡死)。
369
- // 5 分钟超时后 fail-closed 拒绝(不静默放行)。
370
- const APPROVAL_TIMEOUT_MS = 300_000;
371
- const userFinal = await Promise.race<UserDecision>([
372
- realUserPromise,
373
- new Promise<UserDecision>((resolve) =>
374
- setTimeout(
375
- () => resolve({ approved: false, reason: "approval dialog timeout (fail-closed)" }),
376
- APPROVAL_TIMEOUT_MS,
377
- ),
378
- ),
379
- ]);
380
- const action: PermissionAction = userFinal.approved ? "allow" : "deny";
381
- return {
382
- action,
383
- reason: userFinal.reason ?? (userFinal.approved ? "approved by user (after AI ask)" : "denied by user (after AI ask)"),
384
- source: "user",
385
- };
440
+ return await raceAiWithUser(deps, ctx, config, aiPromise, controller, trigger);
386
441
  }
387
442
 
388
443
  // ──────────────────────── checkPermission(纯函数主入口) ────────────────────────
@@ -392,7 +447,8 @@ export async function runLayer3WithRacing(
392
447
  *
393
448
  * 四档模式分支:
394
449
  * - yolo:完全放行(return allow,source='mode')。不跑任何层。
395
- * - auto:AST → 规则(allow 通过 / deny 拒绝 / ask → 层 3 Racing AI+用户)。
450
+ * - auto:AST → 规则(allow 通过 / deny 拒绝 / ask → 层 3 Racing AI+用户;
451
+ * classifier.enabled=false 时 ask → 直接人工审批,无 AI)。
396
452
  * - approve:AST → 规则(allow 通过 / deny+ask → 人工审批,无 AI)。
397
453
  * - strict:全部人工审批(不跑 AST/规则/AI)。
398
454
  *
package/src/production.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  * 生产装配层(W5 集成层)。
3
3
  *
4
4
  * P3 收口:classifier 的 model 解析改走 llm-shared resolveModel(三源合并含 OAuth/env/auth.json),
5
- * 废弃自读 models.json。'auto' 保留作 scoped 别名(向后兼容),scoped 空时 fallback
6
- * available(CL-scoped-fallback)。
5
+ * 废弃自读 models.json。'auto' 由 permission 本地处理:直接取 ctx.modelRegistry.getAvailable()[0]
6
+ * (不经过 llm-shared 的非精确 selector),与下方 createProductionClassifier JSDoc 一致。
7
7
  *
8
8
  * C1a 收口:classifier 的 LLM 调用改走 llm-shared callLLM(凭证 getApiKeyAndHeaders +
9
9
  * completeSimple + stopReason 归一化都在 callLLM 内部完成),删除 getApiProvider +
@@ -8,7 +8,7 @@
8
8
  * G8:build 不生成 id。
9
9
  * G9:tool 只列 bash。
10
10
  * G13:sessionIdCounter 立即赋值真实 id。
11
- * WR8:focusIndex 模 5(Custom form 5 项 Tab 循环)。
11
+ * WR8:focusIndex 以 customChildren.length 为模(New 模式 5 项、Edit 模式 6 项 Tab 循环)。
12
12
  */
13
13
 
14
14
  import { type Component, Container, Input, isFocusable, type SelectItem, SelectList, type SelectListTheme, truncateToWidth } from "@earendil-works/pi-tui";
@@ -23,16 +23,7 @@ import {
23
23
  type TemplateSelections,
24
24
  } from "./rule-templates.js";
25
25
  import type { PermissionAction, Rule } from "./types.js";
26
-
27
- // ──────────────────────── DEFAULT_SELECT_THEME ────────────────────────
28
-
29
- const THEME: SelectListTheme = {
30
- selectedPrefix: (t: string): string => "\u25B6 " + t,
31
- selectedText: (t: string): string => t,
32
- description: (t: string): string => t,
33
- scrollInfo: (t: string): string => t,
34
- noMatch: (t: string): string => t,
35
- };
26
+ import { DEFAULT_SELECT_THEME } from "./select-theme.js";
36
27
 
37
28
  const MAX_VISIBLE = 12;
38
29
 
@@ -111,7 +102,7 @@ export class RuleEditorComponent extends Container {
111
102
  /** deny-family 子阶段:'cmd' = 选命令,'subcmd' = 选子命令。 */
112
103
  private denySubStage: "cmd" | "subcmd" = "cmd";
113
104
 
114
- /** Custom form:5 项焦点索引(WR8:focusIndex 模 5)。 */
105
+ /** Custom form 焦点索引(WR8:Tab 循环以 customChildren.length 为模)。 */
115
106
  private customFocusIndex = 0;
116
107
  private customChildren: Component[] = [];
117
108
 
@@ -124,7 +115,7 @@ export class RuleEditorComponent extends Container {
124
115
  initialRules: readonly Rule[],
125
116
  sessionIdCounter: () => string,
126
117
  done: (ops: RuleOp[]) => void,
127
- theme: SelectListTheme = THEME,
118
+ theme: SelectListTheme = DEFAULT_SELECT_THEME,
128
119
  ) {
129
120
  super();
130
121
  this.initialRules = initialRules;
@@ -244,7 +244,7 @@ async function rpcSelectCommand(
244
244
  return preset?.cmd ?? cmdChoice;
245
245
  }
246
246
 
247
- /** RPC edit flow:选择 action(edit fields / delete)。 */
247
+ /** RPC edit flow:仅支持删除已有规则(Delete / Cancel,不支持改字段)。 */
248
248
  async function rpcEditFlow(
249
249
  select: (title: string, options: string[]) => Promise<string | undefined>,
250
250
  _input: (title: string, placeholder?: string) => Promise<string | undefined>,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * W8: Rule templates + preset commands + applyOps + classifyRuleTemplate.
2
+ * W8: Rule templates + preset commands + applyOps.
3
3
  *
4
4
  * G4:pattern 用 wildcard(`npm *`),不用正则(`\bnpm\b`)。
5
5
  * W3 matcher 对 user 规则走 `wildcardToRegExp(pattern)`,末尾 ` *` 匹配无参 + 有参。
@@ -85,20 +85,6 @@ export const PRESET_COMMANDS: readonly PresetCommand[] = [
85
85
  { cmd: "date", label: "date (system date)", category: "System & Info" },
86
86
  ] as const;
87
87
 
88
- /** PRESET_COMMANDS 按 category 分组(UI 展示用)。 */
89
- export function presetCommandsByCategory(): Map<string, PresetCommand[]> {
90
- const map = new Map<string, PresetCommand[]>();
91
- for (const cmd of PRESET_COMMANDS) {
92
- const list = map.get(cmd.category);
93
- if (list) {
94
- list.push(cmd);
95
- } else {
96
- map.set(cmd.category, [cmd]);
97
- }
98
- }
99
- return map;
100
- }
101
-
102
88
  // ──────────────────────── RuleTemplate ────────────────────────
103
89
 
104
90
  /** 模板 selections 值类型(各字段按模板分支含义不同)。 */
@@ -234,43 +220,6 @@ export const ALL_TEMPLATES: readonly RuleTemplate[] = [
234
220
  customTemplate,
235
221
  ] as const;
236
222
 
237
- // ──────────────────────── classifyRuleTemplate ────────────────────────
238
-
239
- /**
240
- * FAMILY_RE:`<cmd> *` 形式(allow-family / deny-family any / ask-before)。
241
- * SUBCMD_RE:`<cmd> <subcmd> *` 形式(deny-family specific / allow-subcmd)。
242
- */
243
- const FAMILY_RE = /^([^ *?]+) \*$/;
244
- const SUBCMD_RE = /^([^ *?]+) ([^ *?]+) \*$/;
245
-
246
- /**
247
- * 根据 rule 的 pattern + action 推断其模板 id。
248
- * 无法推断时返回 undefined(如 Custom 规则)。
249
- */
250
- export function classifyRuleTemplate(rule: Rule): string | undefined {
251
- const { pattern, action } = rule;
252
-
253
- const subMatch = SUBCMD_RE.exec(pattern);
254
- if (subMatch !== null) {
255
- // `<cmd> <subcmd> *`
256
- if (action === "deny") return "deny-family";
257
- if (action === "allow") return "allow-subcmd";
258
- return undefined;
259
- }
260
-
261
- const famMatch = FAMILY_RE.exec(pattern);
262
- if (famMatch !== null) {
263
- // `<cmd> *`
264
- if (action === "allow") return "allow-family";
265
- if (action === "deny") return "deny-family";
266
- if (action === "ask") return "ask-before";
267
- return undefined;
268
- }
269
-
270
- // 不匹配任何已知 pattern 格式 → custom
271
- return "custom";
272
- }
273
-
274
223
  // ──────────────────────── applyOps ────────────────────────
275
224
 
276
225
  /** RuleOp discriminated union(G13:add 时 rule.id 已是真实 user-N)。 */
@@ -13,7 +13,7 @@
13
13
  * 用 `new RegExp(pattern, 'i')` 编译;其余(builtin-safe/user)是 OpenCode
14
14
  * wildcard,用 `wildcardToRegExp(pattern)` 编译。resolvePattern 按 source 分发。
15
15
  *
16
- * ── G3: 24+5 白名单用函数(C3 修正:不再短路)──
16
+ * ── G3: 50+9 白名单用函数(C3 修正:不再短路)──
17
17
  * matchRulesForArgv 先遍历 rules(last-match-wins);若无 deny/allow 命中(ask),
18
18
  * 再查 isKnownSafeCommand(argv) 兜底返回 allow。白名单不再短路:用户 deny 规则
19
19
  * 可覆盖白名单。白名单不进 Rule[] 数组(Codex flag 子检查无法用单条 wildcard 表达)。
@@ -0,0 +1,15 @@
1
+ /**
2
+ * SelectList 默认主题(model-picker 与 rule-editor 共享单一常量,避免字面量双份漂移)。
3
+ *
4
+ * G2/WR2 修正:selectedPrefix 实现为 `(t) => '▶ ' + t`(非 identity),
5
+ * 选中行有视觉区分(▶ 前缀)。
6
+ */
7
+ import type { SelectListTheme } from "@earendil-works/pi-tui";
8
+
9
+ export const DEFAULT_SELECT_THEME: SelectListTheme = {
10
+ selectedPrefix: (t: string): string => "\u25B6 " + t,
11
+ selectedText: (t: string): string => t,
12
+ description: (t: string): string => t,
13
+ scrollInfo: (t: string): string => t,
14
+ noMatch: (t: string): string => t,
15
+ };
@@ -24,7 +24,8 @@ export interface PermissionPalette {
24
24
  * theme.fg(token, text) 是 Pi 的语义着色 API(token 如 "dim"/"text"/"success")。
25
25
  * 本地包装为 PermissionPalette 接口,便于 footer renderer 与测试共用纯函数。
26
26
  *
27
- * @param theme Pi Theme 对象(ctx.ui.theme,需含 fg 函数)
27
+ * @param theme Pi Theme 对象(需含 fg 函数)。不由 ctx.ui.theme 提供——由 pi-statusline
28
+ * 每次 render 时经 render(ctx, theme) 第二参传入(见 index.ts footer renderer 装配)。
28
29
  */
29
30
  export function paletteFromTheme(theme: { fg(token: string, text: string): string }): PermissionPalette {
30
31
  return {