@trim21/personal-pi-extensions 0.1.608 → 0.1.612

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.608",
3
+ "version": "0.1.612",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -52,7 +52,7 @@
52
52
  "eslint-plugin-import-x": "^4.17.1",
53
53
  "eslint-plugin-promise": "7.3.0",
54
54
  "eslint-plugin-simple-import-sort": "14.0.0",
55
- "eslint-plugin-unicorn": "74.0.0",
55
+ "eslint-plugin-unicorn": "76.0.0",
56
56
  "eslint-plugin-unused-imports": "4.4.1",
57
57
  "husky": "^9.1.7",
58
58
  "jiti": "^2.7.0",
package/src/aft/tools.ts CHANGED
@@ -152,7 +152,7 @@ export function registerOutlineTool(pi: ExtensionAPI, ctx: AftToolContext): void
152
152
  }
153
153
  const rawArgs = compactArgs({
154
154
  target: filesMode ? target : resolved,
155
- files: filesMode ? true : undefined,
155
+ files: filesMode || undefined,
156
156
  includeTests: params.includeTests,
157
157
  });
158
158
 
@@ -232,7 +232,7 @@ export function registerZoomTool(pi: ExtensionAPI, ctx: AftToolContext): void {
232
232
  1,
233
233
  Number.MAX_SAFE_INTEGER,
234
234
  ),
235
- callgraph: coerceBoolean(params.callgraph) ? true : undefined,
235
+ callgraph: coerceBoolean(params.callgraph) || undefined,
236
236
  });
237
237
 
238
238
  const subtitle = buildZoomSubtitle(extCtx.cwd, params);
@@ -427,18 +427,25 @@ export function registerCallgraphTool(pi: ExtensionAPI, ctx: AftToolContext): vo
427
427
 
428
428
  /** `BinaryBridge.subscribeStatus` 的能力探测类型:AftProjectTransport 接口上未暴露。 */
429
429
  interface StatusSubscribableBridge {
430
- subscribeStatus?(listener: (snapshot: StatusSnapshot) => void): () => void;
430
+ subscribeStatus(listener: (snapshot: StatusSnapshot) => void): () => void;
431
431
  }
432
432
 
