@trim21/personal-pi-extensions 0.1.489 → 0.1.491

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.
@@ -18,14 +18,14 @@
18
18
 
19
19
  import { readFileSync } from "node:fs";
20
20
  import { readFile } from "node:fs/promises";
21
- import { homedir } from "node:os";
21
+ import { homedir, tmpdir } from "node:os";
22
22
  import { extname, join, normalize, relative, sep } from "node:path";
23
23
 
24
24
  import type { ExtensionAPI, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
25
25
  import { minimatch } from "minimatch";
26
26
  import { type Static, Type } from "typebox";
27
27
  import { Value } from "typebox/value";
28
- import type { WorkspaceEdit } from "vscode-languageserver-types";
28
+ import type { Hover, WorkspaceEdit } from "vscode-languageserver-types";
29
29
 
30
30
  import { type LspServerAdapter } from "./adapter.js";
31
31
  import {
@@ -33,6 +33,8 @@ import {
33
33
  type CreateInput,
34
34
  type Diagnostic,
35
35
  type Info as LspClient,
36
+ type InspectLocation,
37
+ LspMethodNotSupportedError,
36
38
  RenameNotPossibleError,
37
39
  WATCH_KIND_CHANGE,
38
40
  WATCH_KIND_CREATE,
@@ -88,12 +90,19 @@ export type LspConfig = Static<typeof lspConfigSchema>;
88
90
 
89
91
  /** "500" → 500、"5s" → 5000、"1m" → 60000;无效字符串返回 NaN(由 toMs 过滤)。 */
90
92
  function parseTimeoutString(value: string): number {
91
- const match = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h)?$/.exec(value.trim());
93
+ // 单位组永远参与匹配(缺省为空串),避免"可选捕获组在类型上不可空"的歧义
94
+ const match = /^(\d+(?:\.\d+)?)(ms|s|m|h|)\s*$/.exec(value.trim());
92
95
  if (!match) return NaN;
93
96
  const amount = Number(match[1]);
94
- const unit = match[2] ?? "ms";
95
- const factors: Record<string, number> = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000 };
96
- return amount * (factors[unit] ?? 1);
97
+ const factors: Record<string, number | undefined> = {
98
+ "": 1,
99
+ ms: 1,
100
+ s: 1_000,
101
+ m: 60_000,
102
+ h: 3_600_000,
103
+ };
104
+ const factor = factors[match[2]];
105
+ return amount * (factor ?? 1);
97
106
  }
98
107
 
99
108
  function toMs(value: number | string | undefined): number | undefined {
@@ -242,6 +251,16 @@ interface LspState {
242
251
  /** 渲染 LSP status 文本的回调(传入 undefined 表示清除)。 */
243
252
  export type StatusRenderer = (text: string | undefined) => void;
244
253
 
254
+ export type LspInspectQuery = "definition" | "references" | "hover";
255
+
256
+ /**
257
+ * 返回类型与 query 泛型关联:query 为 "hover" 时返回 hover 内容,否则返回
258
+ * 位置列表。实现内部用 cast 建立关联(TS 无法验证分支与泛型的对应关系)。
259
+ */
260
+ export type LspInspectResult<Q extends LspInspectQuery = LspInspectQuery> = Q extends "hover"
261
+ ? { serverID: string; query: "hover"; hover: Hover | null }
262
+ : { serverID: string; query: "definition" | "references"; locations: InspectLocation[] };
263
+
245
264
  export interface LspRequestOptions {
246
265
  notify?: ExtensionUIContext["notify"];
247
266
  /** 中止时提前结束诊断等待(已中止时直接跳过诊断)。 */
@@ -288,6 +307,19 @@ export interface LspService {
288
307
  newName: string;
289
308
  options?: LspRequestOptions;
290
309
  }): Promise<{ serverID: string; edit: WorkspaceEdit; placeholder?: string }>;
310
+ /**
311
+ * 只读符号查询(definition / references / hover):只面向 kind 为 "language"
312
+ * 的服务器,按配置顺序取第一个成功结果;服务器不支持该方法(MethodNotFound)
313
+ * 时跳过并尝试下一个,全部不支持时抛聚合错误。line / character 为 0-based。
314
+ */
315
+ inspect<Q extends LspInspectQuery>(request: {
316
+ file: string;
317
+ cwd: string;
318
+ line: number;
319
+ character: number;
320
+ query: Q;
321
+ options?: LspRequestOptions;
322
+ }): Promise<LspInspectResult<Q>>;
291
323
  shutdownAll(): Promise<void>;
292
324
  /** 停止全部服务器并禁用 LSP:之后工具调用不再 spawn,直到 start/reload。 */
293
325
  stop(): Promise<void>;
@@ -690,6 +722,56 @@ export function createLspService(
690
722
  : new Error(`LSP rename failed on all servers — ${detail}`);
691
723
  }
692
724
 
725
+ /** 只读符号查询:与 rename 同款多服务器策略,但 MethodNotFound 是"跳过"而非失败。 */
726
+ async function inspect<Q extends LspInspectQuery>(request: {
727
+ file: string;
728
+ cwd: string;
729
+ line: number;
730
+ character: number;
731
+ query: Q;
732
+ options?: LspRequestOptions;
733
+ }): Promise<LspInspectResult<Q>> {
734
+ const clients = await getClients(
735
+ request.file,
736
+ request.cwd,
737
+ request.options?.notify,
738
+ (adapter) => adapter.kind !== "linter",
739
+ );
740
+ if (clients.length === 0) {
741
+ throw new Error(
742
+ `no LSP language server available for ${request.file} (check lsp.json servers and kind)`,
743
+ );
744
+ }
745
+ const failures: { serverID: string; error: unknown }[] = [];
746
+ for (const client of clients) {
747
+ const position = { path: request.file, line: request.line, character: request.character };
748
+ try {
749
+ if (request.query === "hover") {
750
+ const hover = await client.hover(position);
751
+ return { serverID: client.serverID, query: "hover", hover } as LspInspectResult<Q>;
752
+ }
753
+ const locations =
754
+ request.query === "definition"
755
+ ? await client.definition(position)
756
+ : await client.references(position);
757
+ return {
758
+ serverID: client.serverID,
759
+ query: request.query,
760
+ locations,
761
+ } as LspInspectResult<Q>;
762
+ } catch (error) {
763
+ failures.push({ serverID: client.serverID, error });
764
+ }
765
+ }
766
+ const allNotSupported = failures.every((f) => f.error instanceof LspMethodNotSupportedError);
767
+ const detail = failures
768
+ .map((f) => `${f.serverID}: ${f.error instanceof Error ? f.error.message : String(f.error)}`)
769
+ .join("; ");
770
+ throw allNotSupported
771
+ ? new LspMethodNotSupportedError("all configured servers", `textDocument/${request.query}`)
772
+ : new Error(`LSP inspect failed on all servers — ${detail}`);
773
+ }
774
+
693
775
  /** 关闭全部 client 并清空缓存;closing 置 true 让 in-flight spawn 自行退出。 */
694
776
  async function closeAll(): Promise<void> {
695
777
  state.closing = true;
@@ -752,6 +834,7 @@ export function createLspService(
752
834
  diagnostics,
753
835
  lspDiagnosticsForFile,
754
836
  rename,
837
+ inspect,
755
838
  shutdownAll,
756
839
  stop,
757
840
  start,
@@ -767,71 +850,154 @@ export interface LspServiceOptions {
767
850
  globalConfigPath?: string;
768
851
  }
769
852
 
770
- /** 创建 LSP service 并注册 pi 的进程级清理生命周期。 */
771
- export function registerLsp(pi: ExtensionAPI, options?: LspServiceOptions): LspService {
772
- const service = createLspService(options?.adapters, options?.globalConfigPath);
773
- pi.on?.("session_shutdown", () => service.shutdownAll());
774
- // session 开始是最早能拿到本地配置 cwd 的时机:预加载并校验,配置错误立即通知
775
- pi.on?.("session_start", (_event, ctx) => {
776
- void loadLspConfig(ctx.cwd, options?.globalConfigPath)
777
- .then((config) => validateConfig(config, options?.adapters))
778
- .catch((error: unknown) => {
779
- if (error instanceof Error) ctx.ui.notify?.(error.message, "error");
853
+ export interface LspManagerHooks {
854
+ /**
855
+ * session_start 校验通过且存在 enabled 服务器时调用(每实例至多一次);
856
+ * LSP 专属工具(rename / inspect 族)在此注册,未配置时保持不可见。
857
+ */
858
+ onEnabled: (pi: ExtensionAPI, service: LspService) => void;
859
+ }
860
+
861
+ export interface LspManager {
862
+ /**
863
+ * 文件工具的 service 访问器:永不抛错。disabled / session_start 未运行时
864
+ * 返回共享 no-op service(诊断与文件事件通知为空操作)。
865
+ */
866
+ mustLazyGetService(): LspService;
867
+ /** 当前会话是否已创建 service(/lsp-stop 后仍为 true,内部 disabled 语义不变)。 */
868
+ haveEnabledLsp(): boolean;
869
+ }
870
+
871
+ /**
872
+ * no-op service 单例:空 adapter 列表 → getClients 永远返回空,任何请求都是
873
+ * 空操作。globalConfigPath 指向必然不存在的文件——真实全局配置若声明了
874
+ * `enabled`,createLspService 的建时校验会因空 adapter 集合而抛错。
875
+ */
876
+ const noopServiceHolder: { service?: LspService } = {};
877
+ function getNoopService(): LspService {
878
+ noopServiceHolder.service ??= createLspService(
879
+ [],
880
+ join(tmpdir(), ".pi-lsp-noop-global-does-not-exist.json"),
881
+ );
882
+ return noopServiceHolder.service;
883
+ }
884
+
885
+ /** 会话配置里生效(未被 enabled 白名单排除、未被 disabled)的服务器数量。 */
886
+ function enabledServerCount(config: LspConfig, adapters?: LspServerAdapter[]): number {
887
+ const ids = adapters ? adapters.map((adapter) => adapter.id) : Object.keys(config.servers ?? {});
888
+ return ids.filter((id) => {
889
+ if (config.enabled && !config.enabled.includes(id)) return false;
890
+ if (config.disabled?.includes(id)) return false;
891
+ return true;
892
+ }).length;
893
+ }
894
+
895
+ /**
896
+ * 创建 LSP manager 并注册 pi 会话生命周期:
897
+ * - session_start(被 pi await):加载并校验配置;存在 enabled 服务器才创建
898
+ * service(进程仍首次工具调用才 spawn)并调用 onEnabled 注册 LSP 工具;
899
+ * 配置缺失或错误则保持 disabled(错误 notify 后降级,不阻断会话启动)。
900
+ * - session_shutdown:关闭全部服务器进程。
901
+ * 文件工具经 mustLazyGetService 访问,在任何状态下都能安全工作。
902
+ */
903
+ export function createLspManager(
904
+ pi: ExtensionAPI,
905
+ hooks: LspManagerHooks,
906
+ options?: LspServiceOptions,
907
+ ): LspManager {
908
+ let service: LspService | undefined;
909
+
910
+ pi.on("session_start", async (_event, ctx) => {
911
+ // /reload 等路径可能对同一 runner 重发 session_start:先清掉旧实例再建
912
+ if (service !== undefined) {
913
+ // 旧实例关闭失败不阻断新会话构建
914
+ await service.shutdownAll().catch(() => {
915
+ /* noop */
780
916
  });
781
- // footer status 显示当前所有 LSP server 状态(无 UI 时不显示)
782
- service.attachStatus(
783
- ctx.ui?.setStatus
784
- ? (text) => ctx.ui.setStatus("lsp", text ? ctx.ui.theme.fg("accent", text) : undefined)
785
- : undefined,
786
- );
917
+ service = undefined;
918
+ }
919
+ try {
920
+ const config = await loadLspConfig(ctx.cwd, options?.globalConfigPath);
921
+ validateConfig(config, options?.adapters);
922
+ if (enabledServerCount(config, options?.adapters) === 0) return;
923
+ const next = createLspService(options?.adapters, options?.globalConfigPath);
924
+ // footer status 显示当前所有 LSP server 状态(无 UI 时不显示)
925
+ next.attachStatus((text) =>
926
+ ctx.ui.setStatus("lsp", text ? ctx.ui.theme.fg("accent", text) : undefined),
927
+ );
928
+ service = next;
929
+ hooks.onEnabled(pi, next);
930
+ } catch (error) {
931
+ service = undefined;
932
+ if (error instanceof Error) ctx.ui.notify(error.message, "error");
933
+ }
787
934
  });
935
+
936
+ pi.on("session_shutdown", () => {
937
+ void service?.shutdownAll();
938
+ });
939
+
788
940
  // agent 生命周期边界显式刷新 status:agent 运行中 LSP server 才被惰性
789
941
  // spawn(首次工具调用),start/end 时保证 footer 反映当前实际状态。
790
- pi.on?.("agent_start", () => service.refreshStatus());
791
- pi.on?.("agent_end", () => service.refreshStatus());
942
+ pi.on("agent_start", () => service?.refreshStatus());
943
+ pi.on("agent_end", () => service?.refreshStatus());
792
944
 
793
- pi.registerCommand?.("lsp-stop", {
945
+ pi.registerCommand("lsp-stop", {
794
946
  description: "Stop all LSP servers and disable LSP until /lsp-start or /lsp-reload",
795
947
  handler: async (_args, ctx) => {
948
+ if (service === undefined) {
949
+ ctx.ui.notify("LSP not configured: nothing to stop", "warning");
950
+ return;
951
+ }
796
952
  await service.stop();
797
- ctx.ui.notify?.("LSP disabled: all servers stopped", "info");
953
+ ctx.ui.notify("LSP disabled: all servers stopped", "info");
798
954
  },
799
955
  });
800
956
 
801
- pi.registerCommand?.("lsp-start", {
957
+ pi.registerCommand("lsp-start", {
802
958
  description: "Re-enable LSP; servers start on the next tool call",
803
959
  handler: (_args, ctx) => {
960
+ if (service === undefined) {
961
+ ctx.ui.notify("LSP not configured: nothing to enable", "warning");
962
+ return Promise.resolve();
963
+ }
804
964
  service.start();
805
- ctx.ui.notify?.("LSP enabled: servers will start on the next tool call", "info");
965
+ ctx.ui.notify("LSP enabled: servers will start on the next tool call", "info");
806
966
  return Promise.resolve();
807
967
  },
808
968
  });
809
969
 
810
- pi.registerCommand?.("lsp-reload", {
970
+ pi.registerCommand("lsp-reload", {
811
971
  description: "Restart a specific LSP server: /lsp-reload <server-id>",
812
972
  getArgumentCompletions: (prefix) =>
813
- service
814
- .serverIDs()
973
+ (service?.serverIDs() ?? [])
815
974
  .toSorted()
816
975
  .filter((id) => id.startsWith(prefix))
817
976
  .map((id) => ({ value: id, label: id })),
818
977
  handler: async (args, ctx) => {
978
+ if (service === undefined) {
979
+ ctx.ui.notify("LSP not configured: nothing to reload", "warning");
980
+ return;
981
+ }
819
982
  const serverID = args.trim();
820
983
  const known = service.serverIDs().toSorted();
821
984
  if (!serverID) {
822
- ctx.ui.notify?.(
985
+ ctx.ui.notify(
823
986
  `usage: /lsp-reload <server-id>${known.length > 0 ? ` (known: ${known.join(", ")})` : ""}`,
824
987
  "warning",
825
988
  );
826
989
  return;
827
990
  }
828
991
  await service.reload(serverID);
829
- ctx.ui.notify?.(
992
+ ctx.ui.notify(
830
993
  `LSP server "${serverID}" reloaded: will restart on the next tool call`,
831
994
  "info",
832
995
  );
833
996
  },
834
997
  });
835
998
 
836
- return service;
999
+ return {
1000
+ mustLazyGetService: () => service ?? getNoopService(),
1001
+ haveEnabledLsp: () => service !== undefined,
1002
+ };
837
1003
  }
@@ -150,7 +150,7 @@ export function registerLspRenameTool(
150
150
  { result: (typeof successes)[number]["result"]; candidates: typeof candidates }
151
151
  >();
152
152
  for (const [index, candidate] of candidates.entries()) {
153
- const entry = successes[index];
153
+ const entry = successes.at(index);
154
154
  if (!entry) continue;
155
155
  const key = canonicalizeEdit(entry.result.edit);
156
156
  const group = groups.get(key);
@@ -172,7 +172,7 @@ export function registerLspRenameTool(
172
172
  `Ambiguous rename target on line ${params.line} of ${filePath}: several distinct symbols share the name "${params.symbol}". Retry with 'character' (1-based) to pick one:\n${listing}`,
173
173
  );
174
174
  }
175
- const firstGroup = [...groups.values()][0];
175
+ const firstGroup = [...groups.values()].at(0);
176
176
  if (!firstGroup) throw new Error("LSP rename returned no target");
177
177
  const { result } = firstGroup;
178
178
 
package/src/lib/ui.ts CHANGED
@@ -76,7 +76,7 @@ export async function selectMultiple(
76
76
  opts: { signal?: AbortSignal; doneLabel: string },
77
77
  ): Promise<string[]> {
78
78
  const selected: string[] = [];
79
- while (true) {
79
+ for (;;) {
80
80
  const selectedSet = new Set(selected);
81
81
  const displayToLabel = new Map<string, string>();
82
82
  const round: SelectAction[] = [];
@@ -143,7 +143,7 @@ export async function selectCheckboxActions<T extends string>(
143
143
  opts: { signal?: AbortSignal } = {},
144
144
  ): Promise<CheckboxActionResult<T> | undefined> {
145
145
  const selected: string[] = [];
146
- while (true) {
146
+ for (;;) {
147
147
  const selectedSet = new Set(selected);
148
148
  const displayToLabel = new Map<string, string>();
149
149
  const round: SelectAction[] = [];
@@ -143,7 +143,7 @@ export async function guardWriteAccess(
143
143
  throw new Error(`Path "${absolutePath}" is outside workspace. No UI available for approval.`);
144
144
  }
145
145
 
146
- while (true) {
146
+ for (;;) {
147
147
  const diffPreview = opts.change ? await buildDiffPreview(absolutePath, opts.change) : undefined;
148
148
  const title =
149
149
  `Model requests write access outside workspace:\n\n` +
@@ -66,7 +66,7 @@ function nonEmpty(value: string | undefined): string | undefined {
66
66
  function normalizeModel(model: Static<typeof modelSchema>): OpenaiCostModelConfig | undefined {
67
67
  const id = nonEmpty(model.id);
68
68
  if (!id) return undefined;
69
- const input = model.input?.filter((value) => value === "text" || value === "image") ?? [];
69
+ const input = model.input ?? [];
70
70
  return {
71
71
  id,
72
72
  name: nonEmpty(model.name) ?? id,
@@ -90,7 +90,7 @@ export async function scanSseCost(body: ReadableStream<Uint8Array>): Promise<num
90
90
  let buffer = "";
91
91
  let last: number | undefined;
92
92
  try {
93
- while (true) {
93
+ for (;;) {
94
94
  const { done, value } = await reader.read();
95
95
  if (done) break;
96
96
  buffer += decoder.decode(value, { stream: true });
@@ -372,7 +372,7 @@ const EscapeNormalizedReplacer: Replacer = function* (content, find) {
372
372
 
373
373
  const MultiOccurrenceReplacer: Replacer = function* (content, find) {
374
374
  let startIndex = 0;
375
- while (true) {
375
+ for (;;) {
376
376
  const index = content.indexOf(find, startIndex);
377
377
  if (index === -1) break;
378
378
  yield find;
@@ -30,7 +30,8 @@ import {
30
30
  import { Type } from "typebox";
31
31
 
32
32
  import { appendLspDiagnosticText } from "../lib/lsp/diagnostic.js";
33
- import { type LspService, registerLsp } from "../lib/lsp/lsp.js";
33
+ import { registerLspInspectTools } from "../lib/lsp/inspect-tool.js";
34
+ import { createLspManager, type LspService, type LspServiceOptions } from "../lib/lsp/lsp.js";
34
35
  import { registerLspRenameTool } from "../lib/lsp/rename-tool.js";
35
36
  import { formatSubtitlePath } from "../lib/path.js";
36
37
  import { guardWriteAccess } from "../lib/write-guard.js";
@@ -104,8 +105,8 @@ const IMAGE_SIGNATURES: {
104
105
  return (
105
106
  startsWithAscii(buf, 0, "BM") &&
106
107
  buf.length >= 30 &&
107
- (buf[28] ?? 0) === 1 &&
108
- [1, 4, 8, 16, 24, 32].includes(buf[28 + 1] ?? 0)
108
+ buf[28] === 1 &&
109
+ [1, 4, 8, 16, 24, 32].includes(buf[28 + 1])
109
110
  );
110
111
  },
111
112
  mimeType: "image/bmp",
@@ -294,7 +295,7 @@ async function formatDirectoryEntries(dirPath: string): Promise<string[]> {
294
295
  return results;
295
296
  }
296
297
 
297
- function registerReadTool(pi: ExtensionAPI, service: LspService): void {
298
+ function registerReadTool(pi: ExtensionAPI, getService: () => LspService): void {
298
299
  pi.registerTool({
299
300
  name: "read",
300
301
  label: "read",
@@ -433,9 +434,11 @@ function registerReadTool(pi: ExtensionAPI, service: LspService): void {
433
434
  content = [{ type: "text", text: outputText }];
434
435
 
435
436
  // opencode: LSP 文件事件通知是后台任务,失败不影响读取(read 不驻留文档)
436
- void service.notifyFile(absolutePath, ctx.cwd).catch(() => {
437
- // 后台通知失败不影响读取
438
- });
437
+ void getService()
438
+ .notifyFile(absolutePath, ctx.cwd)
439
+ .catch(() => {
440
+ // 后台通知失败不影响读取
441
+ });
439
442
 
440
443
  return {
441
444
  content,
@@ -461,7 +464,7 @@ const editSchema = Type.Object({
461
464
  ),
462
465
  });
463
466
 
464
- function registerEditTool(pi: ExtensionAPI, service: LspService): void {
467
+ function registerEditTool(pi: ExtensionAPI, getService: () => LspService): void {
465
468
  pi.registerTool({
466
469
  name: "edit",
467
470
  label: "edit",
@@ -524,7 +527,7 @@ function registerEditTool(pi: ExtensionAPI, service: LspService): void {
524
527
  text: diagnosticText,
525
528
  errorCount,
526
529
  warningCount,
527
- } = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd, { signal });
530
+ } = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, { signal });
528
531
  return [
529
532
  "Edit applied successfully.",
530
533
  { diff: "", patch: "", firstChangedLine: 0 },
@@ -566,7 +569,7 @@ function registerEditTool(pi: ExtensionAPI, service: LspService): void {
566
569
  text: diagnosticText,
567
570
  errorCount,
568
571
  warningCount,
569
- } = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd, { signal });
572
+ } = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, { signal });
570
573
  return [
571
574
  "Edit applied successfully.",
572
575
  { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
@@ -614,7 +617,7 @@ export function resolveBom(
614
617
  return { bom: sourceBom || nextBom, text };
615
618
  }
616
619
 
617
- function registerWriteTool(pi: ExtensionAPI, service: LspService): void {
620
+ function registerWriteTool(pi: ExtensionAPI, getService: () => LspService): void {
618
621
  pi.registerTool({
619
622
  name: "write",
620
623
  label: "write",
@@ -666,7 +669,7 @@ function registerWriteTool(pi: ExtensionAPI, service: LspService): void {
666
669
  text: diagnosticText,
667
670
  errorCount,
668
671
  warningCount,
669
- } = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd, { signal });
672
+ } = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, { signal });
670
673
 
671
674
  return [
672
675
  "Wrote file successfully.",
@@ -693,17 +696,25 @@ function registerWriteTool(pi: ExtensionAPI, service: LspService): void {
693
696
 
694
697
  // ── 入口 ─────────────────────────────────────────────────────────────────────
695
698
 
696
- export function registerFileTools(pi: ExtensionAPI, service: LspService): void {
697
- registerReadTool(pi, service);
698
- registerEditTool(pi, service);
699
- registerWriteTool(pi, service);
700
- // 工具壳与 claude-code 共享(lib/lsp/rename-tool.ts);opencode 不跟踪
701
- // read-before-write 状态,不传 recordReads hook。
702
- registerLspRenameTool(pi, service);
699
+ export function registerFileTools(pi: ExtensionAPI, getService: () => LspService): void {
700
+ registerReadTool(pi, getService);
701
+ registerEditTool(pi, getService);
702
+ registerWriteTool(pi, getService);
703
+ // lsp-rename / inspect 工具由 manager 的 onEnabled 回调注册,不在这里注册。
703
704
  }
704
705
 
705
- /** 独立入口:创建 LSP service(闭包共享给三个工具)并注册。 */
706
- export default function opencodeFileTools(pi: ExtensionAPI): void {
707
- const service = registerLsp(pi);
708
- registerFileTools(pi, service);
706
+ /** 独立入口:创建 LSP manager(session_start 时按配置启用)并注册文件工具。 */
707
+ export default function opencodeFileTools(pi: ExtensionAPI, options?: LspServiceOptions): void {
708
+ const manager = createLspManager(
709
+ pi,
710
+ {
711
+ onEnabled: (pi, service) => {
712
+ registerLspRenameTool(pi, service);
713
+ registerLspInspectTools(pi, service);
714
+ },
715
+ },
716
+ options,
717
+ );
718
+ // 文件工具无条件注册;service 惰性获取,disabled 时为 no-op。
719
+ registerFileTools(pi, () => manager.mustLazyGetService());
709
720
  }
@@ -89,8 +89,8 @@ const SETTINGS_PATH = join(getAgentDir(), "settings.json");
89
89
  * implementation file (they share a read-snapshot state); the `tools`
90
90
  * allowlist still exposes only the declared subset. The opencode file tools
91
91
  * (read/edit/write) likewise share opencode/files.ts (they share the LSP
92
- * service instance); that file also registers the shared `lsp-rename` tool,
93
- * which stays hidden unless a subagent declares it.
92
+ * service instance); that file also registers the shared `lsp-rename` and
93
+ * LSP inspect tools, which stay hidden unless a subagent declares them.
94
94
  */
95
95
  const TOOL_EXTENSION_OVERRIDES: Record<string, string> = {
96
96
  read: "opencode/files.ts",
package/src/talk/index.ts CHANGED
@@ -171,7 +171,7 @@ export default function talk(pi: ExtensionAPI) {
171
171
  // ── Lifecycle ──────────────────────────────────────────────────────────
172
172
 
173
173
  pi.on("session_start", (_event, ctx: ExtensionContext) => {
174
- const cwd = ctx.sessionManager.getCwd() ?? ctx.cwd;
174
+ const cwd = ctx.sessionManager.getCwd();
175
175
  const now = Date.now();
176
176
  // A fork/branch/resume of a session that joined a group keeps that talk
177
177
  // identity (agentId); a fresh session gets the new session id.
@@ -463,13 +463,7 @@ export default function talk(pi: ExtensionAPI) {
463
463
 
464
464
  pi.registerMessageRenderer<DeliveryDetails>(DELIVERY_TYPE, (message, _options, theme) => {
465
465
  const d = message.details;
466
- if (
467
- !d ||
468
- typeof d.id !== "string" ||
469
- typeof d.ts !== "number" ||
470
- typeof d.body !== "string" ||
471
- !d.from
472
- ) {
466
+ if (!d || typeof d.id !== "string" || typeof d.ts !== "number" || typeof d.body !== "string") {
473
467
  return; // pre-renderer entries: keep pi's default custom-message box
474
468
  }
475
469
  const idTail = d.id.slice(-8);
@@ -164,7 +164,7 @@ function pidAlive(pid: number, pidStart?: number): boolean {
164
164
  process.kill(pid, 0);
165
165
  } catch (error) {
166
166
  // EPERM means the process exists but isn't ours — still alive
167
- return (error as NodeJS.ErrnoException)?.code === "EPERM";
167
+ return (error as NodeJS.ErrnoException).code === "EPERM";
168
168
  }
169
169
  if (pidStart === undefined) return true;
170
170
  const start = readStartTime(pid);
@@ -276,7 +276,7 @@ export async function callVision(
276
276
  messages: [{ role: "user", content, timestamp: Date.now() }],
277
277
  },
278
278
  {
279
- maxTokens: model.maxTokens ?? DEFAULT_MAX_TOKENS,
279
+ maxTokens: model.maxTokens,
280
280
  signal: withTimeout(signal, REQUEST_TIMEOUT_MS),
281
281
  },
282
282
  );
@@ -290,7 +290,7 @@ export async function callVision(
290
290
  throw new Error("API 未返回内容");
291
291
  }
292
292
  const tokenStr =
293
- typeof result.usage?.totalTokens === "number" ? String(result.usage.totalTokens) : "?";
293
+ typeof result.usage.totalTokens === "number" ? String(result.usage.totalTokens) : "?";
294
294
  const labels = loaded.map((l) => l.label).join(", ");
295
295
  return `[${labels}]\n${text}\n[模型: ${model.id}, tokens: ${tokenStr}]`;
296
296
  }