@trim21/personal-pi-extensions 0.1.512 → 0.1.514

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.512",
3
+ "version": "0.1.514",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
package/src/aft/tools.ts CHANGED
@@ -444,18 +444,20 @@ const semanticIndexProgressSchema = Type.Object({
444
444
 
445
445
  type SemanticIndexProgress = Static<typeof semanticIndexProgressSchema>;
446
446
 
447
- /** status 快照 → 一行构建进度文本;非 Building 或形状不符返回 undefined(不显示)。 */
448
- export function formatSemanticIndexProgress(snapshot: StatusSnapshot): string | undefined {
447
+ /** 解析快照里的语义索引构建进度;非 Building 或形状不符返回 undefined(不显示)。 */
448
+ function parseSemanticIndexProgress(snapshot: StatusSnapshot): SemanticIndexProgress | undefined {
449
449
  const raw = snapshot.semantic_index;
450
450
  if (raw === undefined) {
451
451
  return undefined;
452
452
  }
453
- let progress: SemanticIndexProgress;
454
453
  try {
455
- progress = Value.Parse(semanticIndexProgressSchema, raw);
454
+ return Value.Parse(semanticIndexProgressSchema, raw);
456
455
  } catch {
457
456
  return undefined;
458
457
  }
458
+ }
459
+
460
+ function semanticIndexProgressParts(progress: SemanticIndexProgress): string[] {
459
461
  const parts = [`语义索引构建中${progress.stage === undefined ? "" : ` (${progress.stage})`}`];
460
462
  if (
461
463
  progress.embedded_chunks !== undefined &&
@@ -475,7 +477,97 @@ export function formatSemanticIndexProgress(snapshot: StatusSnapshot): string |
475
477
  ) {
476
478
  parts.push(`batch ${progress.current_batch}/${progress.total_batches}`);
477
479
  }
478
- return parts.join(" · ");
480
+ return parts;
481
+ }
482
+
483
+ /** status 快照 → 一行构建进度文本(不含 ETA)。 */
484
+ export function formatSemanticIndexProgress(snapshot: StatusSnapshot): string | undefined {
485
+ const progress = parseSemanticIndexProgress(snapshot);
486
+ if (progress === undefined) {
487
+ return undefined;
488
+ }
489
+ return semanticIndexProgressParts(progress).join(" · ");
490
+ }
491
+
492
+ /** eta 速率估计的滑动窗口:只看最近这段时间的样本,丢弃更早的。 */
493
+ const ETA_WINDOW_MS = 15_000;
494
+ /** 样本跨度过短时速率噪声太大(快照约每秒一条),不显示 ETA。 */
495
+ const ETA_MIN_WINDOW_MS = 3_000;
496
+ /** 滑动窗口里最多保留的样本数,防快照异常密集时无界增长。 */
497
+ const ETA_MAX_SAMPLES = 30;
498
+
499
+ interface EtaSample {
500
+ readonly at: number;
501
+ readonly embedded: number;
502
+ readonly stage: string | undefined;
503
+ }
504
+
505
+ function formatEta(ms: number): string {
506
+ // 取整到 5s 粒度,避免给出虚假的精确感。
507
+ const seconds = Math.max(5, Math.round(ms / 1000 / 5) * 5);
508
+ if (seconds < 60) {
509
+ return `${seconds}s`;
510
+ }
511
+ const minutes = Math.floor(seconds / 60);
512
+ const rest = seconds % 60;
513
+ return rest === 0 ? `${minutes}m` : `${minutes}m${rest}s`;
514
+ }
515
+
516
+ /**
517
+ * 带剩余时间估计的进度格式化器。基于快照序列做滑动窗口速率外推
518
+ * (embedding 批次速率受远端 API 波动影响并非线性,窗口越近越准)。
519
+ * total_chunks / total_batches 变化只影响分母,批次处理速率不变,
520
+ * 直接用新 total 重算 ETA,不清空样本;仅 stage 变化(换工作内容)或
521
+ * embedded 回退(watcher 重建)时重置。工厂 + 闭包持有状态:每次
522
+ * 工具调用 / CLI 运行创建独立实例。
523
+ */
524
+ export function createSemanticIndexProgressFormatter(options?: {
525
+ now?: () => number;
526
+ }): (snapshot: StatusSnapshot) => string | undefined {
527
+ const now = options?.now ?? Date.now;
528
+ let samples: EtaSample[] = [];
529
+
530
+ return (snapshot: StatusSnapshot): string | undefined => {
531
+ const progress = parseSemanticIndexProgress(snapshot);
532
+ if (progress === undefined) {
533
+ return undefined;
534
+ }
535
+ const parts = semanticIndexProgressParts(progress);
536
+
537
+ const { embedded_chunks: embedded, total_chunks: total, stage } = progress;
538
+ const last = samples.at(-1);
539
+ if (last !== undefined && last.stage !== stage) {
540
+ samples = [];
541
+ }
542
+ if (embedded !== undefined && total !== undefined && total > 0) {
543
+ if (last !== undefined && embedded < last.embedded) {
544
+ samples = [];
545
+ }
546
+ const at = now();
547
+ samples.push({ at, embedded, stage });
548
+ samples = samples.filter((sample) => at - sample.at <= ETA_WINDOW_MS);
549
+ if (samples.length > ETA_MAX_SAMPLES) {
550
+ samples = samples.slice(-ETA_MAX_SAMPLES);
551
+ }
552
+ }
553
+
554
+ const first = samples.at(0);
555
+ const current = samples.at(-1);
556
+ if (
557
+ first !== undefined &&
558
+ current !== undefined &&
559
+ total !== undefined &&
560
+ first !== current &&
561
+ current.at - first.at >= ETA_MIN_WINDOW_MS
562
+ ) {
563
+ const rate = (current.embedded - first.embedded) / (current.at - first.at);
564
+ const remaining = total - current.embedded;
565
+ if (rate > 0 && remaining > 0) {
566
+ parts.push(`剩余约 ${formatEta(remaining / rate)}`);
567
+ }
568
+ }
569
+ return parts.join(" · ");
570
+ };
479
571
  }
480
572
 
481
573
  const SearchParams = Type.Object(
@@ -520,11 +612,12 @@ export function registerSearchTool(pi: ExtensionAPI, ctx: AftToolContext): void
520
612
  });
521
613
 
522
614
  const bridge = bridgeFor(ctx);
615
+ const formatProgress = createSemanticIndexProgressFormatter();
523
616
  const stopProgress =
524
617
  onUpdate === undefined
525
618
  ? undefined
526
619
  : subscribeBridgeStatus(bridge, (snapshot) => {
527
- const text = formatSemanticIndexProgress(snapshot);
620
+ const text = formatProgress(snapshot);
528
621
  if (text !== undefined) {
529
622
  onUpdate({ content: [{ type: "text", text }], details: undefined });
530
623
  }
@@ -425,14 +425,17 @@ export interface LspService {
425
425
  start(): void;
426
426
  /**
427
427
  * 重启指定服务器:关闭其全部 client、清除对应失败记录并解除禁用;
428
- * 其余服务器不受影响。配置缓存失效,下次工具调用按当前 cwd 重新读盘。
428
+ * 其余服务器不受影响。配置缓存失效并立即重读盘上配置:此前运行中的该
429
+ * 服务器若仍存在于新配置则马上重启,未在运行的服务器保持惰性。
430
+ * 返回成功重启的 server id。
429
431
  */
430
- reload(serverID: string): Promise<void>;
432
+ reload(serverID: string): Promise<string[]>;
431
433
  /**
432
434
  * 重启全部服务器(/lsp-reload 无参):关闭所有 client、清空失败记录并
433
- * 解除禁用;配置缓存失效,下次工具调用重新读盘。
435
+ * 解除禁用;配置缓存失效并立即重读盘上配置,此前运行中的服务器若仍存在
436
+ * 于新配置则马上重启。返回成功重启的 server id。
434
437
  */
435
- reloadAll(): Promise<void>;
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 inflight = state.spawning.get(key);
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
- async function reload(serverID: string): Promise<void> {
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
- // 配置缓存失效:下次工具调用按当前 cwd 重新读盘,让配置修改生效
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<void> {
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("LSP reloaded: all servers will restart on the next tool call", "info");
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
- `LSP server "${serverID}" reloaded: will restart on the next tool call`,
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
  },