@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 +2 -1
- package/src/gh-readonly.ts +62 -47
- package/src/lib/github.ts +294 -0
- package/src/lib/lsp/client.ts +22 -6
- package/src/lib/lsp/lsp.ts +153 -88
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trim21/personal-pi-extensions",
|
|
3
|
-
"version": "0.1.
|
|
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",
|
package/src/gh-readonly.ts
CHANGED
|
@@ -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
|
|
342
|
+
* Build the `gh` argv for browsing issues/PRs (no keyword search).
|
|
340
343
|
*
|
|
341
|
-
* Keyword searches
|
|
342
|
-
*
|
|
343
|
-
* `gh
|
|
344
|
-
*
|
|
345
|
-
*
|
|
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,
|
|
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
|
-
|
|
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
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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
|
|
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;
|
|
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(
|
|
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
|
-
|
|
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
|
+
}
|
package/src/lib/lsp/client.ts
CHANGED
|
@@ -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 ??
|
|
420
|
-
const diagnosticsDocumentWaitTimeoutMs =
|
|
421
|
-
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
const
|
|
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),
|
package/src/lib/lsp/lsp.ts
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* 闭包变量,不做成模块级全局;
|
|
6
6
|
* - 配置来源:全局 `~/.pi/agent/lsp.json` + 本地 `<cwd>/.pi/lsp.json`
|
|
7
7
|
* (本地覆盖全局):`servers` 按 id 合并(同名 id 整体覆盖、新增 id,全局
|
|
8
|
-
* 其余服务器保留),`
|
|
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 {
|
|
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"
|
|
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
|
-
/**
|
|
59
|
+
/** 是否启用工作区文件监听。 */
|
|
53
60
|
enabled: Type.Optional(Type.Boolean()),
|
|
54
|
-
/** 事件去抖时长(ms
|
|
61
|
+
/** 事件去抖时长(ms,沿用 timeoutValue 字符串写法)。 */
|
|
55
62
|
debounceMs: Type.Optional(timeoutValue),
|
|
56
|
-
/**
|
|
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
|
-
/**
|
|
79
|
+
/** 工作区文件监听配置(缺省全部字段见 configDefaults.watch)。 */
|
|
73
80
|
watch: Type.Optional(watchConfigSchema),
|
|
74
|
-
/** 驻留文档上限(LRU
|
|
81
|
+
/** 驻留文档上限(LRU 容量),超过时淘汰最久未使用并 didClose。 */
|
|
75
82
|
maxOpenDocuments: Type.Optional(Type.Number({ minimum: 1 })),
|
|
76
|
-
/** push 诊断去抖(ms
|
|
83
|
+
/** push 诊断去抖(ms)。 */
|
|
77
84
|
diagnosticsDebounceMs: Type.Optional(timeoutValue),
|
|
78
|
-
/** document 模式诊断等待上限(ms
|
|
85
|
+
/** document 模式诊断等待上限(ms)。 */
|
|
79
86
|
diagnosticsDocumentWaitTimeoutMs: Type.Optional(timeoutValue),
|
|
80
|
-
/** full 模式诊断等待上限(ms
|
|
87
|
+
/** full 模式诊断等待上限(ms)。 */
|
|
81
88
|
diagnosticsFullWaitTimeoutMs: Type.Optional(timeoutValue),
|
|
82
|
-
/** 单次 pull 诊断请求超时(ms
|
|
89
|
+
/** 单次 pull 诊断请求超时(ms)。 */
|
|
83
90
|
diagnosticsRequestTimeoutMs: Type.Optional(timeoutValue),
|
|
84
|
-
/** 服务器 initialize 握手超时(ms
|
|
91
|
+
/** 服务器 initialize 握手超时(ms)。 */
|
|
85
92
|
initializeTimeoutMs: Type.Optional(timeoutValue),
|
|
86
93
|
});
|
|
87
94
|
|
|
88
|
-
/**
|
|
95
|
+
/** 配置值(单文件解析结果);超时字段为原始写法(number 或字符串),换算在 resolveConfig。 */
|
|
89
96
|
export type LspConfig = Static<typeof lspConfigSchema>;
|
|
90
97
|
|
|
91
|
-
/**
|
|
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
|
-
/**
|
|
115
|
-
function
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
/**
|
|
156
|
-
|
|
157
|
-
return
|
|
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:
|
|
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 =
|
|
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
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
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<
|
|
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
|
-
|
|
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:
|
|
283
|
+
config: ResolvedLspConfig,
|
|
227
284
|
): LspServerAdapter[] {
|
|
228
285
|
validateConfig(config, adapters);
|
|
229
286
|
return adapters.filter((adapter) => {
|
|
230
|
-
if (config.enabled && !config.enabled.
|
|
231
|
-
if (config.disabled?.
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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:
|
|
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.
|
|
890
|
-
if (config.disabled?.
|
|
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
|
}
|