@trim21/personal-pi-extensions 0.1.513 → 0.1.516
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 +1 -1
- package/src/gh-readonly.ts +197 -15
- package/src/lib/lsp/lsp.ts +160 -104
package/package.json
CHANGED
package/src/gh-readonly.ts
CHANGED
|
@@ -35,7 +35,7 @@ import { homedir } from "node:os";
|
|
|
35
35
|
import { delimiter, dirname, join, resolve } from "node:path";
|
|
36
36
|
|
|
37
37
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
38
|
-
import { Type } from "typebox";
|
|
38
|
+
import { type Static, Type } from "typebox";
|
|
39
39
|
import { Value } from "typebox/value";
|
|
40
40
|
|
|
41
41
|
import { createGithubSearch, type GithubSearch, renderHits } from "./lib/github.js";
|
|
@@ -266,6 +266,23 @@ const workflowRunSchema = Type.Object({
|
|
|
266
266
|
|
|
267
267
|
const workflowRunsSchema = Type.Object({ workflow_runs: Type.Array(workflowRunSchema) });
|
|
268
268
|
|
|
269
|
+
/**
|
|
270
|
+
* One entry of `gh pr checks --json`. In JSON mode gh exits 0 once it could
|
|
271
|
+
* fetch the checks (real errors still exit non-zero); pass/fail/pending is
|
|
272
|
+
* only conveyed by the `bucket` field, never by the exit code.
|
|
273
|
+
*/
|
|
274
|
+
const prCheckSchema = Type.Object({
|
|
275
|
+
name: Type.String(),
|
|
276
|
+
state: Type.String(),
|
|
277
|
+
bucket: Type.String(),
|
|
278
|
+
startedAt: Type.Union([Type.String(), Type.Null()]),
|
|
279
|
+
completedAt: Type.Union([Type.String(), Type.Null()]),
|
|
280
|
+
link: Type.Union([Type.String(), Type.Null()]),
|
|
281
|
+
workflow: Type.Union([Type.String(), Type.Null()]),
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
type PrCheck = Static<typeof prCheckSchema>;
|
|
285
|
+
|
|
269
286
|
function truncate(
|
|
270
287
|
text: string,
|
|
271
288
|
maxLines = 2000,
|
|
@@ -1111,6 +1128,174 @@ export async function writeLogFile(
|
|
|
1111
1128
|
};
|
|
1112
1129
|
}
|
|
1113
1130
|
|
|
1131
|
+
// ── pr checks watch (pure rendering + poll loop) ────────────────────────────
|
|
1132
|
+
|
|
1133
|
+
const PR_CHECKS_JSON_FIELDS = "name,state,bucket,startedAt,completedAt,link,workflow";
|
|
1134
|
+
const CHECKS_POLL_INTERVAL_MS = 30_000;
|
|
1135
|
+
const CHECKS_WATCH_DEADLINE_MS = 600_000;
|
|
1136
|
+
|
|
1137
|
+
function bucketIcon(bucket: string): string {
|
|
1138
|
+
switch (bucket) {
|
|
1139
|
+
case "pass": {
|
|
1140
|
+
return "✅";
|
|
1141
|
+
}
|
|
1142
|
+
case "fail": {
|
|
1143
|
+
return "❌";
|
|
1144
|
+
}
|
|
1145
|
+
case "skipping": {
|
|
1146
|
+
return "⏭️";
|
|
1147
|
+
}
|
|
1148
|
+
case "cancel": {
|
|
1149
|
+
return "🚫";
|
|
1150
|
+
}
|
|
1151
|
+
default: {
|
|
1152
|
+
return "🔄";
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function formatClock(ms: number): string {
|
|
1158
|
+
const date = new Date(ms);
|
|
1159
|
+
return [date.getHours(), date.getMinutes(), date.getSeconds()]
|
|
1160
|
+
.map((n) => String(n).padStart(2, "0"))
|
|
1161
|
+
.join(":");
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function pad2(n: number): string {
|
|
1165
|
+
return String(n).padStart(2, "0");
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function formatDuration(totalSeconds: number): string {
|
|
1169
|
+
const seconds = Math.floor(totalSeconds);
|
|
1170
|
+
const h = Math.floor(seconds / 3600);
|
|
1171
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
1172
|
+
const s = seconds % 60;
|
|
1173
|
+
if (h > 0) return `${h}h${pad2(m)}m${pad2(s)}s`;
|
|
1174
|
+
if (m > 0) return `${m}m${pad2(s)}s`;
|
|
1175
|
+
return `${s}s`;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* Render one polling round of `gh pr checks` like the GitHub web UI: check
|
|
1180
|
+
* name, workflow, status, start time and elapsed (total duration when the
|
|
1181
|
+
* check completed, time-so-far while still pending). Pure — no network.
|
|
1182
|
+
*/
|
|
1183
|
+
export function renderPrChecksTable(options: {
|
|
1184
|
+
prNumber: number | string;
|
|
1185
|
+
round: number;
|
|
1186
|
+
checks: readonly PrCheck[];
|
|
1187
|
+
now: number;
|
|
1188
|
+
}): string {
|
|
1189
|
+
const { prNumber, round, checks, now } = options;
|
|
1190
|
+
const completed = checks.filter((c) => c.bucket !== "pending").length;
|
|
1191
|
+
|
|
1192
|
+
const rows = checks.map((check) => {
|
|
1193
|
+
const name = check.link ? `[${check.name}](${check.link})` : check.name;
|
|
1194
|
+
let elapsed = "—";
|
|
1195
|
+
if (check.startedAt) {
|
|
1196
|
+
const startMs = Date.parse(check.startedAt);
|
|
1197
|
+
const endMs = check.completedAt ? Date.parse(check.completedAt) : now;
|
|
1198
|
+
elapsed = formatDuration(Math.max(0, (endMs - startMs) / 1000));
|
|
1199
|
+
}
|
|
1200
|
+
const cells = [
|
|
1201
|
+
`${bucketIcon(check.bucket)} ${name}`,
|
|
1202
|
+
check.workflow ?? "—",
|
|
1203
|
+
check.bucket,
|
|
1204
|
+
check.startedAt ? formatClock(Date.parse(check.startedAt)) : "—",
|
|
1205
|
+
elapsed,
|
|
1206
|
+
];
|
|
1207
|
+
return `| ${cells.join(" | ")} |`;
|
|
1208
|
+
});
|
|
1209
|
+
const body = rows.length > 0 ? rows.join("\n") : "| _no checks reported_ | — | — | — | — |";
|
|
1210
|
+
|
|
1211
|
+
return (
|
|
1212
|
+
`### PR #${prNumber} checks — round ${round}: ${completed}/${checks.length} complete\n\n` +
|
|
1213
|
+
`| Check | Workflow | Status | Started | Elapsed |\n` +
|
|
1214
|
+
`|---|---|---|---|---|\n` +
|
|
1215
|
+
body
|
|
1216
|
+
);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
function sleepInterruptibly(ms: number, signal: AbortSignal | undefined): Promise<void> {
|
|
1220
|
+
return new Promise((resolve, reject) => {
|
|
1221
|
+
const onAbort = () => {
|
|
1222
|
+
clearTimeout(timer);
|
|
1223
|
+
reject(new Error("aborted while waiting for the next checks poll"));
|
|
1224
|
+
};
|
|
1225
|
+
const timer = setTimeout(() => {
|
|
1226
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1227
|
+
resolve();
|
|
1228
|
+
}, ms);
|
|
1229
|
+
if (signal?.aborted) {
|
|
1230
|
+
onAbort();
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
export interface PollPrChecksOptions {
|
|
1238
|
+
prNumber: number | string;
|
|
1239
|
+
repo?: string;
|
|
1240
|
+
failFast: boolean;
|
|
1241
|
+
cwd?: string;
|
|
1242
|
+
signal?: AbortSignal;
|
|
1243
|
+
/** Test overrides. */
|
|
1244
|
+
intervalMs?: number;
|
|
1245
|
+
deadlineMs?: number;
|
|
1246
|
+
onUpdate?: (msg: CiLogsResult) => void;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
/**
|
|
1250
|
+
* Poll `gh pr checks --json` until no check is pending (or a fail-fast
|
|
1251
|
+
* failure, or the deadline), emitting a GitHub-UI-like table via `onUpdate`
|
|
1252
|
+
* each round. In JSON mode gh exits 0 whenever it could fetch the checks —
|
|
1253
|
+
* completion is judged from the `bucket` field, not the exit code. A non-zero
|
|
1254
|
+
* exit (no checks reported, auth, network) is not fatal here: the caller
|
|
1255
|
+
* proceeds to the Actions API verification, which either produces the final
|
|
1256
|
+
* report or surfaces the error.
|
|
1257
|
+
*/
|
|
1258
|
+
export async function pollPrChecks(options: PollPrChecksOptions): Promise<void> {
|
|
1259
|
+
const { prNumber, repo, failFast, cwd, signal, onUpdate } = options;
|
|
1260
|
+
const intervalMs = options.intervalMs ?? CHECKS_POLL_INTERVAL_MS;
|
|
1261
|
+
const deadlineMs = options.deadlineMs ?? CHECKS_WATCH_DEADLINE_MS;
|
|
1262
|
+
const args = [
|
|
1263
|
+
"pr",
|
|
1264
|
+
"checks",
|
|
1265
|
+
String(prNumber),
|
|
1266
|
+
...repoArgs(repo),
|
|
1267
|
+
"--json",
|
|
1268
|
+
PR_CHECKS_JSON_FIELDS,
|
|
1269
|
+
];
|
|
1270
|
+
|
|
1271
|
+
const watchStart = Date.now();
|
|
1272
|
+
for (let round = 1; ; round++) {
|
|
1273
|
+
const result = await runGh(args, { cwd, signal });
|
|
1274
|
+
if (result.killed) {
|
|
1275
|
+
throw new Error(
|
|
1276
|
+
result.reason === "timeout" ? "gh pr checks poll timed out" : "gh pr checks was aborted",
|
|
1277
|
+
);
|
|
1278
|
+
}
|
|
1279
|
+
if (result.code !== 0) {
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
const checks: PrCheck[] = Value.Parse(Type.Array(prCheckSchema), JSON.parse(result.stdout));
|
|
1283
|
+
|
|
1284
|
+
onUpdate?.({
|
|
1285
|
+
content: [
|
|
1286
|
+
{ type: "text", text: renderPrChecksTable({ prNumber, round, checks, now: Date.now() }) },
|
|
1287
|
+
],
|
|
1288
|
+
details: {},
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
const hasPending = checks.some((c) => c.bucket === "pending");
|
|
1292
|
+
if (!hasPending) return;
|
|
1293
|
+
if (failFast && checks.some((c) => c.bucket === "fail")) return;
|
|
1294
|
+
if (Date.now() - watchStart >= deadlineMs) return;
|
|
1295
|
+
await sleepInterruptibly(intervalMs, signal);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1114
1299
|
// ── tools ────────────────────────────────────────────────────────────────────
|
|
1115
1300
|
|
|
1116
1301
|
export default function ghReadonlyTools(pi: ExtensionAPI) {
|
|
@@ -1667,6 +1852,7 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
|
|
|
1667
1852
|
label: "Watch GitHub PR Checks",
|
|
1668
1853
|
description:
|
|
1669
1854
|
"Watch CI status checks for a PR until they complete. Blocks until all checks finish or one fails. " +
|
|
1855
|
+
"Each polling round streams a GitHub-UI-like check table (name, workflow, status, started, elapsed) via onUpdate. " +
|
|
1670
1856
|
"Use this when you need to wait for CI to complete and see the final result.",
|
|
1671
1857
|
promptSnippet: "Watch and wait for GitHub PR CI checks to complete",
|
|
1672
1858
|
parameters: Type.Object({
|
|
@@ -1685,21 +1871,17 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
|
|
|
1685
1871
|
details: {},
|
|
1686
1872
|
});
|
|
1687
1873
|
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
// gh 的退出码语义不可靠:checks 失败时返回 exit 1(SilentError),挂起时
|
|
1692
|
-
// 返回 exit 8(PendingError),且失败详情只在非结构化的 stdout 表格里。
|
|
1693
|
-
// 因此 watch 退出后直接用 Actions API 抓取该 PR head 提交关联的所有
|
|
1874
|
+
// 轮询 `gh pr checks --json`(每轮经 onUpdate 流式输出 GitHub Web UI 风格
|
|
1875
|
+
// 的检查表),结束后再用 Actions API 抓取该 PR head 提交关联的所有
|
|
1694
1876
|
// workflow job,以 job 的真实 conclusion 为准判断成功/失败。
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
}
|
|
1877
|
+
await pollPrChecks({
|
|
1878
|
+
prNumber: number,
|
|
1879
|
+
repo,
|
|
1880
|
+
failFast: fail_fast === true,
|
|
1881
|
+
cwd: ctx.cwd,
|
|
1882
|
+
signal,
|
|
1883
|
+
onUpdate,
|
|
1884
|
+
});
|
|
1703
1885
|
|
|
1704
1886
|
const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
|
|
1705
1887
|
const prOut = await ghExec(
|
package/src/lib/lsp/lsp.ts
CHANGED
|
@@ -425,14 +425,17 @@ export interface LspService {
|
|
|
425
425
|
start(): void;
|
|
426
426
|
/**
|
|
427
427
|
* 重启指定服务器:关闭其全部 client、清除对应失败记录并解除禁用;
|
|
428
|
-
*
|
|
428
|
+
* 其余服务器不受影响。配置缓存失效并立即重读盘上配置:此前运行中的该
|
|
429
|
+
* 服务器若仍存在于新配置则马上重启,未在运行的服务器保持惰性。
|
|
430
|
+
* 返回成功重启的 server id。
|
|
429
431
|
*/
|
|
430
|
-
reload(serverID: string): Promise<
|
|
432
|
+
reload(serverID: string): Promise<string[]>;
|
|
431
433
|
/**
|
|
432
434
|
* 重启全部服务器(/lsp-reload 无参):关闭所有 client、清空失败记录并
|
|
433
|
-
*
|
|
435
|
+
* 解除禁用;配置缓存失效并立即重读盘上配置,此前运行中的服务器若仍存在
|
|
436
|
+
* 于新配置则马上重启。返回成功重启的 server id。
|
|
434
437
|
*/
|
|
435
|
-
reloadAll(): Promise<
|
|
438
|
+
reloadAll(): Promise<string[]>;
|
|
436
439
|
/** 已知服务器 id(running 或 broken 的去重集合),供命令补全与提示。 */
|
|
437
440
|
serverIDs(): string[];
|
|
438
441
|
/** 注入 status 渲染回调;传入 undefined 表示不再渲染。 */
|
|
@@ -632,49 +635,115 @@ export function createLspService(
|
|
|
632
635
|
updateStatusText();
|
|
633
636
|
}
|
|
634
637
|
|
|
638
|
+
/**
|
|
639
|
+
* 记录一次启动失败:进入 broken(冷却期内跳过)、渲染 status,并按节流
|
|
640
|
+
* 间隔主动 notify。错误上报优先走会话级 sessionNotify——不依赖触发请求
|
|
641
|
+
* 恰好携带 notify(否则 Read warm-up 等静默通道会把失败吞掉);未注入
|
|
642
|
+
* 会话通知时退回请求级 notify 兜底。
|
|
643
|
+
*/
|
|
644
|
+
function reportStartupFailure(
|
|
645
|
+
key: string,
|
|
646
|
+
serverID: string,
|
|
647
|
+
root: string,
|
|
648
|
+
cause: string,
|
|
649
|
+
notify?: ExtensionUIContext["notify"],
|
|
650
|
+
): void {
|
|
651
|
+
const now = Date.now();
|
|
652
|
+
state.brokenFailAt.set(key, now);
|
|
653
|
+
state.servers.set(key, { serverID, root, state: "broken" });
|
|
654
|
+
updateStatusText();
|
|
655
|
+
const reporter = sessionNotify ?? notify;
|
|
656
|
+
const lastNotified = state.brokenNotifiedAt.get(key);
|
|
657
|
+
if (reporter && (lastNotified === undefined || now - lastNotified >= notifyIntervalMs)) {
|
|
658
|
+
state.brokenNotifiedAt.set(key, now);
|
|
659
|
+
reporter(
|
|
660
|
+
`LSP server "${serverID}" failed to start for ${root}: ${cause}. ` +
|
|
661
|
+
`Fix the issue or run /lsp-reload ${serverID} to retry now.`,
|
|
662
|
+
"error",
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* spawn 指定 adapter 并注册 client:同一 key 的 in-flight spawn 复用其结果,
|
|
669
|
+
* 失败进 broken(节流上报,返回 undefined),成功后注册 client / extensions /
|
|
670
|
+
* status 并确保 watcher 运行。供 getClients 与 reload 后的立即重启共用。
|
|
671
|
+
*/
|
|
672
|
+
async function startClient(
|
|
673
|
+
adapter: LspServerAdapter,
|
|
674
|
+
root: string,
|
|
675
|
+
cwd: string,
|
|
676
|
+
config: ResolvedLspConfig,
|
|
677
|
+
notify?: ExtensionUIContext["notify"],
|
|
678
|
+
): Promise<LspClient | undefined> {
|
|
679
|
+
const key = root + adapter.id;
|
|
680
|
+
const inflight = state.spawning.get(key);
|
|
681
|
+
if (inflight) return inflight;
|
|
682
|
+
const task = (async () => {
|
|
683
|
+
try {
|
|
684
|
+
const handle = await adapter.spawn(root, cwd);
|
|
685
|
+
if (!handle) {
|
|
686
|
+
reportStartupFailure(key, adapter.id, root, "binary not found", notify);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
const client = await create({
|
|
690
|
+
serverID: adapter.id,
|
|
691
|
+
server: handle,
|
|
692
|
+
root,
|
|
693
|
+
directory: cwd,
|
|
694
|
+
diagnosticsDebounceMs: config.diagnosticsDebounceMs,
|
|
695
|
+
diagnosticsDocumentWaitTimeoutMs:
|
|
696
|
+
adapter.diagnosticsWaitMs ?? config.diagnosticsDocumentWaitTimeoutMs,
|
|
697
|
+
diagnosticsFullWaitTimeoutMs: config.diagnosticsFullWaitTimeoutMs,
|
|
698
|
+
diagnosticsRequestTimeoutMs: config.diagnosticsRequestTimeoutMs,
|
|
699
|
+
initializeTimeoutMs: adapter.startupTimeoutMs ?? config.initializeTimeoutMs,
|
|
700
|
+
maxOpenDocuments: config.maxOpenDocuments,
|
|
701
|
+
});
|
|
702
|
+
if (state.closing || state.disabled) {
|
|
703
|
+
await client.shutdown();
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
const duplicate = state.clients.find((c) => c.root === root && c.serverID === adapter.id);
|
|
707
|
+
if (duplicate) {
|
|
708
|
+
await client.shutdown();
|
|
709
|
+
return duplicate;
|
|
710
|
+
}
|
|
711
|
+
state.clients.push(client);
|
|
712
|
+
state.clientExtensions.set(client, adapter.extensions);
|
|
713
|
+
state.servers.set(key, { serverID: adapter.id, root, state: "running" });
|
|
714
|
+
// 启动成功:清除失败记录,之后若再次失败会立即重新上报
|
|
715
|
+
state.brokenFailAt.delete(key);
|
|
716
|
+
state.brokenNotifiedAt.delete(key);
|
|
717
|
+
updateStatusText();
|
|
718
|
+
void ensureWatcher(cwd, notify);
|
|
719
|
+
return client;
|
|
720
|
+
} catch (error) {
|
|
721
|
+
reportStartupFailure(
|
|
722
|
+
key,
|
|
723
|
+
adapter.id,
|
|
724
|
+
root,
|
|
725
|
+
error instanceof Error ? error.message : String(error),
|
|
726
|
+
notify,
|
|
727
|
+
);
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
})();
|
|
731
|
+
state.spawning.set(key, task);
|
|
732
|
+
void task.finally(() => {
|
|
733
|
+
if (state.spawning.get(key) === task) state.spawning.delete(key);
|
|
734
|
+
});
|
|
735
|
+
return task;
|
|
736
|
+
}
|
|
737
|
+
|
|
635
738
|
async function getClients(
|
|
636
739
|
file: string,
|
|
637
740
|
cwd: string,
|
|
638
741
|
notify?: ExtensionUIContext["notify"],
|
|
639
742
|
adapterFilter?: (adapter: LspServerAdapter) => boolean,
|
|
640
743
|
): Promise<LspClient[]> {
|
|
641
|
-
/**
|
|
642
|
-
* 记录一次启动失败:进入 broken(冷却期内跳过)、渲染 status,并按节流
|
|
643
|
-
* 间隔主动 notify。错误上报优先走会话级 sessionNotify——不依赖触发请求
|
|
644
|
-
* 恰好携带 notify(否则 Read warm-up 等静默通道会把失败吞掉);未注入
|
|
645
|
-
* 会话通知时退回请求级 notify 兜底。
|
|
646
|
-
*/
|
|
647
|
-
function reportStartupFailure(
|
|
648
|
-
key: string,
|
|
649
|
-
serverID: string,
|
|
650
|
-
root: string,
|
|
651
|
-
cause: string,
|
|
652
|
-
): void {
|
|
653
|
-
const now = Date.now();
|
|
654
|
-
state.brokenFailAt.set(key, now);
|
|
655
|
-
state.servers.set(key, { serverID, root, state: "broken" });
|
|
656
|
-
updateStatusText();
|
|
657
|
-
const reporter = sessionNotify ?? notify;
|
|
658
|
-
const lastNotified = state.brokenNotifiedAt.get(key);
|
|
659
|
-
if (reporter && (lastNotified === undefined || now - lastNotified >= notifyIntervalMs)) {
|
|
660
|
-
state.brokenNotifiedAt.set(key, now);
|
|
661
|
-
reporter(
|
|
662
|
-
`LSP server "${serverID}" failed to start for ${root}: ${cause}. ` +
|
|
663
|
-
`Fix the issue or run /lsp-reload ${serverID} to retry now.`,
|
|
664
|
-
"error",
|
|
665
|
-
);
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
744
|
if (state.closing || state.disabled) return [];
|
|
669
745
|
if (!containsPath(file, cwd)) return [];
|
|
670
746
|
const config = await currentConfig(cwd);
|
|
671
|
-
const timeout = {
|
|
672
|
-
diagnosticsDebounceMs: config.diagnosticsDebounceMs,
|
|
673
|
-
diagnosticsDocumentWaitTimeoutMs: config.diagnosticsDocumentWaitTimeoutMs,
|
|
674
|
-
diagnosticsFullWaitTimeoutMs: config.diagnosticsFullWaitTimeoutMs,
|
|
675
|
-
diagnosticsRequestTimeoutMs: config.diagnosticsRequestTimeoutMs,
|
|
676
|
-
initializeTimeoutMs: config.initializeTimeoutMs,
|
|
677
|
-
};
|
|
678
747
|
const active = filterAdapters(adapters ?? createAdapters(config.servers), config);
|
|
679
748
|
const extension = extname(file) || file;
|
|
680
749
|
const result: LspClient[] = [];
|
|
@@ -705,65 +774,7 @@ export function createLspService(
|
|
|
705
774
|
continue;
|
|
706
775
|
}
|
|
707
776
|
|
|
708
|
-
const
|
|
709
|
-
if (inflight) {
|
|
710
|
-
const client = await inflight;
|
|
711
|
-
if (client) result.push(client);
|
|
712
|
-
continue;
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
const task = (async () => {
|
|
716
|
-
try {
|
|
717
|
-
const handle = await adapter.spawn(root, cwd);
|
|
718
|
-
if (!handle) {
|
|
719
|
-
reportStartupFailure(key, adapter.id, root, "binary not found");
|
|
720
|
-
return;
|
|
721
|
-
}
|
|
722
|
-
const client = await create({
|
|
723
|
-
serverID: adapter.id,
|
|
724
|
-
server: handle,
|
|
725
|
-
root,
|
|
726
|
-
directory: cwd,
|
|
727
|
-
...timeout,
|
|
728
|
-
initializeTimeoutMs: adapter.startupTimeoutMs ?? timeout.initializeTimeoutMs,
|
|
729
|
-
diagnosticsDocumentWaitTimeoutMs:
|
|
730
|
-
adapter.diagnosticsWaitMs ?? timeout.diagnosticsDocumentWaitTimeoutMs,
|
|
731
|
-
maxOpenDocuments: config.maxOpenDocuments,
|
|
732
|
-
});
|
|
733
|
-
if (state.closing || state.disabled) {
|
|
734
|
-
await client.shutdown();
|
|
735
|
-
return;
|
|
736
|
-
}
|
|
737
|
-
const duplicate = state.clients.find((c) => c.root === root && c.serverID === adapter.id);
|
|
738
|
-
if (duplicate) {
|
|
739
|
-
await client.shutdown();
|
|
740
|
-
return duplicate;
|
|
741
|
-
}
|
|
742
|
-
state.clients.push(client);
|
|
743
|
-
state.clientExtensions.set(client, adapter.extensions);
|
|
744
|
-
state.servers.set(key, { serverID: adapter.id, root, state: "running" });
|
|
745
|
-
// 启动成功:清除失败记录,之后若再次失败会立即重新上报
|
|
746
|
-
state.brokenFailAt.delete(key);
|
|
747
|
-
state.brokenNotifiedAt.delete(key);
|
|
748
|
-
updateStatusText();
|
|
749
|
-
void ensureWatcher(cwd, notify);
|
|
750
|
-
return client;
|
|
751
|
-
} catch (error) {
|
|
752
|
-
reportStartupFailure(
|
|
753
|
-
key,
|
|
754
|
-
adapter.id,
|
|
755
|
-
root,
|
|
756
|
-
error instanceof Error ? error.message : String(error),
|
|
757
|
-
);
|
|
758
|
-
return;
|
|
759
|
-
}
|
|
760
|
-
})();
|
|
761
|
-
state.spawning.set(key, task);
|
|
762
|
-
void task.finally(() => {
|
|
763
|
-
if (state.spawning.get(key) === task) state.spawning.delete(key);
|
|
764
|
-
});
|
|
765
|
-
|
|
766
|
-
const client = await task;
|
|
777
|
+
const client = await startClient(adapter, root, cwd, config, notify);
|
|
767
778
|
if (client) result.push(client);
|
|
768
779
|
}
|
|
769
780
|
|
|
@@ -979,9 +990,43 @@ export function createLspService(
|
|
|
979
990
|
updateStatusText();
|
|
980
991
|
}
|
|
981
992
|
|
|
982
|
-
|
|
993
|
+
/**
|
|
994
|
+
* reload 后立即重启此前运行中的服务器,不再等下一次工具调用。只重启新配置
|
|
995
|
+
* 中仍存在且启用的 server;无运行记录(如 /lsp-stop 之后)或配置重读失败时
|
|
996
|
+
* 不动,保持惰性 spawn。返回成功重启的 server id。
|
|
997
|
+
*/
|
|
998
|
+
async function respawnRunning(serverIDs: readonly string[]): Promise<string[]> {
|
|
999
|
+
const cwd = state.cwd;
|
|
1000
|
+
if (!cwd) return [];
|
|
1001
|
+
let config: ResolvedLspConfig;
|
|
1002
|
+
try {
|
|
1003
|
+
config = await currentConfig(cwd);
|
|
1004
|
+
} catch (error) {
|
|
1005
|
+
sessionNotify?.(
|
|
1006
|
+
`LSP reload: re-reading config failed, servers will start on the next tool call: ${
|
|
1007
|
+
error instanceof Error ? error.message : String(error)
|
|
1008
|
+
}`,
|
|
1009
|
+
"warning",
|
|
1010
|
+
);
|
|
1011
|
+
return [];
|
|
1012
|
+
}
|
|
1013
|
+
const active = filterAdapters(adapters ?? createAdapters(config.servers), config);
|
|
1014
|
+
const restarted = await Promise.all(
|
|
1015
|
+
serverIDs.map(async (serverID): Promise<string | undefined> => {
|
|
1016
|
+
const adapter = active.find((candidate) => candidate.id === serverID);
|
|
1017
|
+
if (!adapter) return;
|
|
1018
|
+
const root = serverRoot(adapter.workingDir, cwd);
|
|
1019
|
+
const client = await startClient(adapter, root, cwd, config);
|
|
1020
|
+
return client ? serverID : undefined;
|
|
1021
|
+
}),
|
|
1022
|
+
);
|
|
1023
|
+
return restarted.filter((id): id is string => id !== undefined);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
async function reload(serverID: string): Promise<string[]> {
|
|
1027
|
+
const wasRunning = state.clients.some((client) => client.serverID === serverID);
|
|
983
1028
|
state.closing = true;
|
|
984
|
-
//
|
|
1029
|
+
// 配置缓存失效:立即重读盘上配置,让配置修改生效
|
|
985
1030
|
state.config = undefined;
|
|
986
1031
|
state.configCwd = undefined;
|
|
987
1032
|
const targets = state.clients.filter((client) => client.serverID === serverID);
|
|
@@ -1000,9 +1045,12 @@ export function createLspService(
|
|
|
1000
1045
|
state.closing = false;
|
|
1001
1046
|
state.disabled = false;
|
|
1002
1047
|
updateStatusText();
|
|
1048
|
+
if (!wasRunning) return [];
|
|
1049
|
+
return respawnRunning([serverID]);
|
|
1003
1050
|
}
|
|
1004
1051
|
|
|
1005
|
-
async function reloadAll(): Promise<
|
|
1052
|
+
async function reloadAll(): Promise<string[]> {
|
|
1053
|
+
const runningIDs = [...new Set(state.clients.map((client) => client.serverID))];
|
|
1006
1054
|
state.closing = true;
|
|
1007
1055
|
state.config = undefined;
|
|
1008
1056
|
state.configCwd = undefined;
|
|
@@ -1018,6 +1066,7 @@ export function createLspService(
|
|
|
1018
1066
|
state.closing = false;
|
|
1019
1067
|
state.disabled = false;
|
|
1020
1068
|
updateStatusText();
|
|
1069
|
+
return respawnRunning(runningIDs);
|
|
1021
1070
|
}
|
|
1022
1071
|
|
|
1023
1072
|
function serverIDs(): string[] {
|
|
@@ -1211,13 +1260,20 @@ export function createLspManager(
|
|
|
1211
1260
|
const serverID = args.trim();
|
|
1212
1261
|
if (!serverID) {
|
|
1213
1262
|
// 无参:重读配置并重启全部服务器
|
|
1214
|
-
await service.reloadAll();
|
|
1215
|
-
ctx.ui.notify(
|
|
1263
|
+
const restarted = await service.reloadAll();
|
|
1264
|
+
ctx.ui.notify(
|
|
1265
|
+
restarted.length > 0
|
|
1266
|
+
? `LSP reloaded: ${restarted.toSorted().join(", ")} restarted`
|
|
1267
|
+
: "LSP reloaded: servers will restart on the next tool call",
|
|
1268
|
+
"info",
|
|
1269
|
+
);
|
|
1216
1270
|
return;
|
|
1217
1271
|
}
|
|
1218
|
-
await service.reload(serverID);
|
|
1272
|
+
const restarted = await service.reload(serverID);
|
|
1219
1273
|
ctx.ui.notify(
|
|
1220
|
-
|
|
1274
|
+
restarted.length > 0
|
|
1275
|
+
? `LSP server "${serverID}" reloaded`
|
|
1276
|
+
: `LSP server "${serverID}" reloaded: will restart on the next tool call`,
|
|
1221
1277
|
"info",
|
|
1222
1278
|
);
|
|
1223
1279
|
},
|