@trim21/personal-pi-extensions 0.1.495 → 0.1.497

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.495",
3
+ "version": "0.1.497",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -105,6 +105,7 @@
105
105
  "linkedom": "^0.18.0",
106
106
  "lodash-es": "^4.18.1",
107
107
  "minimatch": "^10.2.6",
108
+ "octokit": "^5.0.5",
108
109
  "turndown": "^7.2.0",
109
110
  "vscode-jsonrpc": "^9.0.1",
110
111
  "vscode-languageserver-types": "^3.18.0",
@@ -38,6 +38,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
38
38
  import { Type } from "typebox";
39
39
  import { Value } from "typebox/value";
40
40
 
41
+ import { createGithubSearch, type GithubSearch, renderHits } from "./lib/github.js";
41
42
  import { type ToolPendant } from "./lib/pendant.js";
42
43
  import { createSeqState } from "./lib/seq-state.js";
43
44
 
@@ -333,52 +334,30 @@ interface ListFilters {
333
334
  assignee?: string;
334
335
  milestone?: string;
335
336
  limit?: number;
337
+ /** Comma-separated field names for the keyword-search result rows. */
338
+ fields?: string;
336
339
  }
337
340
 
338
341
  /**
339
- * Build the `gh` argv for listing or keyword-searching issues/PRs.
342
+ * Build the `gh` argv for browsing issues/PRs (no keyword search).
340
343
  *
341
- * Keyword searches route through `gh search issues` / `gh search prs`, which rank
342
- * results by relevance and are the only path that can include closed items.
343
- * `gh issue list --search` would silently stay on the list's state filter (open
344
- * by default), so keyword lookups must not use it. Browse calls (no keywords)
345
- * keep `gh issue list` / `gh pr list` semantics.
346
- *
347
- * The state value sets differ between the two command families: `gh issue list`
348
- * accepts `--state all` and `gh pr list` `--state merged`, while `gh search`
349
- * only knows `--state open|closed` plus `--merged` for PRs. The shared `state`
350
- * param is translated here: search defaults to open, `all` means open+closed
351
- * (no state filter), and `merged` maps to `--merged` for PRs.
344
+ * Keyword searches no longer go through the `gh` CLI the octokit-based client
345
+ * in `./lib/github.ts` handles them with state values (`all`, and `merged` for
346
+ * PRs) that `gh search` cannot express. Browse calls keep `gh issue list` /
347
+ * `gh pr list` semantics: `state` is passed through verbatim, since `gh issue
348
+ * list` accepts open/closed/all and `gh pr list` additionally accepts merged.
352
349
  */
353
350
  export function listGithubArgs(kind: "issue" | "pr", params: ListFilters): string[] {
354
- const { repo, keywords, state, label, author, assignee, milestone, limit } = params;
355
-
356
- const addFilters = (args: string[]) => {
357
- if (label) args.push("--label", label);
358
- if (author) args.push("--author", author);
359
- if (assignee) args.push("--assignee", assignee);
360
- if (milestone) args.push("--milestone", milestone);
361
- if (limit) args.push("--limit", String(limit));
362
- return args;
363
- };
364
-
365
- if (keywords) {
366
- const args = ["search", kind === "issue" ? "issues" : "prs", ...repoArgs(repo), keywords];
367
- if (kind === "pr" && state === "merged") {
368
- args.push("--merged");
369
- } else if (state && state !== "all") {
370
- args.push("--state", state);
371
- } else if (!state) {
372
- // keep the browse tools' default rather than gh search's implicit
373
- // open+closed; pass state="all" to cover closed items
374
- args.push("--state", "open");
375
- }
376
- return addFilters(args);
377
- }
351
+ const { repo, state, label, author, assignee, milestone, limit } = params;
378
352
 
379
353
  const args = [kind, "list", ...repoArgs(repo)];
380
354
  if (state) args.push("--state", state);
381
- return addFilters(args);
355
+ if (label) args.push("--label", label);
356
+ if (author) args.push("--author", author);
357
+ if (assignee) args.push("--assignee", assignee);
358
+ if (milestone) args.push("--milestone", milestone);
359
+ if (limit) args.push("--limit", String(limit));
360
+ return args;
382
361
  }
383
362
 
