@trim21/personal-pi-extensions 0.1.628 → 0.1.631

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.628",
3
+ "version": "0.1.631",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -3,9 +3,11 @@
3
3
  * 用 tree-sitter 解析命令(含嵌套 `$(...)`),对每条命令的原文做通配
4
4
  * 匹配,对 allow/deny 规则求值。规则匹配不经过任何命令归一——BashArity
5
5
  * 归一模式只用于 "allow forever" 的建议规则(见 approval-suggest.ts)。
6
- * 接入 bwrap 的 `dangerouslyDisableSandbox` 审批:命中规则自动
7
- * 放行/拒绝,未命中才弹审批对话框。文件输出重定向(`>` / `>>` / `&>`
8
- * 等)不会因命令规则自动放行,避免 `echo *` 把 `echo '' > file` 带过。
6
+ * 接入 bwrap 的 `dangerouslyDisableSandbox` 审批:审批规则集
7
+ * (createApprovalRuleSet)独占「自动放行 / 自动拒绝 / 交人审」的判定、
8
+ * allow 覆盖查询、待允许模式与规则追加,调用方只描述用户动作。
9
+ * 文件输出重定向(`>` / `>>` / `&>` 等)不会因命令规则自动放行,
10
+ * 避免 `echo *` 把 `echo '' > file` 带过。
9
11
  *
10
12
  * 参考实现:
11
13
  * - opencode packages/core/src/util/wildcard.ts(通配匹配)
@@ -209,27 +211,17 @@ export function matchRule(input: string, pattern: string): boolean {
209
211
  return new RegExp(`^${escaped}$`, "s").test(input);
210
212
  }
211
213
 
212
- // ── 规则求值 ────────────────────────────────────────────────────────────────
214
+ // ── 命令树展平 ──────────────────────────────────────────────────────────────
213
215
 
214
216
  /**
215
- * 对命令(含所有嵌套命令)求值:
216
- * - deny 优先:任一命令命中 deny 规则即整体拒绝
217
- * - 文件输出重定向不自动放行:即使命令规则全匹配,也返回 undefined 交人审
218
- * - allow 需全量:所有命令都命中 allow 规则才整体放行,否则返回
219
- * undefined(有命令未命中规则,交给人审),避免未允许的命令被同链放行带过。
220
- * - 匹配输入是命令原文(tree-sitter command 节点 text,含嵌套逐条展开),
221
- * 对齐 opencode shell.ts 的 patterns;通配规则按字面写,`--` 与普通
222
- * token 无区别。
223
- * 规则内后写优先(findLast,对齐 opencode PermissionV2)。
217
+ * 展平命令树:每条命令本身 + 其所有嵌套命令(`$(...)` 内的),父在子前。
218
+ * 求值与建议模式生成(approval-suggest.ts)共用——后者依赖本模块,
219
+ * 反向 import 会成环,所以展平放在这里。
224
220
  */
