@trim21/personal-pi-extensions 0.1.502 → 0.1.505

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/lib/lsp/lsp.ts +83 -26
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.502",
3
+ "version": "0.1.505",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -13,8 +13,8 @@
13
13
  * 是配置错误:全局配置在扩展加载(createLspService)时抛错,本地配置在
14
14
  * session 开始预加载时通知,工具调用时校验抛错兜底。disabled 中未注册
15
15
  * 的 id 直接忽略;
16
- * - client 按 (root, serverID) 缓存,并发 spawn 去重,启动失败记入 broken
17
- * 集合(服务实例生命周期内不再重试);
16
+ * - client 按 (root, serverID) 缓存,并发 spawn 去重,启动失败记入
17
+ * broken(冷却期内跳过,冷却过后下次触碰自动重试)并主动 notify;
18
18
  * - 工具只与 touchFile / notifyFile / diagnostics / lspDiagnosticsForFile 四个方法打交道;通知回调按请求传入。
19
19
  */
20
20
 
@@ -109,6 +109,11 @@ export const configDefaults = {
109
109
  maxOpenDocuments: clientDefaults.maxOpenDocuments,
110
110
  } as const;
111
111
 
112
+ /** 服务器启动失败后自动重试的冷却(ms);冷却内跳过,之后下次触碰自动重试。 */
113
+ const RETRY_COOLDOWN_MS = 60_000;
114
+ /** 同一服务器启动失败错误通知的最小间隔(ms),冷却重试反复失败时不刷屏。 */
115
+ const NOTIFY_INTERVAL_MS = 5 * 60_000;
116
+
112
117
  /** 生效的工作区监听配置(缺省值已应用)。 */