384
363
  async function listGithub(
@@ -389,6 +368,19 @@ async function listGithub(
389
368
  return ghExec(listGithubArgs(kind, params), ctx);
390
369
  }
391
370
 
371
+ /** Run a keyword search through the octokit client and render the rows. */
372
+ async function searchList(
373
+ kind: "issue" | "pr",
374
+ params: ListFilters,
375
+ githubSearch: GithubSearch,
376
+ ): Promise<string> {
377
+ const hits = await githubSearch.search(kind, params);
378
+ if (hits.length === 0) {
379
+ return `(no matching ${kind === "issue" ? "issues" : "pull requests"})`;
380
+ }
381
+ return renderHits(hits, { repo: params.repo, fields: params.fields });
382
+ }
383
+
392
384
  // ── CI helpers ───────────────────────────────────────────────────────────────
393
385
 
394
386
  export interface StepInfo {
@@ -1144,6 +1136,8 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1144
1136
  return;
1145
1137
  }
1146
1138
 
1139
+ const githubSearch = createGithubSearch();
1140
+
1147
1141
  // ── read-github-issue ──────────────────────────────────────────────────────
1148
1142
  pi.registerTool({
1149
1143
  name: "read-github-issue",
@@ -1180,25 +1174,36 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1180
1174
  name: "list-github-issues",
1181
1175
  label: "GitHub Issues List",
1182
1176
  description:
1183
- 'List GitHub issues with optional filters and keyword search. When repo is omitted, keyword search runs across GitHub. Keyword results default to open issues — pass state="all" to include closed ones.',
1177
+ 'List GitHub issues with optional filters and keyword search. When repo is omitted, keyword search runs across GitHub. Keyword search defaults to open issues — pass state="all" to include closed ones. Set fields to choose the columns of each result row.',
1184
1178
  promptSnippet: "List or search GitHub issues",
1185
1179
  parameters: Type.Object({
1186
1180
  repo: Type.Optional(Type.String({ description: "OWNER/REPO (defaults to current repo)" })),
1187
1181
  keywords: Type.Optional(Type.String({ description: "Search keywords (free text)" })),
1188
1182
  state: Type.Optional(
1189
1183
  Type.String({
1190
- description: "open, closed, all (default: open; with keywords, all covers closed too)",
1184
+ description:
1185
+ "open, closed, all (default: open; all applies to keyword search and covers closed too)",
1191
1186
  }),
1192
1187
  ),
1193
1188
  label: Type.Optional(Type.String({ description: "Filter by label" })),
1194
1189
  author: Type.Optional(Type.String({ description: "Filter by author" })),
1195
- assignee: Type.Optional(Type.String({ description: "Filter by assignee" })),
1190
+ assignee: Type.Optional(
1191
+ Type.String({ description: "Filter by assignee (@me for yourself)" }),
1192
+ ),
1196
1193
  milestone: Type.Optional(Type.String({ description: "Filter by milestone" })),
1197
- limit: Type.Optional(Type.Number({ description: "Max results (default 30)" })),
1194
+ limit: Type.Optional(Type.Number({ description: "Max results (default 30, max 100)" })),
1195
+ fields: Type.Optional(
1196
+ Type.String({
1197
+ description:
1198
+ "Comma-separated columns for keyword-search rows (default: number,state,title,labels,updatedAt; adds repo when no repo given). Valid: number,state,title,url,author,labels,milestone,assignees,comments,repo,createdAt,updatedAt,closedAt",
1199
+ }),
1200
+ ),
1198
1201
  }),
1199
1202
  async execute(_id, params, signal, _onUpdate, ctx) {
1200
1203
  const result = toToolResult(
1201
- await listGithub("issue", params, { cwd: ctx.cwd, signal, input: params }),
1204
+ params.keywords
1205
+ ? await searchList("issue", params, githubSearch)
1206
+ : await listGithub("issue", params, { cwd: ctx.cwd, signal, input: params }),
1202
1207
  params,
1203
1208
  );
1204
1209
  result.details.pendant = subtitlePendant(params);
@@ -1242,7 +1247,7 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1242
1247
  name: "list-github-prs",
1243
1248
  label: "GitHub PRs List",
1244
1249
  description:
1245
- 'List GitHub pull requests with optional filters and keyword search. When repo is omitted, keyword search runs across GitHub. Keyword results default to open PRs — pass state="all" (open + closed) or state="merged" to broaden.',
1250
+ 'List GitHub pull requests with optional filters and keyword search. When repo is omitted, keyword search runs across GitHub. Keyword search defaults to open PRs — pass state="merged", state="closed" (merged excluded) or state="all" to broaden. Set fields to choose the columns of each result row.',
1246
1251
  promptSnippet: "List or search GitHub PRs",
1247
1252
  parameters: Type.Object({
1248
1253
  repo: Type.Optional(Type.String({ description: "OWNER/REPO (defaults to current repo)" })),
@@ -1250,18 +1255,28 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1250
1255
  state: Type.Optional(
1251
1256
  Type.String({
1252
1257
  description:
1253
- "open, closed, merged, all (default: open; with keywords, merged and all broaden the search)",
1258
+ "open, closed, merged, all (default: open; all applies to keyword search and covers open + closed + merged)",
1254
1259
  }),
1255
1260
  ),
1256
1261
  label: Type.Optional(Type.String({ description: "Filter by label" })),
1257
1262
  author: Type.Optional(Type.String({ description: "Filter by author" })),
1258
- assignee: Type.Optional(Type.String({ description: "Filter by assignee" })),
1263
+ assignee: Type.Optional(
1264
+ Type.String({ description: "Filter by assignee (@me for yourself)" }),
1265
+ ),
1259
1266
  milestone: Type.Optional(Type.String({ description: "Filter by milestone" })),
1260
- limit: Type.Optional(Type.Number({ description: "Max results (default 30)" })),
1267
+ limit: Type.Optional(Type.Number({ description: "Max results (default 30, max 100)" })),
1268
+ fields: Type.Optional(
1269
+ Type.String({
1270
+ description:
1271
+ "Comma-separated columns for keyword-search rows (default: number,state,title,labels,updatedAt; adds repo when no repo given). Valid: number,state,title,url,author,labels,milestone,assignees,comments,repo,createdAt,updatedAt,closedAt,mergedAt",
1272
+ }),
1273
+ ),
1261
1274
  }),
1262
1275
  async execute(_id, params, signal, _onUpdate, ctx) {
1263
1276
  const result = toToolResult(
1264
- await listGithub("pr", params, { cwd: ctx.cwd, signal, input: params }),
1277
+ params.keywords
1278
+ ? await searchList("pr", params, githubSearch)
1279
+ : await listGithub("pr", params, { cwd: ctx.cwd, signal, input: params }),
1265
1280
  params,
1266
1281
  );
1267
1282
  result.details.pendant = subtitlePendant(params);
@@ -0,0 +1,294 @@
1
+ /**
2
+ * GitHub search client built on octokit, authenticated with the token from the
3
+ * system `gh` CLI (`gh auth token`). Used by the gh-readonly search tools.
4
+ *
5
+ * Unlike the `gh search` CLI, the search API has no `--state all` and treats a
6
+ * merged PR's state as `closed` — so merged/closed disambiguation is expressed
7
+ * through qualifiers here (`is:merged`, `state:closed -is:merged`) and the
8
+ * rendered state is derived from `pull_request.merged_at`.
9
+ */
10
+
11
+ import { spawn } from "node:child_process";
12
+
13
+ import { Octokit } from "octokit";
14
+
15
+ export type SearchKind = "issue" | "pr";
16
+
17
+ export interface SearchParams {
18
+ repo?: string;
19
+ keywords?: string;
20
+ state?: string;
21
+ label?: string;
22
+ author?: string;
23
+ assignee?: string;
24
+ milestone?: string;
25
+ limit?: number;
26
+ }
27
+
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;
34
+ /** 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
+ }
46
+
47
+ export interface SearchHit {
48
+ number: number;
49
+ /** `open`, `closed` or `merged` (merged is inferred from pull_request.merged_at). */
50
+ state: "open" | "closed" | "merged";
51
+ title: string;
52
+ url: string;
53
+ repo: string;
54
+ author: string;
55
+ labels: string[];
56
+ milestone: string;
57
+ assignees: string[];
58
+ comments: number;
59
+ createdAt: string;
60
+ updatedAt: string;
61
+ closedAt: string;
62
+ mergedAt: string;
63
+ }
64
+
65
+ /**
66
+ * Build the `q` parameter for the issues-and-pull-requests search endpoint.
67
+ *
68
+ * State semantics (the whole reason this client exists):
69
+ * - default is `open`, matching the browse tools
70
+ * - `all` applies no state filter (open + closed)
71
+ * - for PRs, `merged` maps to `is:merged` and `closed` excludes merged PRs,
72
+ * because the search API reports a merged PR's state as `closed`
73
+ */
74
+ export function buildSearchQuery(kind: SearchKind, params: SearchParams): string {
75
+ const { repo, keywords, label, author, assignee, milestone } = params;
76
+ const state = params.state ?? "open";
77
+
78
+ const parts: string[] = [];
79
+ if (repo) parts.push(`repo:${repo}`);
80
+ parts.push(kind === "issue" ? "is:issue" : "is:pr");
81
+ switch (state) {
82
+ case "open": {
83
+ parts.push("state:open");
84
+
85
+ break;
86
+ }
87
+ case "closed": {
88
+ parts.push(kind === "pr" ? "state:closed -is:merged" : "state:closed");
89
+
90
+ break;
91
+ }
92
+ case "merged": {
93
+ if (kind === "issue") throw new Error("state=merged is only valid for PR searches");
94
+ parts.push("is:merged");
95
+
96
+ break;
97
+ }
98
+ default: {
99
+ if (state !== "all") {
100
+ throw new Error(
101
+ `invalid state: ${state} (expected open, closed, ${kind === "pr" ? "merged, " : ""}all)`,
102
+ );
103
+ }
104
+ }
105
+ }
106
+ if (keywords) parts.push(keywords);
107
+ if (label) parts.push(`label:${quoteQualifier(label)}`);
108
+ if (author) parts.push(`author:${author}`);
109
+ if (assignee) parts.push(`assignee:${assignee}`);
110
+ if (milestone) parts.push(`milestone:${quoteQualifier(milestone)}`);
111
+ return parts.join(" ");
112
+ }
113
+
114
+ /** Quote a qualifier value that contains whitespace or special characters. */
115
+ function quoteQualifier(value: string): string {
116
+ if (/^[\w@./-]+$/.test(value)) return value;
117
+ return `"${value.replaceAll('"', String.raw`\"`)}"`;
118
+ }
119
+
120
+ const FIELD_EXTRACTORS: Record<string, (hit: SearchHit) => string> = {
121
+ number: (h) => String(h.number),
122
+ state: (h) => h.state,
123
+ title: (h) => h.title,
124
+ url: (h) => h.url,
125
+ repo: (h) => h.repo,
126
+ author: (h) => h.author,
127
+ labels: (h) => h.labels.join(","),
128
+ milestone: (h) => h.milestone,
129
+ assignees: (h) => h.assignees.join(","),
130
+ comments: (h) => String(h.comments),
131
+ createdAt: (h) => h.createdAt,
132
+ updatedAt: (h) => h.updatedAt,
133
+ closedAt: (h) => h.closedAt,
134
+ mergedAt: (h) => h.mergedAt,
135
+ };
136
+
137
+ export const SEARCH_FIELDS: readonly string[] = Object.keys(FIELD_EXTRACTORS);
138
+
139
+ /** Render search hits as tab-separated rows; one row per hit, one column per field. */
140
+ export function renderHits(hits: SearchHit[], options: { repo?: string; fields?: string }): string {
141
+ const requested = options.fields
142
+ ? options.fields
143
+ .split(",")
144
+ .map((f) => f.trim())
145
+ .filter(Boolean)
146
+ : options.repo
147
+ ? ["number", "state", "title", "labels", "updatedAt"]
148
+ : ["repo", "number", "state", "title", "labels", "updatedAt"];
149
+ const unknown = requested.filter((f) => !(f in FIELD_EXTRACTORS));
150
+ if (unknown.length > 0) {
151
+ throw new Error(
152
+ `unknown field${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")} (valid: ${SEARCH_FIELDS.join(", ")})`,
153
+ );
154
+ }
155
+ return hits.map((hit) => requested.map((f) => FIELD_EXTRACTORS[f](hit)).join("\t")).join("\n");
156
+ }
157
+
158
+ function toDate(iso: string | null | undefined): string {
159
+ return iso ? iso.slice(0, 10) : "";
160
+ }
161
+
162
+ const REPO_URL_RE = /\/repos\/([^/]+\/[^/]+)$/;
163
+
164
+ /** repository_url looks like https://api.github.com/repos/OWNER/REPO */
165
+ function repoName(raw: RawSearchItem): string {
166
+ const match = REPO_URL_RE.exec(raw.repository_url);
167
+ return match?.[1] ?? "";
168
+ }
169
+
170
+ function normalize(raw: RawSearchItem): SearchHit {
171
+ const mergedAt = raw.pull_request?.merged_at ?? "";
172
+ return {
173
+ number: raw.number,
174
+ state: mergedAt ? "merged" : (raw.state as "open" | "closed"),
175
+ title: raw.title,
176
+ url: raw.html_url,
177
+ repo: repoName(raw),
178
+ author: raw.user?.login ?? "",
179
+ labels: raw.labels.map((l) => l.name),
180
+ milestone: raw.milestone?.title ?? "",
181
+ assignees: raw.assignees.map((a) => a.login),
182
+ comments: raw.comments,
183
+ createdAt: toDate(raw.created_at),
184
+ updatedAt: toDate(raw.updated_at),
185
+ closedAt: toDate(raw.closed_at),
186
+ mergedAt: toDate(mergedAt),
187
+ };
188
+ }
189
+
190
+ function ghAuthToken(): Promise<string> {
191
+ return new Promise((resolve, reject) => {
192
+ const proc = spawn("gh", ["auth", "token"], { stdio: ["ignore", "pipe", "pipe"] });
193
+ let stdout = "";
194
+ let stderr = "";
195
+ const timer = setTimeout(() => proc.kill("SIGTERM"), 10_000);
196
+ proc.stdout.on("data", (d: Buffer) => {
197
+ stdout += String(d);
198
+ });
199
+ proc.stderr.on("data", (d: Buffer) => {
200
+ stderr += String(d);
201
+ });
202
+ proc.on("error", (err) => {
203
+ clearTimeout(timer);
204
+ reject(new Error(`failed to start gh: ${err.message}`));
205
+ });
206
+ proc.on("close", (code) => {
207
+ clearTimeout(timer);
208
+ const token = stdout.trim();
209
+ if (code === 0 && token) {
210
+ resolve(token);
211
+ } else {
212
+ reject(
213
+ new Error(
214
+ stderr.trim() || `gh auth token exited with code ${code} — run "gh auth login" first`,
215
+ ),
216
+ );
217
+ }
218
+ });
219
+ });
220
+ }
221
+
222
+ /**
223
+ * Error thrown when the GitHub search API rejects the request. Carries the
224
+ * original toolcall params so the model can see the exact input.
225
+ */
226
+ export class GithubSearchError extends Error {
227
+ readonly params: SearchParams;
228
+ readonly status: number | undefined;
229
+
230
+ constructor(message: string, params: SearchParams, status?: number) {
231
+ super(`${message} (input: ${JSON.stringify(params)})`);
232
+ this.name = "GithubSearchError";
233
+ this.params = params;
234
+ this.status = status;
235
+ }
236
+ }
237
+
238
+ function describeHttpError(status: number | undefined): string {
239
+ if (status === 401)
240
+ return 'GitHub auth failed (401): token invalid or expired — run "gh auth login"';
241
+ if (status === 403) return "GitHub rate limit or permissions error (403)";
242
+ if (status === 404) return "repository not found, or the token has no access to it (404)";
243
+ return `GitHub API error${status === undefined ? "" : ` (HTTP ${status})`}`;
244
+ }
245
+
246
+ export interface GithubSearch {
247
+ search(kind: SearchKind, params: SearchParams): Promise<SearchHit[]>;
248
+ }
249
+
250
+ /**
251
+ * Create a search client. The octokit instance (and its auth token) is cached
252
+ * in the returned closure, so repeated searches reuse the same client without
253
+ * module-level state.
254
+ */
255
+ export function createGithubSearch(): GithubSearch {
256
+ let client: Octokit | undefined;
257
+
258
+ async function getClient(): Promise<Octokit> {
259
+ client ??= new Octokit({ auth: await ghAuthToken() });
260
+ return client;
261
+ }
262
+
263
+ return {
264
+ async search(kind, params) {
265
+ const limit = Math.min(Math.max(params.limit ?? 30, 1), 100);
266
+ const effective = { ...params, limit };
267
+
268
+ for (let attempt = 0; ; attempt += 1) {
269
+ try {
270
+ const octokit = await getClient();
271
+ if (effective.assignee === "@me") {
272
+ const { data } = await octokit.rest.users.getAuthenticated();
273
+ effective.assignee = data.login;
274
+ }
275
+ const q = buildSearchQuery(kind, effective);
276
+ const { data } = await octokit.rest.search.issuesAndPullRequests({
277
+ q,
278
+ per_page: limit,
279
+ });
280
+ return data.items.map((item) => normalize(item as unknown as RawSearchItem));
281
+ } catch (error) {
282
+ const status = (error as { status?: number }).status;
283
+ // A stale cached token can produce 401s; drop the cache and retry once.
284
+ if (status === 401 && attempt === 0 && client) {
285
+ client = undefined;
286
+ continue;
287
+ }
288
+ const message = (error as { message?: string }).message ?? String(error);
289
+ throw new GithubSearchError(`${describeHttpError(status)}: ${message}`, params, status);
290
+ }
291
+ }
292
+ },
293
+ };
294
+ }
@@ -266,6 +266,19 @@ interface ServerCapabilities {
266
266
  [key: string]: unknown;
267
267
  }
268
268
 
269
+ /**
270
+ * create 的直连缺省(单一来源):lsp.ts 的 resolveConfig 解析超时/LRU 时引用
271
+ * 同一组数值,保证配置层缺省与直连调用方取值一致。
272
+ */
273
+ export const clientDefaults = {
274
+ diagnosticsDebounceMs: 150,
275
+ diagnosticsDocumentWaitTimeoutMs: 5_000,
276
+ diagnosticsFullWaitTimeoutMs: 10_000,
277
+ diagnosticsRequestTimeoutMs: 3_000,
278
+ initializeTimeoutMs: 45_000,
279
+ maxOpenDocuments: 32,
280
+ } as const;
281
+
269
282
  export interface CreateInput {
270
283
  serverID: string;
271
284
  server: LspServerHandle;
@@ -416,12 +429,15 @@ function stopProcess(process: LspServerHandle["process"]): Promise<void> {
416
429
  }
417
430
 
418
431
  export async function create(input: CreateInput): Promise<LspClient> {
419
- const diagnosticsDebounceMs = input.diagnosticsDebounceMs ?? 150;
420
- const diagnosticsDocumentWaitTimeoutMs = input.diagnosticsDocumentWaitTimeoutMs ?? 5_000;
421
- const diagnosticsFullWaitTimeoutMs = input.diagnosticsFullWaitTimeoutMs ?? 10_000;
422
- const diagnosticsRequestTimeoutMs = input.diagnosticsRequestTimeoutMs ?? 3_000;
423
- const initializeTimeoutMs = input.initializeTimeoutMs ?? 45_000;
424
- const maxOpenDocuments = input.maxOpenDocuments ?? 32;
432
+ const diagnosticsDebounceMs = input.diagnosticsDebounceMs ?? clientDefaults.diagnosticsDebounceMs;
433
+ const diagnosticsDocumentWaitTimeoutMs =
434
+ input.diagnosticsDocumentWaitTimeoutMs ?? clientDefaults.diagnosticsDocumentWaitTimeoutMs;
435
+ const diagnosticsFullWaitTimeoutMs =
436
+ input.diagnosticsFullWaitTimeoutMs ?? clientDefaults.diagnosticsFullWaitTimeoutMs;
437
+ const diagnosticsRequestTimeoutMs =
438
+ input.diagnosticsRequestTimeoutMs ?? clientDefaults.diagnosticsRequestTimeoutMs;
439
+ const initializeTimeoutMs = input.initializeTimeoutMs ?? clientDefaults.initializeTimeoutMs;
440
+ const maxOpenDocuments = input.maxOpenDocuments ?? clientDefaults.maxOpenDocuments;
425
441
 
426
442
  const connection = createMessageConnection(
427
443
  new StreamMessageReader(input.server.process.stdout),
@@ -5,7 +5,9 @@
5
5
  * 闭包变量,不做成模块级全局;
6
6
  * - 配置来源:全局 `~/.pi/agent/lsp.json` + 本地 `<cwd>/.pi/lsp.json`
7
7
  * (本地覆盖全局):`servers` 按 id 合并(同名 id 整体覆盖、新增 id,全局
8
- * 其余服务器保留),`enabled`/`disabled` 白名单与各超时参数继续生效;
8
+ * 其余服务器保留),`watch` 按字段合并(本地逐字段覆盖、`ignore` 取并集);
9
+ * 合并结果解析为 `ResolvedLspConfig`(缺省值应用、超时换算为 ms、白名单转
10
+ * Set),消费方不接触"未配置"歧义;
9
11
  * 没有内置默认服务器,所有服务器均须在配置里定义;
10
12
  * 配置在每个工具的调用 cwd 下惰性读取;enabled 引用不存在的服务器 id
11
13
  * 是配置错误:全局配置在扩展加载(createLspService)时抛错,本地配置在
@@ -29,8 +31,8 @@ import type { Hover, WorkspaceEdit } from "vscode-languageserver-types";
29
31
 
30
32
  import { type LspServerAdapter } from "./adapter.js";
31
33
  import {
34
+ clientDefaults,
32
35
  create,
33
- type CreateInput,
34
36
  type Diagnostic,
35
37
  type Info as LspClient,
36
38
  type InspectLocation,
@@ -41,27 +43,32 @@ import {
41
43
  WATCH_KIND_DELETE,
42
44
  } from "./client.js";
43
45
  import { report } from "./diagnostic.js";
44
- import { createAdapters, mergeServerRecords, serverConfigSchema } from "./server-config.js";
46
+ import {
47
+ createAdapters,
48
+ mergeServerRecords,
49
+ type ServerConfig,
50
+ serverConfigSchema,
51
+ } from "./server-config.js";
45
52
  import { type FileChange, watchWorkspace, type WorkspaceWatcher } from "./watcher.js";
46
53
 
47
- /** 超时值:number(毫秒,>=1)或字符串("500"、"5s"、"1m"),Parse 后由 toMs 统一换算。 */
54
+ /** 超时值:number(毫秒,>=1)或字符串("500"、"5s"、"1m"),换算发生在 resolveConfig。 */
48
55
  const timeoutValue = Type.Union([Type.Number({ minimum: 1 }), Type.String()]);
49
56
 
50
57
  /** lsp.json 顶层 `watch` 段:工作区文件监听配置。 */
51
58
  const watchConfigSchema = Type.Object({
52
- /** 是否启用工作区文件监听(缺省 true)。 */
59
+ /** 是否启用工作区文件监听。 */
53
60
  enabled: Type.Optional(Type.Boolean()),
54
- /** 事件去抖时长(ms,缺省 300),沿用 timeoutValue 字符串写法。 */
61
+ /** 事件去抖时长(ms,沿用 timeoutValue 字符串写法)。 */
55
62
  debounceMs: Type.Optional(timeoutValue),
56
- /** 单批事件上限(缺省 500),超出截断并提示一次。 */
63
+ /** 单批事件上限,超出截断并提示一次。 */
57
64
  maxBatch: Type.Optional(Type.Number({ minimum: 1 })),
58
65
  /** 追加忽略 glob(相对工作区根的 POSIX 路径)。 */
59
66
  ignore: Type.Optional(Type.Array(Type.String())),
60
67
  });
61
68
 
62
- /** lsp.json 的配置项(全局与本地同构)。 */
69
+ /** lsp.json 的配置项(全局与本地同构;只描述用户可写的原始形态,缺省见 configDefaults)。 */
63
70
  const lspConfigSchema = Type.Object({
64
- /** 配置文件版本(当前 1);未知版本会被 typebox 严格校验拒绝并回退空配置。 */
71
+ /** 配置文件版本(当前 1);未知版本会被 typebox 严格校验拒绝。 */
65
72
  version: Type.Optional(Type.Number()),
66
73
  /** 配置驱动的语言服务器定义(id → 配置);无内置默认,全部在此定义。 */
67
74
  servers: Type.Optional(Type.Record(Type.String(), serverConfigSchema)),
@@ -69,26 +76,69 @@ const lspConfigSchema = Type.Object({
69
76
  enabled: Type.Optional(Type.Array(Type.String())),
70
77
  /** 从启用集中排除的服务器 id(缺省 = 无)。 */
71
78
  disabled: Type.Optional(Type.Array(Type.String())),
72
- /** 工作区文件监听配置(缺省全部字段用 watcher 默认值)。 */
79
+ /** 工作区文件监听配置(缺省全部字段见 configDefaults.watch)。 */
73
80
  watch: Type.Optional(watchConfigSchema),
74
- /** 驻留文档上限(LRU 容量,缺省 32)。 */
81
+ /** 驻留文档上限(LRU 容量),超过时淘汰最久未使用并 didClose。 */
75
82
  maxOpenDocuments: Type.Optional(Type.Number({ minimum: 1 })),
76
- /** push 诊断去抖(ms,缺省 150)。 */
83
+ /** push 诊断去抖(ms)。 */
77
84
  diagnosticsDebounceMs: Type.Optional(timeoutValue),
78
- /** document 模式诊断等待上限(ms,缺省 5_000)。 */
85
+ /** document 模式诊断等待上限(ms)。 */
79
86
  diagnosticsDocumentWaitTimeoutMs: Type.Optional(timeoutValue),
80
- /** full 模式诊断等待上限(ms,缺省 10_000)。 */
87
+ /** full 模式诊断等待上限(ms)。 */
81
88
  diagnosticsFullWaitTimeoutMs: Type.Optional(timeoutValue),
82
- /** 单次 pull 诊断请求超时(ms,缺省 3_000)。 */
89
+ /** 单次 pull 诊断请求超时(ms)。 */
83
90
  diagnosticsRequestTimeoutMs: Type.Optional(timeoutValue),
84
- /** 服务器 initialize 握手超时(ms,缺省 45_000)。 */
91
+ /** 服务器 initialize 握手超时(ms)。 */
85
92
  initializeTimeoutMs: Type.Optional(timeoutValue),
86
93
  });
87
94
 
88
- /** 配置值;超时字段为原始写法(number 或字符串),换算发生在 timeoutOptions。 */
95
+ /** 配置值(单文件解析结果);超时字段为原始写法(number 或字符串),换算在 resolveConfig。 */
89
96
  export type LspConfig = Static<typeof lspConfigSchema>;
90
97
 
91
- /** "500" → 500、"5s" → 5000、"1m" → 60000;无效字符串返回 NaN(由 toMs 过滤)。 */
98
+ /**
99
+ * 解析期缺省(单一来源)。超时/LRU 与 client.create 共用 clientDefaults;
100
+ * watch 无 client 对应项,数值在此集中。
101
+ */
102
+ export const configDefaults = {
103
+ watch: {
104
+ enabled: true,
105
+ debounceMs: 300,
106
+ flushMs: 1_000,
107
+ maxBatch: 500,
108
+ },
109
+ maxOpenDocuments: clientDefaults.maxOpenDocuments,
110
+ } as const;
111
+
112
+ /** 生效的工作区监听配置(缺省值已应用)。 */
113
+ export interface EffectiveWatchConfig {
114
+ enabled: boolean;
115
+ debounceMs: number;
116
+ flushMs: number;
117
+ maxBatch: number;
118
+ ignore: string[];
119
+ }
120
+
121
+ /** 全局 + 本地合并并解析后的生效配置:所有字段为确定值,无"未配置"歧义。 */
122
+ export interface ResolvedLspConfig {
123
+ /** 合并后的服务器定义(未配置任何服务器时为空表)。 */
124
+ servers: Record<string, ServerConfig>;
125
+ /** enabled 白名单(undefined = 全部启用)。 */
126
+ enabled: Set<string> | undefined;
127
+ /** 从启用集中排除的服务器 id(undefined = 无排除)。 */
128
+ disabled: Set<string> | undefined;
129
+ /** 工作区监听配置(缺省值已应用)。 */
130
+ watch: EffectiveWatchConfig;
131
+ /** 驻留文档 LRU 容量。 */
132
+ maxOpenDocuments: number;
133
+ /** 以下超时均为换算后的毫秒数(缺省见 configDefaults / clientDefaults)。 */
134
+ diagnosticsDebounceMs: number;
135
+ diagnosticsDocumentWaitTimeoutMs: number;
136
+ diagnosticsFullWaitTimeoutMs: number;
137
+ diagnosticsRequestTimeoutMs: number;
138
+ initializeTimeoutMs: number;
139
+ }
140
+
141
+ /** "500" → 500、"5s" → 5000、"1m" → 60000;无效字符串返回 NaN(由调用方兜底缺省)。 */
92
142
  function parseTimeoutString(value: string): number {
93
143
  // 单位组永远参与匹配(缺省为空串),避免"可选捕获组在类型上不可空"的歧义
94
144
  const match = /^(\d+(?:\.\d+)?)(ms|s|m|h|)\s*$/.exec(value.trim());
@@ -105,65 +155,51 @@ function parseTimeoutString(value: string): number {
105
155
  return amount * (factor ?? 1);
106
156
  }
107
157
 
158
+ /** 时长字段换算为 ms:number 原样、字符串按 parseTimeoutString;无效值返回 undefined。 */
108
159
  function toMs(value: number | string | undefined): number | undefined {
109
160
  if (value === undefined) return undefined;
110
161
  const ms = typeof value === "number" ? value : parseTimeoutString(value);
111
162
  return Number.isFinite(ms) && ms > 0 ? ms : undefined;
112
163
  }
113
164
 
114
- /** 从配置里取超时字段(缺省 undefined,create 用自身默认值)。 */
115
- function timeoutOptions(
116
- config: LspConfig,
117
- ): Pick<
118
- CreateInput,
119
- | "diagnosticsDebounceMs"
120
- | "diagnosticsDocumentWaitTimeoutMs"
121
- | "diagnosticsFullWaitTimeoutMs"
122
- | "diagnosticsRequestTimeoutMs"
123
- | "initializeTimeoutMs"
124
- > {
165
+ /** 把合并后的原始配置解析为生效配置:应用 configDefaults 缺省、字符串时长换算、白名单转 Set。 */
166
+ export function resolveConfig(raw: LspConfig): ResolvedLspConfig {
125
167
  return {
126
- diagnosticsDebounceMs: toMs(config.diagnosticsDebounceMs),
127
- diagnosticsDocumentWaitTimeoutMs: toMs(config.diagnosticsDocumentWaitTimeoutMs),
128
- diagnosticsFullWaitTimeoutMs: toMs(config.diagnosticsFullWaitTimeoutMs),
129
- diagnosticsRequestTimeoutMs: toMs(config.diagnosticsRequestTimeoutMs),
130
- initializeTimeoutMs: toMs(config.initializeTimeoutMs),
131
- };
132
- }
133
-
134
- /** 生效的工作区监听配置(应用缺省值)。 */
135
- export interface EffectiveWatchConfig {
136
- enabled: boolean;
137
- debounceMs: number;
138
- flushMs: number;
139
- maxBatch: number;
140
- ignore: string[];
141
- }
142
-
143
- /** lsp.json `watch` 段 + 缺省值;debounceMs 字符串时长在此换算。 */
144
- export function watchOptions(config: LspConfig): EffectiveWatchConfig {
145
- const watch = config.watch;
146
- return {
147
- enabled: watch?.enabled ?? true,
148
- debounceMs: toMs(watch?.debounceMs) ?? 300,
149
- flushMs: 1_000,
150
- maxBatch: watch?.maxBatch ?? 500,
151
- ignore: watch?.ignore ?? [],
168
+ servers: raw.servers ?? {},
169
+ enabled: raw.enabled === undefined ? undefined : new Set(raw.enabled),
170
+ disabled: raw.disabled === undefined ? undefined : new Set(raw.disabled),
171
+ watch: {
172
+ enabled: raw.watch?.enabled ?? configDefaults.watch.enabled,
173
+ debounceMs: toMs(raw.watch?.debounceMs) ?? configDefaults.watch.debounceMs,
174
+ flushMs: configDefaults.watch.flushMs,
175
+ maxBatch: raw.watch?.maxBatch ?? configDefaults.watch.maxBatch,
176
+ ignore: raw.watch?.ignore ?? [],
177
+ },
178
+ maxOpenDocuments: raw.maxOpenDocuments ?? configDefaults.maxOpenDocuments,
179
+ diagnosticsDebounceMs: toMs(raw.diagnosticsDebounceMs) ?? clientDefaults.diagnosticsDebounceMs,
180
+ diagnosticsDocumentWaitTimeoutMs:
181
+ toMs(raw.diagnosticsDocumentWaitTimeoutMs) ?? clientDefaults.diagnosticsDocumentWaitTimeoutMs,
182
+ diagnosticsFullWaitTimeoutMs:
183
+ toMs(raw.diagnosticsFullWaitTimeoutMs) ?? clientDefaults.diagnosticsFullWaitTimeoutMs,
184
+ diagnosticsRequestTimeoutMs:
185
+ toMs(raw.diagnosticsRequestTimeoutMs) ?? clientDefaults.diagnosticsRequestTimeoutMs,
186
+ initializeTimeoutMs: toMs(raw.initializeTimeoutMs) ?? clientDefaults.initializeTimeoutMs,
152
187
  };
153
188
  }
154
189
 
155
- /** 驻留文档 LRU 容量(缺省 32)。 */
156
- export function maxOpenDocuments(config: LspConfig): number {
157
- return config.maxOpenDocuments ?? 32;
190
+ /** 文件不存在的读取错误(ENOENT),其余错误原样抛出。 */
191
+ function isMissingFile(error: unknown): boolean {
192
+ return (error as { code?: unknown }).code === "ENOENT";
158
193
  }
159
194
 
160
- /** 读取并解析单个配置文件;文件不存在或解析失败时返回空配置。 */
195
+ /** 读取并解析单个配置文件;文件不存在视为空配置,JSON / typebox 校验错误直接抛出。 */
161
196
  async function readConfigFile(filePath: string): Promise<LspConfig> {
162
197
  try {
163
198
  const raw = await readFile(filePath, "utf8");
164
199
  return Value.Parse(lspConfigSchema, JSON.parse(raw) as unknown);
165
- } catch {
166
- return {};
200
+ } catch (error) {
201
+ if (isMissingFile(error)) return {};
202
+ throw error;
167
203
  }
168
204
  }
169
205
 
@@ -172,8 +208,9 @@ function readConfigFileSync(filePath: string): LspConfig {
172
208
  try {
173
209
  const raw = readFileSync(filePath, "utf8");
174
210
  return Value.Parse(lspConfigSchema, JSON.parse(raw) as unknown);
175
- } catch {
176
- return {};
211
+ } catch (error) {
212
+ if (isMissingFile(error)) return {};
213
+ throw error;
177
214
  }
178
215
  }
179
216
 
@@ -182,11 +219,11 @@ function readConfigFileSync(filePath: string): LspConfig {
182
219
  * servers),否则抛配置错误(避免白名单静默失效)。disabled 中未注册的 id
183
220
  * 直接忽略。
184
221
  */
185
- function validateConfig(config: LspConfig, adapters?: LspServerAdapter[]): void {
222
+ function validateConfig(config: ResolvedLspConfig, adapters?: LspServerAdapter[]): void {
186
223
  const available = new Set(
187
- adapters ? adapters.map((adapter) => adapter.id) : Object.keys(config.servers ?? {}),
224
+ adapters ? adapters.map((adapter) => adapter.id) : Object.keys(config.servers),
188
225
  );
189
- const unknown = (config.enabled ?? []).filter((id) => !available.has(id));
226
+ const unknown = config.enabled === undefined ? [] : [...config.enabled.difference(available)];
190
227
  if (unknown.length > 0) {
191
228
  const list = [...available].toSorted().join(", ") || "none";
192
229
  throw new Error(
@@ -195,26 +232,46 @@ function validateConfig(config: LspConfig, adapters?: LspServerAdapter[]): void
195
232
  }
196
233
  }
197
234
 
235
+ /** 合并 watch 段:全局为基底、本地逐字段覆盖;ignore 取并集去重(全局在前)。两边都未配置时返回 undefined,调用方据此省略 watch 键。 */
236
+ function mergeWatch(
237
+ globalWatch: LspConfig["watch"],
238
+ localWatch: LspConfig["watch"],
239
+ ): LspConfig["watch"] {
240
+ if (!globalWatch && !localWatch) return undefined;
241
+ return {
242
+ ...globalWatch,
243
+ ...localWatch,
244
+ ignore: [...new Set([...(globalWatch?.ignore ?? []), ...(localWatch?.ignore ?? [])])],
245
+ };
246
+ }
247
+
198
248
  /**
199
- * 合并后的生效配置:全局 `~/.pi/agent/lsp.json` 为基底,本地
200
- * `<cwd>/.pi/lsp.json` 覆盖——顶层标量字段(enabled/disabled、超时等)本地
201
- * 直接替换;`servers` 按 id 合并(同名 id 整体覆盖、新增 id,全局其余服务器
202
- * 保留)。
249
+ * 合并全局与本地两份原始配置(纯函数,供 loadLspConfig 与针对性测试使用):
250
+ * 全局为基底、本地逐字段覆盖;`servers` 按 id 合并(同名 id 整体覆盖、新增 id,
251
+ * 全局其余服务器保留);`watch` 按字段合并(本地逐字段覆盖、缺省用全局,
252
+ * `ignore` 取并集去重)。
203
253
  */
254
+ export function mergeConfig(globalConfig: LspConfig, localConfig: LspConfig): LspConfig {
255
+ const servers = mergeServerRecords(globalConfig.servers, localConfig.servers);
256
+ const watch = mergeWatch(globalConfig.watch, localConfig.watch);
257
+ return {
258
+ ...globalConfig,
259
+ ...localConfig,
260
+ ...(servers && { servers }),
261
+ ...(watch && { watch }),
262
+ };
263
+ }
264
+
265
+ /** 读取全局 + 本地配置,合并并解析为生效配置。 */
204
266
  export async function loadLspConfig(
205
267
  cwd: string,
206
268
  globalConfigPath: string = join(homedir(), ".pi", "agent", "lsp.json"),
207
- ): Promise<LspConfig> {
269
+ ): Promise<ResolvedLspConfig> {
208
270
  const [globalConfig, localConfig] = await Promise.all([
209
271
  readConfigFile(globalConfigPath),
210
272
  readConfigFile(join(cwd, ".pi", "lsp.json")),
211
273
  ]);
212
- const servers = mergeServerRecords(globalConfig.servers, localConfig.servers);
213
- return {
214
- ...globalConfig,
215
- ...localConfig,
216
- ...(servers && { servers }),
217
- };
274
+ return resolveConfig(mergeConfig(globalConfig, localConfig));
218
275
  }
219
276
 
220
277
  /**
@@ -223,12 +280,12 @@ export async function loadLspConfig(
223
280
  */
224
281
  export function filterAdapters(
225
282
  adapters: LspServerAdapter[],
226
- config: LspConfig,
283
+ config: ResolvedLspConfig,
227
284
  ): LspServerAdapter[] {
228
285
  validateConfig(config, adapters);
229
286
  return adapters.filter((adapter) => {
230
- if (config.enabled && !config.enabled.includes(adapter.id)) return false;
231
- if (config.disabled?.includes(adapter.id)) return false;
287
+ if (config.enabled && !config.enabled.has(adapter.id)) return false;
288
+ if (config.disabled?.has(adapter.id)) return false;
232
289
  return true;
233
290
  });
234
291
  }
@@ -357,7 +414,9 @@ export function createLspService(
357
414
  ): LspService {
358
415
  // 扩展加载时校验全局配置(本地配置在 session_start 预加载时校验)
359
416
  validateConfig(
360
- readConfigFileSync(globalConfigPath ?? join(homedir(), ".pi", "agent", "lsp.json")),
417
+ resolveConfig(
418
+ readConfigFileSync(globalConfigPath ?? join(homedir(), ".pi", "agent", "lsp.json")),
419
+ ),
361
420
  adapters,
362
421
  );
363
422
  const state: LspState = {
@@ -443,7 +502,7 @@ export function createLspService(
443
502
  async function ensureWatcher(cwd: string, notify?: ExtensionUIContext["notify"]): Promise<void> {
444
503
  if (state.closing || state.disabled) return;
445
504
  const config = await loadLspConfig(cwd, globalConfigPath);
446
- const watch = watchOptions(config);
505
+ const watch = config.watch;
447
506
  if (!watch.enabled) return;
448
507
  if (watcher && watcherCwd === cwd) return;
449
508
  await stopWatcher();
@@ -502,7 +561,13 @@ export function createLspService(
502
561
  if (state.closing || state.disabled) return [];
503
562
  if (!containsPath(file, cwd)) return [];
504
563
  const config = await loadLspConfig(cwd, globalConfigPath);
505
- const timeout = timeoutOptions(config);
564
+ const timeout = {
565
+ diagnosticsDebounceMs: config.diagnosticsDebounceMs,
566
+ diagnosticsDocumentWaitTimeoutMs: config.diagnosticsDocumentWaitTimeoutMs,
567
+ diagnosticsFullWaitTimeoutMs: config.diagnosticsFullWaitTimeoutMs,
568
+ diagnosticsRequestTimeoutMs: config.diagnosticsRequestTimeoutMs,
569
+ initializeTimeoutMs: config.initializeTimeoutMs,
570
+ };
506
571
  const active = filterAdapters(adapters ?? createAdapters(config.servers), config);
507
572
  const extension = extname(file) || file;
508
573
  const result: LspClient[] = [];
@@ -556,7 +621,7 @@ export function createLspService(
556
621
  initializeTimeoutMs: adapter.startupTimeoutMs ?? timeout.initializeTimeoutMs,
557
622
  diagnosticsDocumentWaitTimeoutMs:
558
623
  adapter.diagnosticsWaitMs ?? timeout.diagnosticsDocumentWaitTimeoutMs,
559
- maxOpenDocuments: maxOpenDocuments(config),
624
+ maxOpenDocuments: config.maxOpenDocuments,
560
625
  });
561
626
  if (state.closing || state.disabled) {
562
627
  await client.shutdown();
@@ -883,11 +948,11 @@ function getNoopService(): LspService {
883
948
  }
884
949
 
885
950
  /** 会话配置里生效(未被 enabled 白名单排除、未被 disabled)的服务器数量。 */
886
- function enabledServerCount(config: LspConfig, adapters?: LspServerAdapter[]): number {
887
- const ids = adapters ? adapters.map((adapter) => adapter.id) : Object.keys(config.servers ?? {});
951
+ function enabledServerCount(config: ResolvedLspConfig, adapters?: LspServerAdapter[]): number {
952
+ const ids = adapters ? adapters.map((adapter) => adapter.id) : Object.keys(config.servers);
888
953
  return ids.filter((id) => {
889
- if (config.enabled && !config.enabled.includes(id)) return false;
890
- if (config.disabled?.includes(id)) return false;
954
+ if (config.enabled && !config.enabled.has(id)) return false;
955
+ if (config.disabled?.has(id)) return false;
891
956
  return true;
892
957
  }).length;
893
958
  }