225
- export async function evaluateBashApproval(
226
- command: string,
227
- rules: readonly ApprovalRule[],
228
- ): Promise<ApprovalAction | undefined> {
229
- const parsed = await parseBashCommands(command);
230
- const raws: string[] = [];
221
+ export function flattenCommands(parsed: ParsedBash): BashCommand[] {
222
+ const flat: BashCommand[] = [];
231
223
  const visit = (cmd: BashCommand) => {
232
- raws.push(cmd.raw);
224
+ flat.push(cmd);
233
225
  for (const nested of cmd.nested) {
234
226
  visit(nested);
235
227
  }
@@ -237,21 +229,101 @@ export async function evaluateBashApproval(
237
229
  for (const cmd of parsed.commands) {
238
230
  visit(cmd);
239
231
  }
240
- if (raws.length === 0) {
241
- return;
232
+ return flat;
233
+ }
234
+
235
+ // ── 审批规则集 ──────────────────────────────────────────────────────────────
236
+
237
+ export interface ApprovalRuleSetOptions {
238
+ /** 当前规则(读取时取最新,追加后无需重建规则集)。规则数组即优先级:靠后优先。 */
239
+ rules: () => readonly ApprovalRule[];
240
+ /** 命令 → 候选模式(BashArity);注入以免与 approval-suggest.ts 成环。 */
241
+ suggestPatterns: (command: string) => Promise<readonly string[]>;
242
+ /** 追加 allow 规则并持久化;resolve 后 rules() 必须能看到它们,抛错即视为未追加。 */
243
+ persist: (rules: readonly ApprovalRule[]) => Promise<void>;
244
+ }
245
+
246
+ export interface ApprovalRuleSet {
247
+ /**
248
+ * 对命令(含所有嵌套命令)求值:deny 优先 / allow 需全量 / 含文件输出
249
+ * 重定向不自动放行。返回 undefined 表示未命中规则,交人工审批。
250
+ */
251
+ evaluate(command: string): Promise<ApprovalAction | undefined>;
252
+ /**
253
+ * 该字符串(命令原文或候选模式)是否被最后一条匹配的规则允许。
254
+ * 只回答 allow 覆盖,不考虑 deny 与重定向——放行判定一律用 evaluate。
255
+ */
256
+ isAllowed(pattern: string): boolean;
257
+ /** 命令的候选模式(去重)中尚未被 allow 规则覆盖的那些;审批子菜单的唯一来源。 */
258
+ pendingPatterns(command: string): Promise<string[]>;
259
+ /** 追加 allow 规则并持久化;persist 成功后立即生效。 */
260
+ addAllowRules(patterns: readonly string[]): Promise<void>;
261
+ }
262
+
263
+ /**
264
+ * 审批规则集:命令自动判定、allow 覆盖查询、待允许模式与规则追加的唯一 owner。
265
+ * 规则数组的顺序即优先级:靠后优先(项目规则经 deepMerge 排在全局规则之后)。
266
+ * 三条协作方由调用方注入:规则 getter(reload 或追加后无需重建规则集)、
267
+ * 候选模式生成、持久化副作用(文件读写留在调用方)。
268
+ */
269
+ export function createApprovalRuleSet(options: ApprovalRuleSetOptions): ApprovalRuleSet {
270
+ const { rules, suggestPatterns, persist } = options;
271
+
272
+ /** 最后一条匹配 input 的规则;数组靠后者优先(对齐 opencode PermissionV2)。 */
273
+ function lastMatch(input: string): ApprovalRule | undefined {
274
+ return rules().findLast((rule) => matchRule(input, rule.pattern));
275
+ }
276
+
277
+ function isAllowed(pattern: string): boolean {
278
+ return lastMatch(pattern)?.action === "allow";
242
279
  }
243
- let allowed = 0;
244
- for (const raw of raws) {
245
- const rule = rules.findLast((r) => matchRule(raw, r.pattern));
246
- if (rule?.action === "deny") {
247
- return "deny";
280
+
281
+ async function evaluate(command: string): Promise<ApprovalAction | undefined> {
282
+ const parsed = await parseBashCommands(command);
283
+ // 匹配输入是命令原文(tree-sitter command 节点 text,含嵌套逐条展开),
284
+ // 对齐 opencode shell.ts 的 patterns;通配规则按字面写,`--` 与普通 token 无区别。
285
+ const raws = flattenCommands(parsed).map((cmd) => cmd.raw);
286
+ if (raws.length === 0) {
287
+ return; // 空命令或解析失败:没有可匹配的命令,交人审
288
+ }
289
+ let allowed = 0;
290
+ for (const raw of raws) {
291
+ const rule = lastMatch(raw);
292
+ if (rule?.action === "deny") {
293
+ return "deny";
294
+ }
295
+ if (rule?.action === "allow") {
296
+ allowed++;
297
+ }
298
+ }
299
+ // 文件输出重定向不自动放行:即使命令规则全匹配也交人审
300
+ if (parsed.hasFileOutputRedirect) {
301
+ return;
248
302
  }
249
- if (rule?.action === "allow") {
250
- allowed++;
303
+ // allow 需全量:有命令未命中规则时交人审,避免未允许的命令被同链放行带过
304
+ return allowed === raws.length ? "allow" : undefined;
305
+ }
306
+
307
+ async function pendingPatterns(command: string): Promise<string[]> {
308
+ const pending: string[] = [];
309
+ const seen = new Set<string>();
310
+ for (const pattern of await suggestPatterns(command)) {
311
+ if (seen.has(pattern) || isAllowed(pattern)) {
312
+ continue;
313
+ }
314
+ seen.add(pattern);
315
+ pending.push(pattern);
251
316
  }
317
+ return pending;
252
318
  }
253
- if (parsed.hasFileOutputRedirect) {
254
- return;
319
+
320
+ async function addAllowRules(patterns: readonly string[]): Promise<void> {
321
+ const additions: ApprovalRule[] = patterns.map((pattern) => ({ action: "allow", pattern }));
322
+ if (additions.length === 0) {
323
+ return; // 解析失败或未勾选:本次处理,不写配置
324
+ }
325
+ await persist(additions);
255
326
  }
256
- return allowed === raws.length ? "allow" : undefined;
327
+
328
+ return { evaluate, isAllowed, pendingPatterns, addAllowRules };
257
329
  }
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { readFileSync } from "node:fs";
10
10
 
11
- import { type BashCommand, parseBashCommands } from "./approval-rules.js";
11
+ import { type BashCommand, flattenCommands, parseBashCommands } from "./approval-rules.js";
12
12
 
13
13
  /**
14
14
  * 命令前缀 → 定义该命令的 token 数。`git checkout main` → `git` 的 arity 2,
@@ -41,21 +41,7 @@ export function commandPattern(command: BashCommand): string {
41
41
  }
42
42
 
43
43
  /** 命令(含所有嵌套命令)的 BashArity 建议模式列表(命令替换里的命令也展开)。 */
44
- function patternsFromCommands(commands: BashCommand[]): string[] {
45
- const flat: string[] = [];
46
- const visit = (cmd: BashCommand) => {
47
- flat.push(commandPattern(cmd));
48
- for (const nested of cmd.nested) {
49
- visit(nested);
50
- }
51
- };
52
- for (const cmd of commands) {
53
- visit(cmd);
54
- }
55
- return flat;
56
- }
57
-
58
44
  export async function commandPatternsFor(command: string): Promise<string[]> {
59
45
  const parsed = await parseBashCommands(command);
60
- return patternsFromCommands(parsed.commands);
46
+ return flattenCommands(parsed).map((cmd) => commandPattern(cmd));
61
47
  }
@@ -14,17 +14,24 @@ import {
14
14
  } from "@earendil-works/pi-coding-agent";
15
15
  import { throttle } from "lodash-es";
16
16
  import { type TObject, Type } from "typebox";
17
+ import { Value } from "typebox/value";
17
18
 
18
19
  import { type CommandSpec, parseCommand } from "../lib/cli.js";
19
20
  import { fenceCodeBlock } from "../lib/markdown.js";
20
21
  import { isUnknownArray } from "../lib/narrow.js";
22
+ import { parseWithSchema } from "../lib/parse-with-schema.js";
21
23
  import { formatDisplayPath } from "../lib/path.js";
22
24
  import { createRequestPolicy, type RequestPolicy } from "../lib/request-policy.js";
23
25
  import { type SelectAction, selectMultiple, selectWithOptionalInput } from "../lib/ui.js";
24
- import { type ApprovalRule, evaluateBashApproval, matchRule } from "./approval-rules.js";
26
+ import {
27
+ type ApprovalRule,
28
+ type ApprovalRuleSet,
29
+ createApprovalRuleSet,
30
+ } from "./approval-rules.js";
25
31
  import { commandPatternsFor } from "./approval-suggest.js";
26
32
  import {
27
33
  type BwrapConfig,
34
+ bwrapConfigFileSchema,
28
35
  findBwrap,
29
36
  findMihomo,
30
37
  type FsMode,
@@ -381,6 +388,20 @@ export function sandboxHintBlock(hint: string | undefined): { type: "text"; text
381
388
  return hint === undefined ? [] : [{ type: "text", text: hint }];
382
389
  }
383
390
 
391
+ /**
392
+ * 项目配置文件形状非法时的错误:消息带文件路径与字段位置,cause 保留校验错误
393
+ * (Value.Check 只回答是否合法,详情用同一 schema 解析一次取到)。
394
+ */
395
+ function invalidConfigError(path: string, raw: unknown): Error {
396
+ try {
397
+ parseWithSchema(bwrapConfigFileSchema, raw);
398
+ } catch (error) {
399
+ const detail = error instanceof Error ? error.message : String(error);
400
+ return new Error(`Invalid bwrap configuration at ${path}: ${detail}`, { cause: error });
401
+ }
402
+ return new Error(`Invalid bwrap configuration at ${path}: schema validation failed`);
403
+ }
404
+
384
405
  export class BwrapRuntime {
385
406
  private resolved: ResolvedBwrap | undefined;
386
407
  private bwrapUnavailable = false;
@@ -539,8 +560,14 @@ export class BwrapRuntime {
539
560
  ) {
540
561
  throw new Error(UNSANDBOXED_DENIED);
541
562
  }
542
- // 先按 approvalRules 自动判定:allow 直接放行,deny 直接拒绝,未命中才弹框
543
- const decision = await evaluateBashApproval(request.command, runtime.approvalRules);
563
+ // 先按 approvalRules 自动判定:allow 直接放行,deny 直接拒绝,未命中才弹框。
564
+ // 规则集跟随 this.resolved,reload 或追加规则后无需重建
565
+ const ruleSet = createApprovalRuleSet({
566
+ rules: () => this.resolve(request.ctx).approvalRules,
567
+ suggestPatterns: commandPatternsFor,
568
+ persist: (rules) => this.persistAllowRules(request.ctx, rules),
569
+ });
570
+ const decision = await ruleSet.evaluate(request.command);
544
571
  if (decision === "deny") {
545
572
  throw new Error(`Command denied by bwrap approval rule: ${request.command}`);
546
573
  }
@@ -551,6 +578,7 @@ export class BwrapRuntime {
551
578
  request.command,
552
579
  request.description,
553
580
  execCwd,
581
+ ruleSet,
554
582
  )) === "sandbox";
555
583
  }
556
584
  }
@@ -686,12 +714,13 @@ export class BwrapRuntime {
686
714
  command: string,
687
715
  reason: string | undefined,
688
716
  execCwd: string,
717
+ ruleSet: ApprovalRuleSet,
689
718
  ): Promise<FullAccessGrant> {
690
719
  // hasUI 判定推迟到审批时刻:无 UI 会话弹不了审批框,按用户点 Deny 的标准文案拒绝
691
720
  if (!ctx.hasUI) {
692
721
  throw new Error(UNSANDBOXED_DENIED);
693
722
  }
694
- const decision = await this.approveFullAccessUI(ctx, command, reason, execCwd);
723
+ const decision = await this.approveFullAccessUI(ctx, command, reason, execCwd, ruleSet);
695
724
  // 关闭对话框 = 中断并拒绝,不循环重问
696
725
  if (decision === undefined) {
697
726
  ctx.abort();
@@ -706,13 +735,13 @@ export class BwrapRuntime {
706
735
  }
707
736
  case DENY: {
708
737
  if (foreverApprovedPattern.length > 0) {
709
- await this.persistAllowRule(ctx, command, foreverApprovedPattern);
738
+ await ruleSet.addAllowRules(foreverApprovedPattern);
710
739
  }
711
740
  throw new Error(UNSANDBOXED_DENIED);
712
741
  }
713
742
  case DENY_WITH_REASON: {
714
743
  if (foreverApprovedPattern.length > 0) {
715
- await this.persistAllowRule(ctx, command, foreverApprovedPattern);
744
+ await ruleSet.addAllowRules(foreverApprovedPattern);
716
745
  }
717
746
  const feedback = decision.reason?.trim() ?? "";
718
747
  throw new Error(
@@ -723,7 +752,7 @@ export class BwrapRuntime {
723
752
  }
724
753
  case ALLOW_ONCE: {
725
754
  if (foreverApprovedPattern.length > 0) {
726
- await this.persistAllowRule(ctx, command, foreverApprovedPattern);
755
+ await ruleSet.addAllowRules(foreverApprovedPattern);
727
756
  }
728
757
  return "full-access";
729
758
  }
@@ -744,22 +773,14 @@ export class BwrapRuntime {
744
773
  command: string,
745
774
  reason: string | undefined,
746
775
  execCwd: string,
776
+ ruleSet: ApprovalRuleSet,
747
777
  ): Promise<FullAccessUIDecision | undefined> {
748
778
  // 弹框前解析命令的持久化规则:`echo 1 | head` → `echo *`、`head *`。
749
779
  // 持久化规则的勾选折叠进 EDIT_RULES 子菜单,主决策列表只保留放行/拒绝,
750
780
  // 避免一屏 checkbox 淹没决策项。
751
- const patterns = await commandPatternsFor(command);
752
781
  // 子菜单只列出未命中 allow 规则的 pattern:已提前允许的部分自动放行,
753
782
  // 无需再展示或重复勾选持久化(deny 命中的命令在 evaluate 阶段已被拒绝)。
754
- const rules = this.resolve(ctx).approvalRules;
755
- const unallowedPatterns = [
756
- ...new Set(
757
- patterns.filter((pattern) => {
758
- const rule = rules.findLast((r) => matchRule(pattern, r.pattern));
759
- return rule?.action !== "allow";
760
- }),
761
- ),
762
- ];
783
+ const unallowedPatterns = await ruleSet.pendingPatterns(command);
763
784
  // dcg 扫描建议是可选的参考文本:未安装时静默跳过;已安装但扫描失败
764
785
  // 时 notify 提示,弹窗本身与无 dcg 时一致
765
786
  const outcome = await dcgSuggestion(command);
@@ -837,23 +858,27 @@ export class BwrapRuntime {
837
858
  }
838
859
  }
839
860
 
840
- /** 把命令的权限模式写入项目 sandbox.json 的 approvalRules(allow forever)。 */
841
- private async persistAllowRule(
861
+ /**
862
+ * 规则集的持久化实现:把勾选的 allow 规则追加进项目 sandbox.json 的
863
+ * approvalRules,并刷新缓存的规则使其立即生效。
864
+ */
865
+ private async persistAllowRules(
842
866
  ctx: ExtensionContext,
843
- command: string,
844
- patterns?: string[],
867
+ newRules: readonly ApprovalRule[],
845
868
  ): Promise<void> {
846
- const rulePatterns = patterns ?? (await commandPatternsFor(command));
847
- if (rulePatterns.length === 0) {
848
- return; // 解析失败:本次处理,不写规则
849
- }
850
- const newRules: ApprovalRule[] = rulePatterns.map((pattern) => ({ action: "allow", pattern }));
851
869
  const { project } = getBwrapConfigPaths(ctx.cwd);
852
870
  let config: Record<string, unknown> = {};
853
871
  if (existsSync(project)) {
854
- config = JSON.parse(readFileSync(project, "utf8")) as Record<string, unknown>;
872
+ const raw: unknown = JSON.parse(readFileSync(project, "utf8"));
873
+ // 会话开始时已校验过配置文件:这里再查一次形状(Value.Check 同时收窄类型),
874
+ // 避免把新规则追加进一个后续加载必然失败的文件(只在配置文件被中途改坏时可达)
875
+ if (!Value.Check(bwrapConfigFileSchema, raw)) {
876
+ throw invalidConfigError(project, raw);
877
+ }
878
+ config = raw;
855
879
  }
856
- // 既有规则按不透明值原样保留(形状不认识也不丢),只追加本次允许的规则。
880
+ // 既有规则按不透明值原样保留(形状不认识也不丢),只追加本次允许的规则;
881
+ // 与规则无关的字段(含不认识的)同样原样写回,不经过 schema 往返
857
882
  const existing = isUnknownArray(config.approvalRules) ? config.approvalRules : [];
858
883
  config.approvalRules = [...existing, ...newRules];
859
884
  await mkdir(dirname(project), { recursive: true });
@@ -1123,6 +1123,56 @@ export async function create(input: CreateInput): Promise<LspClient> {
1123
1123
  });
1124
1124
  }
1125
1125
 
1126
+ /** 轮询的归宿:谁先让等待收敛,以及没有收敛时是哪一种耗尽。 */
1127
+ type PollOutcome = "pulled" | "pushed" | "pullTimedOut" | "budgetExhausted";
1128
+
1129
+ /**
1130
+ * 等「当前文档版本」的诊断就绪,两种等待模式的唯一一份轮询实现。
1131
+ * pull 与 push 语义相同(都是等当前文档版本的诊断结果):先 pull,拿到就绪结论
1132
+ * 即返回;pull 超时说明服务器未响应,不再重试 pull,把「是否退回等 push」交给
1133
+ * 调用方;否则进入三路竞速——版本匹配的 push / 服务器注册表变化 / pull 重试间隔,
1134
+ * 除 push 命中外都再来一轮,直到预算耗尽、连接关闭或等待被中断。
1135
+ */
1136
+ async function pollUntilSettled(request: {
1137
+ path: string;
1138
+ /** 本次等待的预算(毫秒)。 */
1139
+ budgetMs: number;
1140
+ /** 预算起点(调用方传 request.after ?? Date.now())。 */
1141
+ startedAt: number;
1142
+ /** 版本匹配的 push 兜底,由调用方按自己的预算创建。 */
1143
+ pushWait: Promise<boolean>;
1144
+ /** pull 入口(两种模式各自的那一个)。 */
1145
+ pull: (path: string) => Promise<PullResult>;
1146
+ /** 该模式的就绪判据。 */
1147
+ isSettled: (result: PullResult) => boolean;
1148
+ signal?: AbortSignal;
1149
+ }): Promise<PollOutcome> {
1150
+ while (!connectionClosed && !request.signal?.aborted) {
1151
+ const remaining = request.budgetMs - (Date.now() - request.startedAt);
1152
+ if (remaining <= 0) {
1153
+ return "budgetExhausted";
1154
+ }
1155
+ const result = await request.pull(request.path);
1156
+ if (request.isSettled(result)) {
1157
+ return "pulled";
1158
+ }
1159
+ if (result.timedOut) {
1160
+ return "pullTimedOut";
1161
+ }
1162
+ const next = await Promise.race([
1163
+ request.pushWait.then((ready) => (ready ? ("push" as const) : ("timeout" as const))),
1164
+ waitForRegistrationChange(remaining).then((changed) =>
1165
+ changed ? ("registration" as const) : ("timeout" as const),
1166
+ ),
1167
+ sleep(Math.min(remaining, PULL_RETRY_INTERVAL_MS)).then(() => "interval" as const),
1168
+ ]);
1169
+ if (next === "push") {
1170
+ return "pushed";
1171
+ }
1172
+ }
1173
+ return "budgetExhausted";
1174
+ }
1175
+
1126
1176
  /**
1127
1177
  * 等待「当前文档版本」的诊断结论。返回值表示就绪是否被证实:true = 拿到了
1128
1178
  * 当前版本的诊断结果(push / pull / 已有结论);false = 等满预算或连接关闭
@@ -1150,9 +1200,6 @@ export async function create(input: CreateInput): Promise<LspClient> {
1150
1200
  files[request.path]?.lastPushEmpty === true
1151
1201
  ? Math.min(diagnosticsDocumentWaitTimeoutMs, diagnosticsSilentWaitTimeoutMs)
1152
1202
  : diagnosticsDocumentWaitTimeoutMs;
1153
- // pull 与 push 语义相同:都是等「当前文档版本」的诊断结果,统一一个循环。
1154
- // 先 pull(拿到即返回);pull 超时说明服务器未响应,不再重试 pull,只等
1155
- // 版本匹配的 push 兜底;版本不匹配的 push 一律忽略(防迟到旧结果)。
1156
1203
  const pushWait = waitForFreshPush({
1157
1204
  path: request.path,
1158
1205
  version: request.version,
@@ -1160,30 +1207,24 @@ export async function create(input: CreateInput): Promise<LspClient> {
1160
1207
  timeout: budget,
1161
1208
  });
1162
1209
 
1163
- while (!connectionClosed && !request.signal?.aborted) {
1164
- const remaining = budget - (Date.now() - startedAt);
1165
- if (remaining <= 0) {
1166
- return false;
1167
- }
1168
- const result = await requestDocumentDiagnostics(request.path);
1169
- if (result.matched) {
1170
- return true;
1171
- }
1172
- if (result.timedOut) {
1173
- return await pushWait;
1174
- }
1175
- const next = await Promise.race([
1176
- pushWait.then((ready) => (ready ? ("push" as const) : ("timeout" as const))),
1177
- waitForRegistrationChange(remaining).then((changed) =>
1178
- changed ? ("registration" as const) : ("timeout" as const),
1179
- ),
1180
- sleep(Math.min(remaining, PULL_RETRY_INTERVAL_MS)).then(() => "interval" as const),
1181
- ]);
1182
- if (next === "push") {
1183
- return true;
1184
- }
1185
- }
1186
- return false;
1210
+ const outcome = await pollUntilSettled({
1211
+ path: request.path,
1212
+ budgetMs: budget,
1213
+ startedAt,
1214
+ pushWait,
1215
+ pull: requestDocumentDiagnostics,
1216
+ isSettled: (result) => result.matched,
1217
+ signal: request.signal,
1218
+ });
1219
+ // 对应重构前的返回路径:pulled / pushed → `return true`(pull 命中或等到
1220
+ // 版本匹配的 push);pullTimedOut → `return await pushWait`(pull 挂起不重试,
1221
+ // 但继续等 push 到预算结束,返回是否等到);budgetExhausted(含连接关闭 /
1222
+ // 中断)→ 循环尾 `return false`。
1223
+ return (
1224
+ outcome === "pulled" ||
1225
+ outcome === "pushed" ||
1226
+ (outcome === "pullTimedOut" && (await pushWait))
1227
+ );
1187
1228
  }
1188
1229
 
1189
1230
  async function waitForFullDiagnostics(request: {
@@ -1200,29 +1241,20 @@ export async function create(input: CreateInput): Promise<LspClient> {
1200
1241
  timeout: diagnosticsFullWaitTimeoutMs,
1201
1242
  });
1202
1243
 
1203
- while (!connectionClosed && !request.signal?.aborted) {
1204
- const remaining = diagnosticsFullWaitTimeoutMs - (Date.now() - startedAt);
1205
- if (remaining <= 0) {
1206
- return;
1207
- }
1208
- const result = await requestFullDiagnostics(request.path);
1209
- if (result.handled || result.matched) {
1210
- return;
1211
- }
1212
- if (result.timedOut) {
1213
- await pushWait;
1214
- return;
1215
- }
1216
- const next = await Promise.race([
1217
- pushWait.then((ready) => (ready ? ("push" as const) : ("timeout" as const))),
1218
- waitForRegistrationChange(remaining).then((changed) =>
1219
- changed ? ("registration" as const) : ("timeout" as const),
1220
- ),
1221
- sleep(Math.min(remaining, PULL_RETRY_INTERVAL_MS)).then(() => "interval" as const),
1222
- ]);
1223
- if (next === "push") {
1224
- return;
1225
- }
1244
+ const outcome = await pollUntilSettled({
1245
+ path: request.path,
1246
+ budgetMs: diagnosticsFullWaitTimeoutMs,
1247
+ startedAt,
1248
+ pushWait,
1249
+ pull: requestFullDiagnostics,
1250
+ isSettled: (result) => result.handled || result.matched,
1251
+ signal: request.signal,
1252
+ });
1253
+ // 对应重构前的返回路径:pulled / pushed / budgetExhausted 都对应 `return`
1254
+ // (就绪、等到 push 或耗尽预算);pullTimedOut → `await pushWait; return`,
1255
+ // 即 pull 挂起后仍等 push 兜底到预算结束,结果丢弃(本函数返回 void)。
1256
+ if (outcome === "pullTimedOut") {
1257
+ await pushWait;
1226
1258
  }
1227
1259
  }
1228
1260
 
@@ -24,7 +24,10 @@
24
24
  * so one log entry is always exactly one rendered line. The final line is
25
25
  * always the subagent name as a code span (`` `scout` ``), followed by the
26
26
  * live usage stats when there are any; it rides outside the rolling window so
27
- * it is never trimmed. While the model is thinking, a transient
27
+ * it is never trimmed. The model name on that line is the model the session
28
+ * actually uses, which differs from the frontmatter string when the declared
29
+ * model resolved to nothing and the SDK fell back to a default. While the
30
+ * model is thinking, a transient
28
31
  * `thinking ( N chars )` line sits between the log and the footer showing the
29
32
  * live character count of the streamed thinking; it disappears when the
30
33
  * thinking block ends.
@@ -177,6 +180,7 @@ interface SubagentResult {
177
180
  messages: AgentMessage[];
178
181
  stderr: string;
179
182
  usage: UsageStats;
183
+ /** 实际生效的模型 id(fallback 之后),不是 frontmatter 里写的名字。 */
180
184
  model?: string;
181
185
  stopReason?: string;
182
186
  errorMessage?: string;
@@ -378,6 +382,11 @@ function toolSegment(name: string, count: number): string {
378
382
  /** The subset of AgentSession runAgent relies on (injectable for tests). */
379
383
  export interface SubagentSession {
380
384
  agent: { state: { messages: AgentMessage[] } };
385
+ /**
386
+ * Model the session actually runs (after any SDK fallback), shown in the
387
+ * progress footer. Optional: injected test sessions need not expose it.
388
+ */
389
+ model?: { id: string };
381
390
  subscribe(listener: AgentSessionEventListener): () => void;
382
391
  prompt(text: string, options?: PromptOptions): Promise<void>;
383
392
  abort(): Promise<void>;
@@ -453,7 +462,6 @@ export async function runAgent(
453
462
  contextTokens: 0,
454
463
  turns: 0,
455
464
  },
456
- model: agent.model,
457
465
  };
458
466
 
459
467
  let session: SubagentSession;
@@ -466,6 +474,10 @@ export async function runAgent(
466
474
  return result;
467
475
  }
468
476
 
477
+ // 面板显示实际生效的模型:frontmatter 里的模型名解析不到时,SDK 会静默
478
+ // fallback 到默认模型,回显配置原文会掩盖这次 fallback。
479
+ result.model = session.model?.id;
480
+
469
481
  let logLines: string[] = [];
470
482
  // 工具调用行合并:连续的 tool_execution_start 事件合并在同一 `tool:` 行
471
483
  // (如 `tool: read x 2, glob`),相同工具名连续出现时计为 `name x N`,