113
118
  export interface EffectiveWatchConfig {
114
119
  enabled: boolean;
@@ -292,7 +297,10 @@ export function filterAdapters(
292
297
 
293
298
  interface LspState {
294
299
  clients: LspClient[];
295
- broken: Set<string>;
300
+ /** root+serverID → 最近一次启动失败时间;冷却期过后允许自动重试。 */
301
+ brokenFailAt: Map<string, number>;
302
+ /** root+serverID → 最近一次启动失败错误通知时间;节流避免反复刷屏。 */
303
+ brokenNotifiedAt: Map<string, number>;
296
304
  spawning: Map<string, Promise<LspClient | undefined>>;
297
305
  closing: boolean;
298
306
  /** /lsp-stop 置 true:所有工具调用不再 spawn 服务器,直到 start/reload。 */
@@ -380,10 +388,10 @@ export interface LspService {
380
388
  shutdownAll(): Promise<void>;
381
389
  /** 停止全部服务器并禁用 LSP:之后工具调用不再 spawn,直到 start/reload。 */
382
390
  stop(): Promise<void>;
383
- /** 解除禁用并清空 broken 缓存;服务器在下次工具调用时惰性启动。 */
391
+ /** 解除禁用;服务器在下次工具调用时惰性启动(启动失败会按冷却自动重试)。 */
384
392
  start(): void;
385
393
  /**
386
- * 重启指定服务器:关闭其全部 client、清除对应 broken 记录并解除禁用;
394
+ * 重启指定服务器:关闭其全部 client、清除对应失败记录并解除禁用;
387
395
  * 其余服务器不受影响。配置在下次工具调用时重新读取。
388
396
  */
389
397
  reload(serverID: string): Promise<void>;
@@ -408,10 +416,26 @@ function containsPath(file: string, cwd: string): boolean {
408
416
  * 内置默认服务器合并构建。globalConfigPath 供测试注入固定的全局配置
409
417
  * 路径,避免被本机 ~/.pi/agent/lsp.json 影响。
410
418
  */
419
+ export interface LspServiceStartupOptions {
420
+ /**
421
+ * 服务器启动失败后到允许自动重试的冷却时长(ms)。冷却期内该服务器被
422
+ * 跳过,冷却过后下次触碰匹配文件时自动重试,无需 /lsp-reload。
423
+ */
424
+ retryCooldownMs?: number;
425
+ /** 同一服务器的启动失败错误通知最小间隔(ms),防止反复重试刷屏。 */
426
+ notifyIntervalMs?: number;
427
+ /** 会话级通知:启动失败时主动上报,不依赖触发请求恰好携带 notify。 */
428
+ notify?: ExtensionUIContext["notify"];
429
+ }
430
+
411
431
  export function createLspService(
412
432
  adapters?: LspServerAdapter[],
413
433
  globalConfigPath?: string,
434
+ startupOptions?: LspServiceStartupOptions,
414
435
  ): LspService {
436
+ const retryCooldownMs = startupOptions?.retryCooldownMs ?? RETRY_COOLDOWN_MS;
437
+ const notifyIntervalMs = startupOptions?.notifyIntervalMs ?? NOTIFY_INTERVAL_MS;
438
+ const sessionNotify = startupOptions?.notify;
415
439
  // 扩展加载时校验全局配置(本地配置在 session_start 预加载时校验)
416
440
  validateConfig(
417
441
  resolveConfig(
@@ -421,7 +445,8 @@ export function createLspService(
421
445
  );
422
446
  const state: LspState = {
423
447
  clients: [],
424
- broken: new Set(),
448
+ brokenFailAt: new Map(),
449
+ brokenNotifiedAt: new Map(),
425
450
  spawning: new Map(),
426
451
  closing: false,
427
452
  disabled: false,
@@ -558,6 +583,33 @@ export function createLspService(
558
583
  notify?: ExtensionUIContext["notify"],
559
584
  adapterFilter?: (adapter: LspServerAdapter) => boolean,
560
585
  ): Promise<LspClient[]> {
586
+ /**
587
+ * 记录一次启动失败:进入 broken(冷却期内跳过)、渲染 status,并按节流
588
+ * 间隔主动 notify。错误上报优先走会话级 sessionNotify——不依赖触发请求
589
+ * 恰好携带 notify(否则 Read warm-up 等静默通道会把失败吞掉);未注入
590
+ * 会话通知时退回请求级 notify 兜底。
591
+ */
592
+ function reportStartupFailure(
593
+ key: string,
594
+ serverID: string,
595
+ root: string,
596
+ cause: string,
597
+ ): void {
598
+ const now = Date.now();
599
+ state.brokenFailAt.set(key, now);
600
+ state.servers.set(key, { serverID, root, state: "broken" });
601
+ updateStatusText();
602
+ const reporter = sessionNotify ?? notify;
603
+ const lastNotified = state.brokenNotifiedAt.get(key);
604
+ if (reporter && (lastNotified === undefined || now - lastNotified >= notifyIntervalMs)) {
605
+ state.brokenNotifiedAt.set(key, now);
606
+ reporter(
607
+ `LSP server "${serverID}" failed to start for ${root}: ${cause}. ` +
608
+ `Fix the issue or run /lsp-reload ${serverID} to retry now.`,
609
+ "error",
610
+ );
611
+ }
612
+ }
561
613
  if (state.closing || state.disabled) return [];
562
614
  if (!containsPath(file, cwd)) return [];
563
615
  const config = await loadLspConfig(cwd, globalConfigPath);
@@ -584,7 +636,12 @@ export function createLspService(
584
636
  const root = await adapter.findRoot(file, cwd);
585
637
  if (!root) continue;
586
638
  const key = root + adapter.id;
587
- if (state.broken.has(key)) continue;
639
+ const failedAt = state.brokenFailAt.get(key);
640
+ if (failedAt !== undefined) {
641
+ if (Date.now() - failedAt < retryCooldownMs) continue;
642
+ // 冷却已过:允许重试;重试成功后下面会清除 broken 记录
643
+ state.brokenFailAt.delete(key);
644
+ }
588
645
 
589
646
  const existing = state.clients.find((c) => c.root === root && c.serverID === adapter.id);
590
647
  if (existing) {
@@ -603,13 +660,7 @@ export function createLspService(
603
660
  try {
604
661
  const handle = await adapter.spawn(root, cwd);
605
662
  if (!handle) {
606
- state.broken.add(key);
607
- state.servers.set(key, { serverID: adapter.id, root, state: "broken" });
608
- updateStatusText();
609
- notify?.(
610
- `LSP server "${adapter.id}" is not available for ${root} (binary not found)`,
611
- "error",
612
- );
663
+ reportStartupFailure(key, adapter.id, root, "binary not found");
613
664
  return;
614
665
  }
615
666
  const client = await create({
@@ -635,18 +686,18 @@ export function createLspService(
635
686
  state.clients.push(client);
636
687
  state.clientExtensions.set(client, adapter.extensions);
637
688
  state.servers.set(key, { serverID: adapter.id, root, state: "running" });
689
+ // 启动成功:清除失败记录,之后若再次失败会立即重新上报
690
+ state.brokenFailAt.delete(key);
691
+ state.brokenNotifiedAt.delete(key);
638
692
  updateStatusText();
639
693
  void ensureWatcher(cwd, notify);
640
694
  return client;
641
695
  } catch (error) {
642
- state.broken.add(key);
643
- state.servers.set(key, { serverID: adapter.id, root, state: "broken" });
644
- updateStatusText();
645
- notify?.(
646
- `LSP server "${adapter.id}" failed to start for ${root}: ${
647
- error instanceof Error ? error.message : String(error)
648
- }`,
649
- "error",
696
+ reportStartupFailure(
697
+ key,
698
+ adapter.id,
699
+ root,
700
+ error instanceof Error ? error.message : String(error),
650
701
  );
651
702
  return;
652
703
  }
@@ -845,7 +896,8 @@ export function createLspService(
845
896
  });
846
897
  state.clients = [];
847
898
  state.clientExtensions.clear();
848
- state.broken.clear();
899
+ state.brokenFailAt.clear();
900
+ state.brokenNotifiedAt.clear();
849
901
  state.servers.clear();
850
902
  await stopWatcher();
851
903
  updateStatusText();
@@ -866,7 +918,8 @@ export function createLspService(
866
918
  function start(): void {
867
919
  state.closing = false;
868
920
  state.disabled = false;
869
- state.broken.clear();
921
+ state.brokenFailAt.clear();
922
+ state.brokenNotifiedAt.clear();
870
923
  updateStatusText();
871
924
  }
872
925
 
@@ -880,7 +933,8 @@ export function createLspService(
880
933
  for (const client of targets) state.clientExtensions.delete(client);
881
934
  for (const [key, server] of state.servers) {
882
935
  if (server.serverID !== serverID) continue;
883
- state.broken.delete(key);
936
+ state.brokenFailAt.delete(key);
937
+ state.brokenNotifiedAt.delete(key);
884
938
  state.servers.delete(key);
885
939
  }
886
940
  if (state.clients.length === 0) await stopWatcher();
@@ -985,7 +1039,10 @@ export function createLspManager(
985
1039
  const config = await loadLspConfig(ctx.cwd, options?.globalConfigPath);
986
1040
  validateConfig(config, options?.adapters);
987
1041
  if (enabledServerCount(config, options?.adapters) === 0) return;
988
- const next = createLspService(options?.adapters, options?.globalConfigPath);
1042
+ const next = createLspService(options?.adapters, options?.globalConfigPath, {
1043
+ // 会话级通知:任何通道触发的启动失败都主动上报(不只依赖请求方 notify)
1044
+ notify: (message, level) => ctx.ui.notify(message, level),
1045
+ });
989
1046
  // footer status 显示当前所有 LSP server 状态(无 UI 时不显示)
990
1047
  next.attachStatus((text) =>
991
1048
  ctx.ui.setStatus("lsp", text ? ctx.ui.theme.fg("accent", text) : undefined),