433
- function subscribeBridgeStatus(
433
+ /**
434
+ * 运行时能力探测:`AftProjectTransport` 接口没声明 subscribeStatus,但实际实现
435
+ * (BinaryBridge)有。用 in 收窄做真检查,而不是断言成「一定支持」。
436
+ */
437
+ export function isStatusSubscribable(
438
+ bridge: AftProjectTransport,
439
+ ): bridge is AftProjectTransport & StatusSubscribableBridge {
440
+ return "subscribeStatus" in bridge && typeof bridge.subscribeStatus === "function";
441
+ }
442
+
443
+ export function subscribeBridgeStatus(
434
444
  bridge: AftProjectTransport,
435
445
  listener: (snapshot: StatusSnapshot) => void,
436
446
  ): (() => void) | undefined {
437
- const subscribable = bridge as AftProjectTransport & StatusSubscribableBridge;
438
- if (typeof subscribable.subscribeStatus !== "function") {
439
- return undefined;
440
- }
441
- return subscribable.subscribeStatus(listener);
447
+ if (!isStatusSubscribable(bridge)) return undefined;
448
+ return bridge.subscribeStatus(listener);
442
449
  }
443
450
 
444
451
  /**
@@ -140,10 +140,15 @@ function collectCommand(node: Node, all: Node[]): BashCommand | undefined {
140
140
  // 自身且每次返回新 wrapper,`===` 比较失效会无限递归。
141
141
  const nested: BashCommand[] = [];
142
142
  for (const descendant of all) {
143
- if (descendant === node) continue;
144
- if (descendant.startIndex >= node.startIndex && descendant.endIndex <= node.endIndex) {
145
- const inner = collectCommand(descendant, all);
146
- if (inner) nested.push(inner);
143
+ if (descendant === node) {
144
+ continue;
145
+ }
146
+ if (descendant.startIndex < node.startIndex || descendant.endIndex > node.endIndex) {
147
+ continue;
148
+ }
149
+ const inner = collectCommand(descendant, all);
150
+ if (inner) {
151
+ nested.push(inner);
147
152
  }
148
153
  }
149
154
  return { name, args, raw: node.text, nested };
package/src/bwrap/core.ts CHANGED
@@ -31,6 +31,18 @@ export const NETWORK_MODES = ["block", "limited", "allow-all"] as const;
31
31
 
32
32
  export type NetworkMode = (typeof NETWORK_MODES)[number];
33
33
 
34
+ // 模式集合按 string 装:外部输入(CLI 参数)是 string,用它收窄成字面量联合。
35
+ const FS_MODE_SET: ReadonlySet<string> = new Set(FS_MODES);
36
+ const NETWORK_MODE_SET: ReadonlySet<string> = new Set(NETWORK_MODES);
37
+
38
+ export function isFsMode(value: unknown): value is FsMode {
39
+ return typeof value === "string" && FS_MODE_SET.has(value);
40
+ }
41
+
42
+ export function isNetworkMode(value: unknown): value is NetworkMode {
43
+ return typeof value === "string" && NETWORK_MODE_SET.has(value);
44
+ }
45
+
34
46
  const fsConfigProperties = {
35
47
  mode: StringEnum(FS_MODES),
36
48
  writablePaths: Type.Array(Type.String()),
@@ -81,8 +81,8 @@ function parseAllowlistEntry(entry: string): AllowlistEntry {
81
81
  const match = /^\[(.+)\](?::(\d+))?$/.exec(entry);
82
82
  if (match?.[1] === undefined) throw new Error(`Invalid allowlist entry "${entry}"`);
83
83
  host = match[1];
84
- // 端口组 (?::(\d+))? 可选:无端口时 match[2] 在运行时是 undefined
85
- const portPart = match[2] as string | undefined;
84
+ // 端口组 (?::(\d+))? 可选:无端口时 at(2) 是 undefined
85
+ const portPart = match.at(2);
86
86
  port = portPart === undefined ? undefined : parsePort(portPart);
87
87
  } else {
88
88
  const colon = entry.lastIndexOf(":");
@@ -17,6 +17,7 @@ import { type TObject, Type } from "typebox";
17
17
 
18
18
  import { type CommandSpec, parseCommand } from "../lib/cli.js";
19
19
  import { fenceCodeBlock } from "../lib/markdown.js";
20
+ import { isUnknownArray } from "../lib/narrow.js";
20
21
  import { formatDisplayPath } from "../lib/path.js";
21
22
  import { createRequestPolicy, type RequestPolicy } from "../lib/request-policy.js";
22
23
  import { type SelectAction, selectMultiple, selectWithOptionalInput } from "../lib/ui.js";
@@ -208,11 +209,13 @@ class BashOutput {
208
209
  this.tailBytes -= this.tail[0].length;
209
210
  this.tail.shift();
210
211
  }
211
- if (this.tailBytes > BASH_TAIL_LIMIT_BYTES && this.tail.length === 1) {
212
- // 单个 chunk 超过上限:截掉头部,只保留尾部
213
- this.tail[0] = this.tail[0].subarray(this.tailBytes - BASH_TAIL_LIMIT_BYTES);
214
- this.tailBytes = BASH_TAIL_LIMIT_BYTES;
212
+ if (this.tailBytes <= BASH_TAIL_LIMIT_BYTES || this.tail.length !== 1) {
213
+ return;
215
214
  }
215
+
216
+ // 单个 chunk 超过上限:截掉头部,只保留尾部
217
+ this.tail[0] = this.tail[0].subarray(this.tailBytes - BASH_TAIL_LIMIT_BYTES);
218
+ this.tailBytes = BASH_TAIL_LIMIT_BYTES;
216
219
  }
217
220
 
218
221
  close(): Promise<void> {
@@ -830,9 +833,8 @@ export class BwrapRuntime {
830
833
  if (existsSync(project)) {
831
834
  config = JSON.parse(readFileSync(project, "utf8")) as Record<string, unknown>;
832
835
  }
833
- const existing = Array.isArray(config.approvalRules)
834
- ? (config.approvalRules as ApprovalRule[])
835
- : [];
836
+ // 既有规则按不透明值原样保留(形状不认识也不丢),只追加本次允许的规则。
837
+ const existing = isUnknownArray(config.approvalRules) ? config.approvalRules : [];
836
838
  config.approvalRules = [...existing, ...newRules];
837
839
  await mkdir(dirname(project), { recursive: true });
838
840
  await writeFile(project, `${JSON.stringify(config, null, 2)}\n`, "utf8");
package/src/lib/cli.ts CHANGED
@@ -32,10 +32,11 @@
32
32
  * the result so handlers can display it in chat instead of writing stdout.
33
33
  */
34
34
 
35
- import type { Static, TObject } from "typebox";
35
+ import { IsKind, type Static, type TObject, type TSchema } from "typebox";
36
36
  import { Value } from "typebox/value";
37
37
 
38
38
  import { shlexSplit } from "./cli-args.js";
39
+ import { isRecord, isUnknownArray } from "./narrow.js";
39
40
 
40
41
  /** Per-flag CLI metadata on top of the typebox schema. */
41
42
  export interface FlagMeta {
@@ -69,16 +70,6 @@ export type CommandResult<TFlags extends TObject> =
69
70
  | { kind: "help"; text: string }
70
71
  | { kind: "error"; text: string };
71
72
 
72
- /** Runtime view of a flag schema (typebox's `TSchema` is empty at the type level). */
73
- interface FlagSchema {
74
- "~kind"?: string;
75
- "~optional"?: boolean;
76
- type?: string;
77
- default?: unknown;
78
- description?: string;
79
- anyOf?: { type?: string; const?: unknown }[];
80
- }
81
-
82
73
  type FlagKind = "boolean" | "string" | "number" | "enum";
83
74
 
84
75
  interface FlagInfo {
@@ -88,54 +79,67 @@ interface FlagInfo {
88
79
  required: boolean;
89
80
  placeholder: string;
90
81
  description: string;
91
- schema: FlagSchema;
82
+ schema: TSchema;
92
83
  }
93
84
 
94
- function kindOf(key: string, schema: FlagSchema): FlagKind {
95
- switch (schema["~kind"]) {
96
- case "Boolean": {
97
- return "boolean";
98
- }
99
- case "String": {
100
- return "string";
101
- }
102
- case "Number":
103
- case "Integer": {
104
- return "number";
105
- }
106
- case "Union": {
107
- return "enum";
108
- }
109
- default: {
110
- throw new TypeError(
111
- `Unsupported flag type for '${key}': ${schema["~kind"] ?? schema.type ?? "unknown"} ` +
112
- "(use Type.Boolean/String/Number/Integer or a string literal union)",
113
- );
114
- }
115
- }
85
+ /** schema 的种类名,仅用于报错文案。 */
86
+ function schemaKindLabel(schema: TSchema): string {
87
+ if ("~kind" in schema && typeof schema["~kind"] === "string") return schema["~kind"];
88
+ if ("type" in schema && typeof schema.type === "string") return schema.type;
89
+ return "unknown";
90
+ }
91
+
92
+ function kindOf(key: string, schema: TSchema): FlagKind {
93
+ if (IsKind(schema, "Boolean")) return "boolean";
94
+ if (IsKind(schema, "String")) return "string";
95
+ if (IsKind(schema, "Number") || IsKind(schema, "Integer")) return "number";
96
+ if (IsKind(schema, "Union")) return "enum";
97
+ throw new TypeError(
98
+ `Unsupported flag type for '${key}': ${schemaKindLabel(schema)} ` +
99
+ "(use Type.Boolean/String/Number/Integer or a string literal union)",
100
+ );
101
+ }
102
+
103
+ /** Type.Optional 的 `~optional` 标记:缺省即「不带该 flag 时不报错」。 */
104
+ function isOptionalFlag(schema: TSchema): boolean {
105
+ return "~optional" in schema && schema["~optional"] === true;
106
+ }
107
+
108
+ /** schema 声明的 default:有 default 的 flag 缺省时由 typebox 补值,不算 required。 */
109
+ function flagDefault(schema: TSchema): unknown {
110
+ return "default" in schema ? schema.default : undefined;
111
+ }
112
+
113
+ function flagDescription(schema: TSchema): string {
114
+ return "description" in schema && typeof schema.description === "string"
115
+ ? schema.description
116
+ : "";
116
117
  }
117
118
 
118
119
  /** Allowed values for a string-literal union flag, or undefined for mixed unions. */
119
- function enumValues(schema: FlagSchema): string[] | undefined {
120
- const anyOf = schema.anyOf;
121
- if (!anyOf) return undefined;
122
- const values = anyOf.map((s) => s.const).filter((c) => typeof c === "string");
123
- return values.length === anyOf.length ? values : undefined;
120
+ function enumValues(schema: TSchema): string[] | undefined {
121
+ if (!("anyOf" in schema) || !isUnknownArray(schema.anyOf)) return undefined;
122
+ const values: string[] = [];
123
+ for (const variant of schema.anyOf) {
124
+ if (!isRecord(variant) || typeof variant.const !== "string") return undefined;
125
+ values.push(variant.const);
126
+ }
127
+ return values;
124
128
  }
125
129
 
126
130
  function buildFlagInfos<TFlags extends TObject>(spec: CommandSpec<TFlags>): FlagInfo[] {
131
+ // flagMeta 的键随 flags 泛型变化(映射类型),运行时按字符串键索引需在此收敛一次。
127
132
  const meta = spec.flagMeta as Record<string, FlagMeta> | undefined;
128
133
  const infos: FlagInfo[] = [];
129
- for (const [key, rawSchema] of Object.entries(spec.flags.properties)) {
130
- const schema = rawSchema as FlagSchema;
134
+ for (const [key, schema] of Object.entries(spec.flags.properties)) {
131
135
  const m = meta?.[key];
132
136
  infos.push({
133
137
  key,
134
138
  kind: kindOf(key, schema),
135
139
  short: m?.short,
136
- required: schema["~optional"] !== true && schema.default === undefined,
140
+ required: !isOptionalFlag(schema) && flagDefault(schema) === undefined,
137
141
  placeholder: m?.valuePlaceholder ?? `<${key}>`,
138
- description: m?.description ?? schema.description ?? "",
142
+ description: m?.description ?? flagDescription(schema),
139
143
  schema,
140
144
  });
141
145
  }
@@ -290,22 +294,24 @@ export function parseCommand<TFlags extends TObject>(
290
294
  }
291
295
 
292
296
  for (const f of flags) {
293
- if (f.kind === "enum" && typeof rawFlags[f.key] === "string") {
297
+ const raw = rawFlags[f.key];
298
+ if (typeof raw === "string" && f.kind === "enum") {
294
299
  const values = enumValues(f.schema);
295
- if (values && !values.includes(rawFlags[f.key] as string)) {
300
+ if (values && !values.includes(raw)) {
296
301
  return errorResult(
297
302
  spec,
298
- `Invalid value for '--${f.key}': '${String(rawFlags[f.key])}' (expected one of: ${values.join(", ")})`,
303
+ `Invalid value for '--${f.key}': '${raw}' (expected one of: ${values.join(", ")})`,
299
304
  );
300
305
  }
301
306
  }
302
- if (f.kind === "number" && typeof rawFlags[f.key] === "string") {
303
- const n = Number(rawFlags[f.key]);
304
- if (Number.isNaN(n)) {
305
- return errorResult(spec, `Invalid value for '--${f.key}': '${String(rawFlags[f.key])}'`);
306
- }
307
- rawFlags[f.key] = n;
307
+ if (typeof raw !== "string" || f.kind !== "number") {
308
+ continue;
309
+ }
310
+ const n = Number(raw);
311
+ if (Number.isNaN(n)) {
312
+ return errorResult(spec, `Invalid value for '--${f.key}': '${raw}'`);
308
313
  }
314
+ rawFlags[f.key] = n;
309
315
  }
310
316
 
311
317
  const { min, max } = spec.arity ?? {};
@@ -16,6 +16,8 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
16
16
  import { type Static, Type } from "typebox";
17
17
  import { Value } from "typebox/value";
18
18
 
19
+ import { isRecord } from "./narrow.js";
20
+
19
21
  const fileSnapshotSchema = Type.Object({
20
22
  digest: Type.String(),
21
23
  textEditable: Type.Boolean(),
@@ -39,8 +41,11 @@ export function snapshotOf(content: Uint8Array | string, textEditable = true): F
39
41
  /** 整文件指纹:流式读取,避免为记账把大文件整个读进内存。 */
40
42
  export async function fileDigest(filePath: string): Promise<string> {
41
43
  const hash = createHash("sha256");
42
- for await (const chunk of createReadStream(filePath)) {
43
- hash.update(chunk as Buffer);
44
+ // createReadStream 的 async iterator 在 @types/node 里是 any:显式声明成 Buffer 流,
45
+ // 而不是在 update 处断言。
46
+ const stream: AsyncIterable<Buffer> = createReadStream(filePath);
47
+ for await (const chunk of stream) {
48
+ hash.update(chunk);
44
49
  }
45
50
  return hash.digest("hex");
46
51
  }
@@ -150,8 +155,8 @@ export function restoreReads(
150
155
  for (const entry of sessionManager.getBranch()) {
151
156
  if (entry.type !== "message" || entry.message.role !== "toolResult") continue;
152
157
  if (!toolNames.has(entry.message.toolName)) continue;
153
- const details = entry.message.details as { reads?: unknown } | undefined;
154
- if (!details?.reads) continue;
158
+ const details = entry.message.details;
159
+ if (!isRecord(details) || !details.reads) continue;
155
160
  for (const [filePath, snapshot] of deserializeReads(details.reads)) {
156
161
  state.reads.set(filePath, snapshot);
157
162
  }
package/src/lib/github.ts CHANGED
@@ -11,6 +11,9 @@
11
11
  import { spawn } from "node:child_process";
12
12
 
13
13
  import { Octokit } from "octokit";
14
+ import { type Static, Type } from "typebox";
15
+
16
+ import { parseWithSchema } from "./parse-with-schema.js";
14
17
 
15
18
  export type SearchKind = "issue" | "pr";
16
19
 
@@ -25,24 +28,36 @@ export interface SearchParams {
25
28
  limit?: number;
26
29
  }
27
30
 
28
- /** REST /search/issues response shape we consume (item is an issue/pr union). */
29
- interface RawSearchItem {
30
- number: number;
31
- state: string;
32
- title: string;
33
- html_url: string;
31
+ /**
32
+ * REST /search/issues 里本客户端消费的 item 形状(issue / PR 联合)。只声明真正
33
+ * 读取的字段,其余键忽略;未声明的字段类型不符时按「响应形状变了」处理,报错而
34
+ * 不是把 undefined 混进结果。labels 兼容字符串与 {name} 两种形态(openapi 里是
35
+ * 联合类型)。
36
+ */
37
+ const searchItemSchema = Type.Object({
38
+ number: Type.Number(),
39
+ state: Type.Union([Type.Literal("open"), Type.Literal("closed")]),
40
+ title: Type.String(),
41
+ html_url: Type.String(),
34
42
  /** The search API exposes the repo as a URL, not as an object. */
35
- repository_url: string;
36
- user: { login: string } | null;
37
- labels: { name: string }[];
38
- milestone: { title: string } | null;
39
- assignees: { login: string }[];
40
- comments: number;
41
- created_at: string;
42
- updated_at: string;
43
- closed_at: string | null;
44
- pull_request: { merged_at: string | null } | null;
45
- }
43
+ repository_url: Type.String(),
44
+ user: Type.Union([Type.Object({ login: Type.String() }), Type.Null()]),
45
+ labels: Type.Array(
46
+ Type.Union([Type.String(), Type.Object({ name: Type.Optional(Type.String()) })]),
47
+ ),
48
+ milestone: Type.Union([Type.Object({ title: Type.Optional(Type.String()) }), Type.Null()]),
49
+ assignees: Type.Array(Type.Object({ login: Type.String() })),
50
+ comments: Type.Number(),
51
+ created_at: Type.String(),
52
+ updated_at: Type.String(),
53
+ closed_at: Type.Union([Type.String(), Type.Null()]),
54
+ pull_request: Type.Union([
55
+ Type.Object({ merged_at: Type.Optional(Type.Union([Type.String(), Type.Null()])) }),
56
+ Type.Null(),
57
+ ]),
58
+ });
59
+
60
+ type SearchItem = Static<typeof searchItemSchema>;
46
61
 
47
62
  export interface SearchHit {
48
63
  number: number;
@@ -162,21 +177,21 @@ function toDate(iso: string | null | undefined): string {
162
177
  const REPO_URL_RE = /\/repos\/([^/]+\/[^/]+)$/;
163
178
 
164
179
  /** repository_url looks like https://api.github.com/repos/OWNER/REPO */
165
- function repoName(raw: RawSearchItem): string {
180
+ function repoName(raw: SearchItem): string {
166
181
  const match = REPO_URL_RE.exec(raw.repository_url);
167
182
  return match?.[1] ?? "";
168
183
  }
169
184
 
170
- function normalize(raw: RawSearchItem): SearchHit {
185
+ function normalize(raw: SearchItem): SearchHit {
171
186
  const mergedAt = raw.pull_request?.merged_at ?? "";
172
187
  return {
173
188
  number: raw.number,
174
- state: mergedAt ? "merged" : (raw.state as "open" | "closed"),
189
+ state: mergedAt ? "merged" : raw.state,
175
190
  title: raw.title,
176
191
  url: raw.html_url,
177
192
  repo: repoName(raw),
178
193
  author: raw.user?.login ?? "",
179
- labels: raw.labels.map((l) => l.name),
194
+ labels: raw.labels.map((label) => (typeof label === "string" ? label : (label.name ?? ""))),
180
195
  milestone: raw.milestone?.title ?? "",
181
196
  assignees: raw.assignees.map((a) => a.login),
182
197
  comments: raw.comments,
@@ -320,7 +335,7 @@ export function createGithubSearch(options: GithubClientOptions = {}): GithubSea
320
335
  q,
321
336
  per_page: limit,
322
337
  });
323
- return data.items.map((item) => normalize(item as RawSearchItem));
338
+ return data.items.map((item) => normalize(parseWithSchema(searchItemSchema, item)));
324
339
  } catch (error) {
325
340
  const status = (error as { status?: number }).status;
326
341
  const message = (error as { message?: string }).message ?? String(error);
@@ -274,12 +274,13 @@ function validateConfig(config: ResolvedLspConfig, adapters?: LspServerAdapter[]
274
274
  adapters ? adapters.map((adapter) => adapter.id) : Object.keys(config.servers),
275
275
  );
276
276
  const unknown = config.enabled === undefined ? [] : [...config.enabled.difference(available)];
277
- if (unknown.length > 0) {
278
- const list = [...available].toSorted().join(", ") || "none";
279
- throw new Error(
280
- `lsp.json: unknown server id in enabled: ${unknown.join(", ")} (available: ${list})`,
281
- );
277
+ if (unknown.length === 0) {
278
+ return;
282
279
  }
280
+ const list = [...available].toSorted().join(", ") || "none";
281
+ throw new Error(
282
+ `lsp.json: unknown server id in enabled: ${unknown.join(", ")} (available: ${list})`,
283
+ );
283
284
  }
284
285
 
285
286
  /** 合并 watch 段:全局为基底、本地逐字段覆盖;ignore 取并集去重(全局在前)。两边都未配置时返回 undefined,调用方据此省略 watch 键。 */
@@ -370,13 +371,24 @@ export type StatusRenderer = (text: string | undefined) => void;
370
371
  export type LspInspectQuery = "definition" | "references" | "hover";
371
372
 
372
373
  /**
373
- * 返回类型与 query 泛型关联:query 为 "hover" 时返回 hover 内容,否则返回
374
- * 位置列表。实现内部用 cast 建立关联(TS 无法验证分支与泛型的对应关系)。
374
+ * 返回类型与 query 关联:query 为 "hover" 时返回 hover 内容,否则返回位置列表。
375
+ * 关联由 `inspect` 的重载签名建立(泛型参数下 TS 无法验证分支与返回体的对应关系,
376
+ * 所以接口按 query 分重载,实现体只需返回这个联合)。
375
377
  */
376
378
  export type LspInspectResult<Q extends LspInspectQuery = LspInspectQuery> = Q extends "hover"
377
379
  ? { serverID: string; query: "hover"; hover: Hover | null }
378
380
  : { serverID: string; query: "definition" | "references"; locations: InspectLocation[] };
379
381
 
382
+ /** 只读符号查询的入参:query 决定返回体(见 LspInspectResult)。line / character 为 0-based。 */
383
+ export interface LspInspectRequest<Q extends LspInspectQuery = LspInspectQuery> {
384
+ file: string;
385
+ cwd: string;
386
+ line: number;
387
+ character: number;
388
+ query: Q;
389
+ options?: LspRequestOptions;
390
+ }
391
+
380
392
  export interface LspRequestOptions {
381
393
  notify?: ExtensionUIContext["notify"];
382
394
  /** 中止时提前结束诊断等待(已中止时直接跳过诊断)。 */
@@ -427,14 +439,11 @@ export interface LspService {
427
439
  * 的服务器,按配置顺序取第一个成功结果;服务器不支持该方法(MethodNotFound)
428
440
  * 时跳过并尝试下一个,全部不支持时抛聚合错误。line / character 为 0-based。
429
441
  */
430
- inspect<Q extends LspInspectQuery>(request: {
431
- file: string;
432
- cwd: string;
433
- line: number;
434
- character: number;
435
- query: Q;
436
- options?: LspRequestOptions;
437
- }): Promise<LspInspectResult<Q>>;
442
+ inspect(request: LspInspectRequest<"hover">): Promise<LspInspectResult<"hover">>;
443
+ inspect(
444
+ request: LspInspectRequest<"definition" | "references">,
445
+ ): Promise<LspInspectResult<"definition" | "references">>;
446
+ inspect(request: LspInspectRequest): Promise<LspInspectResult>;
438
447
  shutdownAll(): Promise<void>;
439
448
  /** 停止全部服务器并禁用 LSP:之后工具调用不再 spawn,直到 start/reload。 */
440
449
  stop(): Promise<void>;
@@ -756,14 +765,17 @@ export function createLspService(
756
765
  updateStatusText();
757
766
  const reporter = sessionNotify ?? notify;
758
767
  const lastNotified = state.brokenNotifiedAt.get(key);
759
- if (reporter && (lastNotified === undefined || now - lastNotified >= notifyIntervalMs)) {
760
- state.brokenNotifiedAt.set(key, now);
761
- reporter(
762
- `LSP server "${serverID}" failed to start for ${root}: ${cause}. ` +
763
- `Fix the issue or run /lsp-reload ${serverID} to retry now.`,
764
- "error",
765
- );
768
+ const due = lastNotified === undefined || now - lastNotified >= notifyIntervalMs;
769
+ if (!reporter || !due) {
770
+ return;
766
771
  }
772
+
773
+ state.brokenNotifiedAt.set(key, now);
774
+ reporter(
775
+ `LSP server "${serverID}" failed to start for ${root}: ${cause}. ` +
776
+ `Fix the issue or run /lsp-reload ${serverID} to retry now.`,
777
+ "error",
778
+ );
767
779
  }
768
780
 
769
781
  /**
@@ -994,14 +1006,12 @@ export function createLspService(
994
1006
  }
995
1007
 
996
1008
  /** 只读符号查询:与 rename 同款多服务器策略,但 MethodNotFound 是"跳过"而非失败。 */
997
- async function inspect<Q extends LspInspectQuery>(request: {
998
- file: string;
999
- cwd: string;
1000
- line: number;
1001
- character: number;
1002
- query: Q;
1003
- options?: LspRequestOptions;
1004
- }): Promise<LspInspectResult<Q>> {
1009
+ async function inspect(request: LspInspectRequest<"hover">): Promise<LspInspectResult<"hover">>;
1010
+ async function inspect(
1011
+ request: LspInspectRequest<"definition" | "references">,
1012
+ ): Promise<LspInspectResult<"definition" | "references">>;
1013
+ async function inspect(request: LspInspectRequest): Promise<LspInspectResult>;
1014
+ async function inspect(request: LspInspectRequest): Promise<LspInspectResult> {
1005
1015
  const clients = await getClients(
1006
1016
  request.file,
1007
1017
  request.cwd,
@@ -1024,7 +1034,7 @@ export function createLspService(
1024
1034
  try {
1025
1035
  if (request.query === "hover") {
1026
1036
  const hover = await client.hover(position);
1027
- return { serverID: client.serverID, query: "hover", hover } as LspInspectResult<Q>;
1037
+ return { serverID: client.serverID, query: "hover", hover };
1028
1038
  }
1029
1039
  const locations =
1030
1040
  request.query === "definition"
@@ -1034,7 +1044,7 @@ export function createLspService(
1034
1044
  serverID: client.serverID,
1035
1045
  query: request.query,
1036
1046
  locations,
1037
- } as LspInspectResult<Q>;
1047
+ };
1038
1048
  } catch (error) {
1039
1049
  // 调用方取消不是"服务器失败":直接向上抛,别折叠成 all-servers-failed
1040
1050
  request.options?.signal?.throwIfAborted();
@@ -17,6 +17,8 @@ import { fileURLToPath } from "node:url";
17
17
 
18
18
  import type { TextEdit, WorkspaceEdit } from "vscode-languageserver-types";
19
19
 
20
+ import { isRecord } from "../narrow.js";
21
+
20
22
  /** 单个文件展开后的编辑结果。 */
21
23
  export interface AppliedFileEdit {
22
24
  readonly path: string;
@@ -38,13 +40,7 @@ interface RangeLike {
38
40
 
39
41
  /** 编辑联合里 SnippetTextEdit 没有 newText,应用层不支持且无法静默处理。 */
40
42
  function isRangeLike(edit: unknown): edit is RangeLike {
41
- return (
42
- typeof edit === "object" &&
43
- edit !== null &&
44
- "range" in edit &&
45
- "newText" in edit &&
46
- typeof (edit as { newText: unknown }).newText === "string"
47
- );
43
+ return isRecord(edit) && "range" in edit && "newText" in edit && typeof edit.newText === "string";
48
44
  }
49
45
 
50
46
  /** file:// URI → 规范化本地路径;非 file scheme 是服务器的意外行为,直接报错。 */
@@ -25,6 +25,7 @@ import { minimatch } from "minimatch";
25
25
  import { type Static, Type } from "typebox";
26
26
  import { Value } from "typebox/value";
27
27
 
28
+ import { isRecord } from "../narrow.js";
28
29
  import { type LspServerAdapter, type LspServerHandle, type ServerKind } from "./adapter.js";
29
30
  import { exists, findBinaryInWorkspace, which } from "./bin.js";
30
31
  import { spawnProcess } from "./launch.js";
@@ -185,11 +186,6 @@ async function runConfigCommand(argv: string[], options: ConfigCommandOptions):
185
186
  /** 命令 stdout 即 initializationOptions:JSON 对象,经 typebox 校验后作为配置值。 */
186
187
  const initializationOptionsOutputSchema = Type.Record(Type.String(), Type.Unknown());
187
188
 
188
- /** 纯对象判定(数组与 null 不算),用于深合并与命令输出校验。 */
189
- function isRecord(value: unknown): value is Record<string, unknown> {
190
- return typeof value === "object" && value !== null && !Array.isArray(value);
191
- }
192
-
193
189
  /** 深合并 initializationOptions:两侧都是纯对象时逐层递归,其余类型整体覆盖(override 优先)。 */
194
190
  function mergeInitializationOptions(
195
191
  base: Record<string, unknown> | undefined,
@@ -0,0 +1,15 @@
1
+ /**
2
+ * unknown 收窄守卫:外部数据(JSON、SQLite 行、子进程输出、宿主抛出的错误)
3
+ * 在类型层是 unknown,用守卫显式收窄,避免用 `as` 断言把未检查的值伪装成
4
+ * 已知类型。
5
+ */
6
+
7
+ /** 纯对象判定(数组与 null 不算)。 */
8
+ export function isRecord(value: unknown): value is Record<string, unknown> {
9
+ return typeof value === "object" && value !== null && !Array.isArray(value);
10
+ }
11
+
12
+ /** 数组判定。`Array.isArray` 单独用会把 unknown 收成 any[],元素仍是未检查的。 */
13
+ export function isUnknownArray(value: unknown): value is unknown[] {
14
+ return Array.isArray(value);
15
+ }
@@ -119,20 +119,21 @@ const LineTrimmedReplacer: Replacer = function* (content, find) {
119
119
  break;
120
120
  }
121
121
  }
122
- if (matches) {
123
- let matchStartIndex = 0;
124
- for (let k = 0; k < i; k++) {
125
- matchStartIndex += originalLines[k].length + 1;
126
- }
127
- let matchEndIndex = matchStartIndex;
128
- for (let k = 0; k < searchLines.length; k++) {
129
- matchEndIndex += originalLines[i + k].length;
130
- if (k < searchLines.length - 1) {
131
- matchEndIndex += 1;
132
- }
122
+ if (!matches) {
123
+ continue;
124
+ }
125
+ let matchStartIndex = 0;
126
+ for (let k = 0; k < i; k++) {
127
+ matchStartIndex += originalLines[k].length + 1;
128
+ }
129
+ let matchEndIndex = matchStartIndex;
130
+ for (let k = 0; k < searchLines.length; k++) {
131
+ matchEndIndex += originalLines[i + k].length;
132
+ if (k < searchLines.length - 1) {
133
+ matchEndIndex += 1;
133
134
  }
134
- yield content.slice(matchStartIndex, matchEndIndex);
135
135
  }
136
+ yield content.slice(matchStartIndex, matchEndIndex);
136
137
  }
137
138
  };
138
139
 
@@ -230,26 +231,29 @@ const BlockAnchorReplacer: Replacer = function* (content, find) {
230
231
  } else {
231
232
  similarity = 1;
232
233
  }
233
- if (similarity > maxSimilarity) {
234
- maxSimilarity = similarity;
235
- bestMatch = candidate;
234
+ if (similarity <= maxSimilarity) {
235
+ continue;
236
236
  }
237
+
238
+ maxSimilarity = similarity;
239
+ bestMatch = candidate;
237
240
  }
238
- if (bestMatch && maxSimilarity >= MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD) {
239
- const { startLine, endLine } = bestMatch;
240
- let matchStartIndex = 0;
241
- for (let k = 0; k < startLine; k++) {
242
- matchStartIndex += originalLines[k].length + 1;
243
- }
244
- let matchEndIndex = matchStartIndex;
245
- for (let k = startLine; k <= endLine; k++) {
246
- matchEndIndex += originalLines[k].length;
247
- if (k < endLine) {
248
- matchEndIndex += 1;
249
- }
241
+ if (!bestMatch || maxSimilarity < MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD) {
242
+ return;
243
+ }
244
+ const { startLine, endLine } = bestMatch;
245
+ let matchStartIndex = 0;
246
+ for (let k = 0; k < startLine; k++) {
247
+ matchStartIndex += originalLines[k].length + 1;
248
+ }
249
+ let matchEndIndex = matchStartIndex;
250
+ for (let k = startLine; k <= endLine; k++) {
251
+ matchEndIndex += originalLines[k].length;
252
+ if (k < endLine) {
253
+ matchEndIndex += 1;
250
254
  }
251
- yield content.slice(matchStartIndex, matchEndIndex);
252
255
  }
256
+ yield content.slice(matchStartIndex, matchEndIndex);
253
257
  };
254
258
 
255
259
  function normalizeWhitespace(text: string): string {
@@ -370,11 +374,13 @@ const ContextAwareReplacer: Replacer = function* (content, find) {
370
374
  for (let k = 1; k < blockLines.length - 1; k++) {
371
375
  const blockLine = blockLines[k].trim();
372
376
  const findLine = findLines[k].trim();
373
- if (blockLine.length > 0 || findLine.length > 0) {
374
- totalNonEmptyLines++;
375
- if (blockLine === findLine) {
376
- matchingLines++;
377
- }
377
+ if (blockLine.length === 0 && findLine.length === 0) {
378
+ continue;
379
+ }
380
+
381
+ totalNonEmptyLines++;
382
+ if (blockLine === findLine) {
383
+ matchingLines++;
378
384
  }
379
385
  }
380
386
  if (totalNonEmptyLines === 0 || matchingLines / totalNonEmptyLines >= 0.5) {
@@ -19,6 +19,7 @@ import { fileURLToPath } from "node:url";
19
19
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
20
20
  import { Type } from "typebox";
21
21
 
22
+ import { isRecord } from "../lib/narrow.js";
22
23
  import { parseWithSchema } from "../lib/parse-with-schema.js";
23
24
  import { didYouMean } from "./files.js";
24
25
  import { RIPGREP_RESULT_LIMIT, runRipgrep } from "./ripgrep.js";
@@ -74,7 +75,7 @@ export function parseGrepRecord(line: string): GrepMatch | undefined {
74
75
  } catch (error) {
75
76
  throw new Error("Invalid ripgrep JSON output", { cause: error });
76
77
  }
77
- if (typeof json !== "object" || json === null || (json as { type?: unknown }).type !== "match") {
78
+ if (!isRecord(json) || json.type !== "match") {
78
79
  return undefined;
79
80
  }
80
81
  const record = parseWithSchema(rawMatchSchema, json);
@@ -88,10 +88,12 @@ export function runRipgrep<R>(
88
88
  }
89
89
  if (item === undefined) return;
90
90
  items.push(item);
91
- if (items.length > options.limit) {
92
- state.truncated = true;
93
- stop();
91
+ if (items.length <= options.limit) {
92
+ return;
94
93
  }
94
+
95
+ state.truncated = true;
96
+ stop();
95
97
  };
96
98
 
97
99
  child.stderr.setEncoding("utf8");
@@ -36,6 +36,8 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
36
36
  import { type Static, Type } from "typebox";
37
37
  import { Value } from "typebox/value";
38
38
 
39
+ import { isRecord } from "./lib/narrow.js";
40
+
39
41
  // ── constants ────────────────────────────────────────────────────────────────
40
42
 
41
43
  /** ~/.pi/agent/settings.json:sessionName 配置所在文件 */
@@ -153,13 +155,7 @@ function messageText(content: unknown): string {
153
155
  return content
154
156
  .map((part: unknown) => {
155
157
  if (typeof part === "string") return part;
156
- if (
157
- part &&
158
- typeof part === "object" &&
159
- typeof (part as Record<string, unknown>).text === "string"
160
- ) {
161
- return (part as Record<string, unknown>).text as string;
162
- }
158
+ if (isRecord(part) && typeof part.text === "string") return part.text;
163
159
  return "";
164
160
  })
165
161
  .join("")
@@ -175,12 +171,10 @@ export function extractFirstUserPrompt(
175
171
  currentPrompt: string,
176
172
  ): string | undefined {
177
173
  const trimmed = currentPrompt.trim();
178
- if (branch.length > 0) {
179
- for (const entry of branch) {
180
- if (entry.type !== "message") continue;
181
- const text = messageText(entry.message?.content);
182
- if (text) return text;
183
- }
174
+ for (const entry of branch) {
175
+ if (entry.type !== "message") continue;
176
+ const text = messageText(entry.message?.content);
177
+ if (text) return text;
184
178
  }
185
179
  return trimmed || undefined;
186
180
  }
@@ -286,10 +286,12 @@ export function overrideExtensionPaths(tools: string[]): string[] {
286
286
  const paths: string[] = [];
287
287
  for (const tool of tools) {
288
288
  const ext = TOOL_EXTENSION_OVERRIDES[tool];
289
- if (ext && !loaded.has(ext)) {
290
- loaded.add(ext);
291
- paths.push(extensionPath(ext));
289
+ if (!ext || loaded.has(ext)) {
290
+ continue;
292
291
  }
292
+
293
+ loaded.add(ext);
294
+ paths.push(extensionPath(ext));
293
295
  }
294
296
  return paths;
295
297
  }
package/src/talk/core.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  * poll — so a swallowed sendMessage error no longer destroys the letter.
15
15
  */
16
16
 
17
+ import { isRecord } from "../lib/narrow.js";
17
18
  import { age, formatListing, refusalUnknown, shortAddr } from "./format.js";
18
19
  import {
19
20
  deleteGroup,
@@ -101,14 +102,11 @@ export const TALK_JOIN_ENTRY_TYPE = "talk:join";
101
102
  export function restoreTalkAgentId(branchEntries: readonly unknown[]): string | undefined {
102
103
  let agentId: string | undefined;
103
104
  for (const entry of branchEntries) {
104
- if (typeof entry !== "object" || entry === null) continue;
105
- const { type, customType, data } = entry as {
106
- type?: unknown;
107
- customType?: unknown;
108
- data?: unknown;
109
- };
105
+ if (!isRecord(entry)) continue;
106
+ const { type, customType, data } = entry;
110
107
  if (type !== "custom" || customType !== TALK_JOIN_ENTRY_TYPE) continue;
111
- const recorded = (data as { agentId?: unknown } | undefined)?.agentId;
108
+ if (!isRecord(data)) continue;
109
+ const recorded = data.agentId;
112
110
  if (typeof recorded === "string" && recorded.length > 0) agentId = recorded;
113
111
  }
114
112
  return agentId;
@@ -18,6 +18,7 @@ import { randomUUID } from "node:crypto";
18
18
  import { type Static, Type } from "typebox";
19
19
  import { Value } from "typebox/value";
20
20
 
21
+ import { isRecord } from "../lib/narrow.js";
21
22
  import { asksNs, assertAddress, inboxNs } from "./registry.js";
22
23
  import type { TalkStorage } from "./storage.js";
23
24
 
@@ -49,14 +50,13 @@ export type LetterKind = Letter["kind"];
49
50
  */
50
51
  export function normalizeLetter(value: unknown): Letter | null {
51
52
  if (Value.Check(LetterSchema, value)) return value;
52
- const record = value as { from?: unknown } | null;
53
- const from = record?.from;
54
- if (typeof from !== "object" || from === null) return null;
55
- const fromRecord = from as Record<string, unknown>;
56
- if (typeof fromRecord.sessionId !== "string") return null;
53
+ if (!isRecord(value)) return null;
54
+ const from = value.from;
55
+ if (!isRecord(from)) return null;
56
+ if (typeof from.sessionId !== "string") return null;
57
57
  const migrated = {
58
- ...(value as object),
59
- from: { ...fromRecord, agentId: fromRecord.sessionId },
58
+ ...value,
59
+ from: { ...from, agentId: from.sessionId },
60
60
  };
61
61
  return Value.Check(LetterSchema, migrated) ? migrated : null;
62
62
  }
@@ -68,11 +68,12 @@ export class OutboundPolicy {
68
68
  this.sentAt.push(this.now());
69
69
  this.recentBodies.set(`${body}\u0000${target ?? ""}`, this.now());
70
70
  // bound the dedupe map
71
- if (this.recentBodies.size > 200) {
72
- const cutoff = this.now() - DEDUPE_WINDOW_MS;
73
- for (const [key, ts] of this.recentBodies) {
74
- if (ts < cutoff) this.recentBodies.delete(key);
75
- }
71
+ if (this.recentBodies.size <= 200) {
72
+ return;
73
+ }
74
+ const cutoff = this.now() - DEDUPE_WINDOW_MS;
75
+ for (const [key, ts] of this.recentBodies) {
76
+ if (ts < cutoff) this.recentBodies.delete(key);
76
77
  }
77
78
  }
78
79
  }
@@ -114,8 +114,9 @@ export async function readRecord(storage: TalkStorage, addr: string): Promise<Ag
114
114
  if (Value.Check(AgentRecordSchema, raw)) return raw;
115
115
  // Migrate legacy records in place of the `sessionId` → `agentId` rename.
116
116
  if (Value.Check(LegacyAgentRecordSchema, raw)) {
117
- const { sessionId, ...rest } = raw as { sessionId: string } & Record<string, unknown>;
118
- return { ...rest, agentId: sessionId } as AgentRecord;
117
+ // Value.Check 是类型守卫,raw 已收窄成 legacy 记录的形状,不需要再断言。
118
+ const { sessionId, ...rest } = raw;
119
+ return { ...rest, agentId: sessionId };
119
120
  }
120
121
  return null;
121
122
  }
@@ -168,7 +169,8 @@ function pidAlive(pid: number, pidStart?: number): boolean {
168
169
  }
169
170
  if (pidStart === undefined) return true;
170
171
  const start = readStartTime(pid);
171
- return start === undefined ? true : start === pidStart;
172
+ // 读不到启动时间时保守认为「pid 还活着」,只有两边都读到且不同才判定被复用
173
+ return start === undefined || start === pidStart;
172
174
  }
173
175
 
174
176
  export function presenceOf(record: AgentRecord): Presence {
@@ -33,6 +33,7 @@ import {
33
33
  } from "@earendil-works/pi-ai";
34
34
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
35
35
  import { Type } from "typebox";
36
+ import { Value } from "typebox/value";
36
37
 
37
38
  import { type ToolPendant } from "./lib/pendant.js";
38
39
 
@@ -83,6 +84,21 @@ export interface VisionConfigSettings {
83
84
  model?: string;
84
85
  }
85
86
 
87
+ /**
88
+ * ~/.pi/agent/settings.json 里本扩展读取的字段:默认 provider 与视觉模型配置。
89
+ * 未声明的键(packages / defaultModel 等 pi 自己的配置)忽略;声明了的字段类型
90
+ * 不符则整份配置视为不可用,退回「未配置」而不是猜。
91
+ */
92
+ const visionSettingsSchema = Type.Object({
93
+ defaultProvider: Type.Optional(Type.String()),
94
+ visionConfig: Type.Optional(
95
+ Type.Object({
96
+ provider: Type.Optional(Type.String()),
97
+ model: Type.Optional(Type.String()),
98
+ }),
99
+ ),
100
+ });
101
+
86
102
  /**
87
103
  * 视觉识别所需的模型注册表操作:扩展传 ctx.modelRegistry,测试传 mock。
88
104
  * 结构化类型(duck typing),只声明用到的两个方法。
@@ -121,26 +137,21 @@ export function loadVisionConfig(settingsPath = SETTINGS_PATH): VisionConfigSett
121
137
  } catch {
122
138
  return undefined;
123
139
  }
140
+ let parsed: unknown;
124
141
  try {
125
- const parsed: unknown = JSON.parse(raw);
126
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
127
- const settings = parsed as Record<string, unknown>;
128
- const vc = settings.visionConfig;
129
- if (!vc || typeof vc !== "object" || Array.isArray(vc)) return undefined;
130
- const config = vc as Record<string, unknown>;
131
- const provider =
132
- typeof config.provider === "string" ? config.provider.trim() || undefined : undefined;
133
- const defaultProvider =
134
- typeof settings.defaultProvider === "string"
135
- ? settings.defaultProvider.trim() || undefined
136
- : undefined;
137
- return {
138
- provider: provider ?? defaultProvider,
139
- model: typeof config.model === "string" ? config.model.trim() || undefined : undefined,
140
- };
142
+ parsed = JSON.parse(raw);
141
143
  } catch {
142
144
  return undefined;
143
145
  }
146
+ if (!Value.Check(visionSettingsSchema, parsed)) return undefined;
147
+ const config = parsed.visionConfig;
148
+ if (config === undefined) return undefined;
149
+ const provider = config.provider?.trim() || undefined;
150
+ const defaultProvider = parsed.defaultProvider?.trim() || undefined;
151
+ return {
152
+ provider: provider ?? defaultProvider,
153
+ model: config.model?.trim() || undefined,
154
+ };
144
155
  }
145
156
 
146
157
  /**