@trim21/personal-pi-extensions 0.1.490 → 0.1.492

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.490",
3
+ "version": "0.1.492",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -18,7 +18,7 @@ import { type BashCommand, parseBashCommands } from "./approval-rules.js";
18
18
  */
19
19
  const ARITY = JSON.parse(readFileSync(new URL("arity.json", import.meta.url), "utf8")) as Record<
20
20
  string,
21
- number
21
+ number | undefined
22
22
  >;
23
23
 
24
24
  /**
package/src/bwrap/core.ts CHANGED
@@ -105,11 +105,11 @@ export function resolveBwrap(config: BwrapConfig): ResolvedBwrap {
105
105
  const base = {
106
106
  mode: config.mode,
107
107
  bwrapPath: config.bwrapPath,
108
- writablePaths: config.writablePaths ?? [".", "/tmp"],
108
+ writablePaths: config.writablePaths,
109
109
  extraWritablePaths: config.extraWritablePaths,
110
- denyPaths: config.denyPaths ?? [],
111
- extraArgs: config.extraArgs ?? [],
112
- networkAllowlist: config.networkAllowlist ?? [],
110
+ denyPaths: config.denyPaths,
111
+ extraArgs: config.extraArgs,
112
+ networkAllowlist: config.networkAllowlist,
113
113
  mihomoPath: config.mihomoPath,
114
114
  slirp4netnsPath: config.slirp4netnsPath,
115
115
  approvalRules: config.approvalRules ?? [],
@@ -529,8 +529,8 @@ export function createBwrapBashOperations(
529
529
  }, timeout * 1000)
530
530
  : undefined;
531
531
  const onAbort = () => killChild(child);
532
- child.stdout?.on("data", onData);
533
- child.stderr?.on("data", onData);
532
+ child.stdout.on("data", onData);
533
+ child.stderr.on("data", onData);
534
534
  signal?.addEventListener("abort", onAbort, { once: true });
535
535
  child.once("error", reject);
536
536
  child.once("close", (exitCode) => {
@@ -81,7 +81,9 @@ function parseAllowlistEntry(entry: string): AllowlistEntry {
81
81
  const match = /^\[(.+)\](?::(\d+))?$/.exec(entry);
82
82
  if (match?.[1] === undefined) throw new Error(`Invalid allowlist entry "${entry}"`);
83
83
  host = match[1];
84
- port = match[2] === undefined ? undefined : parsePort(match[2]);
84
+ // 端口组 (?::(\d+))? 可选:无端口时 match[2] 在运行时是 undefined
85
+ const portPart = match[2] as string | undefined;
86
+ port = portPart === undefined ? undefined : parsePort(portPart);
85
87
  } else {
86
88
  const colon = entry.lastIndexOf(":");
87
89
  if (colon === -1) {
@@ -332,8 +332,8 @@ export async function startNetworkStack(options: NetworkStackOptions): Promise<N
332
332
  killChild(child.pid);
333
333
  };
334
334
 
335
- child.stdout?.on("data", execOptions.onData);
336
- child.stderr?.on("data", execOptions.onData);
335
+ child.stdout.on("data", execOptions.onData);
336
+ child.stderr.on("data", execOptions.onData);
337
337
  execOptions.signal?.addEventListener("abort", onAbort, { once: true });
338
338
 
339
339
  child.once("error", (error) => {
@@ -18,10 +18,6 @@ import { Value } from "typebox/value";
18
18
  const fileSnapshotSchema = Type.Object({
19
19
  digest: Type.String(),
20
20
  textEditable: Type.Boolean(),
21
- // 以下字段仅 Read 写入,供同范围重复读取 dedup;Edit/Write 不写,
22
- // 覆盖记录后 offset 缺省 → 不再 dedup,强制重新 Read(对齐 CC readFileState)
23
- offset: Type.Optional(Type.Number()),
24
- limit: Type.Optional(Type.Number()),
25
21
  });
26
22
 
27
23
  export type FileSnapshot = Static<typeof fileSnapshotSchema>;
@@ -73,10 +73,10 @@ function applyCurlySingleQuotes(str: string): string {
73
73
  for (let i = 0; i < chars.length; i++) {
74
74
  if (chars[i] === "'") {
75
75
  // 缩写中的撇号(如 don't)用右花引号,不做开/闭判断
76
- const prev = chars[i - 1];
77
- const next = chars[i + 1];
78
- const prevIsLetter = prev !== undefined && /\p{L}/u.test(prev);
79
- const nextIsLetter = next !== undefined && /\p{L}/u.test(next);
76
+ const prev = i > 0 ? chars[i - 1] : "";
77
+ const next = i + 1 < chars.length ? chars[i + 1] : "";
78
+ const prevIsLetter = /\p{L}/u.test(prev);
79
+ const nextIsLetter = /\p{L}/u.test(next);
80
80
  if (prevIsLetter && nextIsLetter) {
81
81
  result.push(RIGHT_SINGLE_CURLY_QUOTE);
82
82
  } else {
@@ -16,7 +16,7 @@ import { Type } from "typebox";
16
16
 
17
17
  import { appendLspDiagnosticText } from "../lib/lsp/diagnostic.js";
18
18
  import { registerLspInspectTools } from "../lib/lsp/inspect-tool.js";
19
- import { type LspService, registerLsp } from "../lib/lsp/lsp.js";
19
+ import { createLspManager, type LspService, type LspServiceOptions } from "../lib/lsp/lsp.js";
20
20
  import { registerLspRenameTool } from "../lib/lsp/rename-tool.js";
21
21
  import { formatSubtitlePath } from "../lib/path.js";
22
22
  import type { ToolPendant } from "../lib/pendant.ts";
@@ -34,10 +34,6 @@ import { convertLeadingTabsToSpaces, findActualString, preserveQuoteStyle } from
34
34
 
35
35
  const SAMPLE_BYTES = 4096;
36
36
 
37
- /** 同范围重复读取、文件未变时返回的 stub(对齐 Claude Code 的 file_unchanged)。 */
38
- export const FILE_UNCHANGED_STUB =
39
- "File unchanged since last read. The content from the earlier Read tool_result in this conversation is still current — refer to that instead of re-reading.";
40
-
41
37
  /** Read 全读时的文件大小上限(对齐 Claude Code 的 256KB)。 */
42
38
  const MAX_READ_SIZE_BYTES = 0.25 * 1024 * 1024;
43
39
  /** Read 输出 token 粗估上限(对齐 Claude Code 的 25K tokens;无 tokenizer,按 4 字符/token 估算)。 */
@@ -229,7 +225,7 @@ function requireCurrentRead(
229
225
  export function registerFileTools(
230
226
  pi: ExtensionAPI,
231
227
  state: ClaudeCodeState,
232
- service: LspService,
228
+ getService: () => LspService,
233
229
  ): void {
234
230
  pi.registerTool({
235
231
  name: "Read",
@@ -317,21 +313,6 @@ export function registerFileTools(
317
313
  const key = await readStateKey(filePath);
318
314
  const buffer = await readFile(filePath);
319
315
 
320
- // 同范围 + checksum 未变 → 返回 stub 而非重发内容(对齐 CC readFileState;
321
- // 复用 reads 里的 sha256,比 mtime 可靠,无时间片粒度问题)
322
- const previous = state.reads.get(key);
323
- if (
324
- previous !== undefined &&
325
- previous.offset !== undefined &&
326
- previous.offset === offset &&
327
- previous.limit === limit &&
328
- snapshotsEqual(previous, snapshotOf(buffer))
329
- ) {
330
- return {
331
- content: [{ type: "text", text: FILE_UNCHANGED_STUB }],
332
- details: { pendant: { subtitle: formatSubtitlePath(ctx.cwd, filePath) } },
333
- };
334
- }
335
316
  if (isBinary(buffer.subarray(0, SAMPLE_BYTES)))
336
317
  throw new Error(`Cannot read binary file: ${filePath}`);
337
318
  // 全读(limit 未传)时受字节上限约束(对齐 Claude Code)
@@ -349,12 +330,14 @@ export function registerFileTools(
349
330
  `File content (${estimatedTokens} tokens) exceeds maximum allowed tokens (${MAX_READ_TOKENS}). Use offset and limit parameters to read specific portions of the file, or search for specific content instead of reading the whole file.`,
350
331
  );
351
332
  }
352
- const snapshot = { ...snapshotOf(buffer), offset, limit };
333
+ const snapshot = snapshotOf(buffer);
353
334
  state.reads.set(key, snapshot);
354
335
  // LSP 文件事件通知是后台任务,失败不影响读取(read 不驻留文档)
355
- void service.notifyFile(filePath, ctx.cwd).catch(() => {
356
- // 后台通知失败不影响读取
357
- });
336
+ void getService()
337
+ .notifyFile(filePath, ctx.cwd)
338
+ .catch(() => {
339
+ // 后台通知失败不影响读取
340
+ });
358
341
  return {
359
342
  content: [{ type: "text", text: formatted.text }],
360
343
  details: {
@@ -444,7 +427,7 @@ export function registerFileTools(
444
427
  text: diagnosticText,
445
428
  errorCount,
446
429
  warningCount,
447
- } = await service.lspDiagnosticsForFile(filePath, ctx.cwd, {
430
+ } = await getService().lspDiagnosticsForFile(filePath, ctx.cwd, {
448
431
  notify: (message, level) => ctx.ui.notify(message, level),
449
432
  signal,
450
433
  });
@@ -547,7 +530,7 @@ export function registerFileTools(
547
530
  text: diagnosticText,
548
531
  errorCount,
549
532
  warningCount,
550
- } = await service.lspDiagnosticsForFile(filePath, ctx.cwd, {
533
+ } = await getService().lspDiagnosticsForFile(filePath, ctx.cwd, {
551
534
  notify: (message, level) => ctx.ui.notify(message, level),
552
535
  });
553
536
  return [
@@ -646,7 +629,7 @@ export function registerFileTools(
646
629
  text: diagnosticText,
647
630
  errorCount,
648
631
  warningCount,
649
- } = await service.lspDiagnosticsForFile(filePath, ctx.cwd, {
632
+ } = await getService().lspDiagnosticsForFile(filePath, ctx.cwd, {
650
633
  notify: (message, level) => ctx.ui.notify(message, level),
651
634
  });
652
635
  return [
@@ -675,23 +658,8 @@ export function registerFileTools(
675
658
  },
676
659
  });
677
660
 
678
- // lsp-rename 工具壳与 opencode 共享(lib/lsp/rename-tool.ts);本工具集
679
- // 跟踪 read-before-write 状态,rename 落盘的文件要标记为已读并随 details
680
- // 持久化(FILE_TOOL_NAMES 的 restoreFileReads 依赖 details.reads)。
681
- registerLspRenameTool(pi, service, {
682
- recordReads: async (applied) => {
683
- const reads: Record<string, FileSnapshot> = {};
684
- for (const fileEdit of applied) {
685
- const key = await readStateKey(fileEdit.path);
686
- const snapshot = snapshotOf(fileEdit.newText);
687
- state.reads.set(key, snapshot);
688
- reads[key] = snapshot;
689
- }
690
- return reads;
691
- },
692
- });
693
- // 只读符号查询工具(find-definition / find-reference / inspect)与 opencode 共享。
694
- registerLspInspectTools(pi, service);
661
+ // 只读符号查询工具(find-definition / find-reference / inspect)与 opencode 共享;
662
+ // 它们由 manager onEnabled 回调注册,不在这里注册。
695
663
  }
696
664
 
697
665
  /** 会更新 reads state 并随 details 持久化快照的工具名。 */
@@ -723,10 +691,34 @@ function restoreFileReads(
723
691
  * state 归本文件所有:扩展实例内创建,并随 session 事件从历史分支恢复,
724
692
  * 与主进程 index.ts 聚合加载时的行为一致。
725
693
  */
726
- export default function claudeCodeFileTools(pi: ExtensionAPI): void {
727
- const service = registerLsp(pi);
694
+ export default function claudeCodeFileTools(pi: ExtensionAPI, options?: LspServiceOptions): void {
728
695
  const state = createClaudeCodeState();
729
696
 
697
+ // LSP 专属工具(lsp-rename / inspect 族)仅在 lsp.json 存在 enabled 服务器时
698
+ // 注册(session_start 校验后);本工具集跟踪 read-before-write 状态,rename
699
+ // 落盘的文件要标记为已读并随 details 持久化(restoreFileReads 依赖 details.reads)。
700
+ const manager = createLspManager(
701
+ pi,
702
+ {
703
+ onEnabled: (pi, service) => {
704
+ registerLspRenameTool(pi, service, {
705
+ recordReads: async (applied) => {
706
+ const reads: Record<string, FileSnapshot> = {};
707
+ for (const fileEdit of applied) {
708
+ const key = await readStateKey(fileEdit.path);
709
+ const snapshot = snapshotOf(fileEdit.newText);
710
+ state.reads.set(key, snapshot);
711
+ reads[key] = snapshot;
712
+ }
713
+ return reads;
714
+ },
715
+ });
716
+ registerLspInspectTools(pi, service);
717
+ },
718
+ },
719
+ options,
720
+ );
721
+
730
722
  // 扩展实例在进程启动 / /reload / /new / /resume / /fork 时重建,内存里的
731
723
  // 已读记账随之丢失。这里从当前分支的历史工具结果里恢复:digest 是当时的值,
732
724
  // 若文件在此期间被外部修改,Edit/Write 时的指纹对比仍会要求重新 Read,
@@ -742,5 +734,6 @@ export default function claudeCodeFileTools(pi: ExtensionAPI): void {
742
734
  restoreFileReads(state, ctx.sessionManager);
743
735
  });
744
736
 
745
- registerFileTools(pi, state, service);
737
+ // 文件工具无条件注册;service 惰性获取,disabled 时为 no-op。
738
+ registerFileTools(pi, state, () => manager.mustLazyGetService());
746
739
  }
@@ -34,7 +34,7 @@ function extractGlobBaseDirectory(pattern: string): {
34
34
  } {
35
35
  const globChars = /[*?[{]/;
36
36
  const match = globChars.exec(pattern);
37
- if (!match || match.index === undefined) {
37
+ if (!match) {
38
38
  // 无 glob 特殊字符:字面路径,目录部分作为 baseDir
39
39
  return { baseDir: dirname(pattern), relativePattern: basename(pattern) };
40
40
  }
@@ -122,12 +122,12 @@ export function runGh(
122
122
  timeoutId = setTimeout(() => killProcess("timeout"), timeout);
123
123
  }
124
124
 
125
- proc.stdout?.on("data", (data: Buffer) => {
125
+ proc.stdout.on("data", (data: Buffer) => {
126
126
  const text = data.toString();
127
127
  stdout += text;
128
128
  combined.push(text);
129
129
  });
130
- proc.stderr?.on("data", (data: Buffer) => {
130
+ proc.stderr.on("data", (data: Buffer) => {
131
131
  const text = data.toString();
132
132
  stderr += text;
133
133
  combined.push(text);
@@ -793,7 +793,7 @@ export async function renderStepLog(
793
793
  const totalLines = clean.split("\n").length;
794
794
  let logToShow = clean;
795
795
  let appliedOffset = false;
796
- if (offset !== undefined && offset !== null && offset > 1) {
796
+ if (offset !== undefined && offset > 1) {
797
797
  if (offset > totalLines) {
798
798
  return {
799
799
  content: [
@@ -867,7 +867,7 @@ export async function renderJobLogs(
867
867
  ): Promise<CiLogsResult> {
868
868
  const { job, offset, limit, full } = params;
869
869
 
870
- if (!jobs || jobs.length === 0) {
870
+ if (jobs.length === 0) {
871
871
  return {
872
872
  content: [{ type: "text", text: `No jobs found for run ${params.runId}` }],
873
873
  details: {},
@@ -923,7 +923,7 @@ export async function renderJobLogs(
923
923
 
924
924
  // Apply offset on the cleaned text, then truncate.
925
925
  let logToShow = clean;
926
- if (offset !== undefined && offset !== null && offset > 1) {
926
+ if (offset !== undefined && offset > 1) {
927
927
  if (offset > totalLines) {
928
928
  steps.push({ name: s.name });
929
929
  continue;
@@ -1037,9 +1037,12 @@ export async function writeLogFile(
1037
1037
 
1038
1038
  let content: string;
1039
1039
  let what: string;
1040
- if (step !== undefined && step !== null) {
1040
+ if (step === undefined) {
1041
+ content = stripAnsi(rawLog);
1042
+ what = `job "${targetJob.name}" (id: ${targetJob.id})`;
1043
+ } else {
1041
1044
  const found = targetJob.steps.find((s) => s.name.toLowerCase() === step.toLowerCase());
1042
- if (!found) {
1045
+ if (found === undefined) {
1043
1046
  return {
1044
1047
  content: [
1045
1048
  {
@@ -1064,9 +1067,6 @@ export async function writeLogFile(
1064
1067
  }
1065
1068
  content = cleanStepOutput(stepLog);
1066
1069
  what = `step ${found.number} ("${found.name}") of job "${targetJob.name}"`;
1067
- } else {
1068
- content = stripAnsi(rawLog);
1069
- what = `job "${targetJob.name}" (id: ${targetJob.id})`;
1070
1070
  }
1071
1071
 
1072
1072
  const target = resolve(cwd ?? process.cwd(), outputFile);
@@ -1480,7 +1480,7 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1480
1480
  getJobLog(String(run_id), jobId, effectiveRepo, signal, ctx.cwd, params);
1481
1481
 
1482
1482
  // ── Write the complete log to a file ───────────────────────────────
1483
- if (output_file !== undefined && output_file !== null && output_file !== "") {
1483
+ if (output_file !== undefined && output_file !== "") {
1484
1484
  const result = await writeLogFile(
1485
1485
  { runId: String(run_id), job, step, outputFile: output_file },
1486
1486
  jobs,
@@ -1492,7 +1492,7 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
1492
1492
  }
1493
1493
 
1494
1494
  // ── Fetch a specific step's logs (requires `job`) ─────────────────
1495
- if (step !== undefined && step !== null) {
1495
+ if (step !== undefined) {
1496
1496
  onUpdate?.({
1497
1497
  content: [{ type: "text", text: `Fetching job list...` }],
1498
1498
  details: {},
package/src/lib/cli.ts CHANGED
@@ -238,7 +238,7 @@ export function parseCommand<TFlags extends TObject>(
238
238
  } else if (info.kind === "boolean") {
239
239
  rawFlags[info.key] = true;
240
240
  } else {
241
- const next = tokens[i + 1];
241
+ const next = tokens.at(i + 1);
242
242
  if (next !== undefined && !looksLikeFlag(next)) {
243
243
  rawFlags[info.key] = next;
244
244
  i++;
@@ -266,7 +266,7 @@ export function parseCommand<TFlags extends TObject>(
266
266
  } else if (j + 1 < rest.length) {
267
267
  rawFlags[info.key] = rest.slice(j + 1);
268
268
  } else {
269
- const next = tokens[i + 1];
269
+ const next = tokens.at(i + 1);
270
270
  if (next !== undefined && !looksLikeFlag(next)) {
271
271
  rawFlags[info.key] = next;
272
272
  i++;
@@ -327,10 +327,7 @@ export function parseCommand<TFlags extends TObject>(
327
327
  parsed = Value.Convert(spec.flags, Value.Clone(parsed));
328
328
  if (!Value.Check(spec.flags, parsed)) {
329
329
  const [first] = [...Value.Errors(spec.flags, parsed)];
330
- return errorResult(
331
- spec,
332
- `Invalid arguments: ${first?.message ?? "value does not match flags"}`,
333
- );
330
+ return errorResult(spec, `Invalid arguments: ${first.message}`);
334
331
  }
335
332
  } catch {
336
333
  return errorResult(spec, "Invalid arguments");
@@ -25,6 +25,7 @@ import type {
25
25
  Hover,
26
26
  Location as LspLocation,
27
27
  LocationLink,
28
+ Range,
28
29
  WorkspaceEdit,
29
30
  } from "vscode-languageserver-types";
30
31
 
@@ -192,7 +193,9 @@ function toInspectLocations(result: DefinitionResult): InspectLocation[] {
192
193
  for (const item of items) {
193
194
  if ("targetUri" in item) {
194
195
  if (!item.targetUri.startsWith("file:")) continue;
195
- const range = item.targetSelectionRange ?? item.targetRange;
196
+ const range =
197
+ (item as Omit<LocationLink, "targetSelectionRange"> & { targetSelectionRange?: Range })
198
+ .targetSelectionRange ?? item.targetRange;
196
199
  locations.push({
197
200
  path: normalize(fileURLToPath(item.targetUri)),
198
201
  line: range.start.line,
@@ -424,7 +427,7 @@ export async function create(input: CreateInput): Promise<LspClient> {
424
427
  new StreamMessageReader(input.server.process.stdout),
425
428
  new StreamMessageWriter(input.server.process.stdin),
426
429
  );
427
- input.server.process.stderr?.resume();
430
+ input.server.process.stderr.resume();
428
431
  /** 连接或服务器进程已关闭;pull 重试循环以此终止,避免无界等待。 */
429
432
  let connectionClosed = false;
430
433
  input.server.process.once("exit", () => {
@@ -581,7 +584,7 @@ export async function create(input: CreateInput): Promise<LspClient> {
581
584
  await connection.sendNotification("workspace/didChangeConfiguration", { settings });
582
585
  }
583
586
 
584
- const files: Record<string, { version: number; text: string }> = {};
587
+ const files: Record<string, { version: number; text: string } | undefined> = {};
585
588
 
586
589
  // ── 驻留 LRU ────────────────────────────────────────────────────────────────
587
590
 
@@ -823,16 +826,16 @@ export async function create(input: CreateInput): Promise<LspClient> {
823
826
  if (timeout <= 0) return Promise.resolve(false);
824
827
  return new Promise<boolean>((resolve) => {
825
828
  let finished = false;
829
+ const timer = setTimeout(() => finish(false), timeout);
826
830
  const finish = (result: boolean) => {
827
831
  if (finished) return;
828
832
  finished = true;
829
- if (timer) clearTimeout(timer);
833
+ clearTimeout(timer);
830
834
  registrationListeners.delete(listener);
831
835
  resolve(result);
832
836
  };
833
837
  const listener = () => finish(true);
834
838
  registrationListeners.add(listener);
835
- const timer = setTimeout(() => finish(false), timeout);
836
839
  });
837
840
  }
838
841
 
@@ -846,12 +849,14 @@ export async function create(input: CreateInput): Promise<LspClient> {
846
849
  return new Promise<boolean>((resolve) => {
847
850
  let finished = false;
848
851
  let debounceTimer: ReturnType<typeof setTimeout> | undefined;
852
+ const timeoutTimer = setTimeout(() => finish(false), request.timeout);
853
+ const unsub = () => diagnosticListeners.delete(listener);
849
854
  const finish = (result: boolean) => {
850
855
  if (finished) return;
851
856
  finished = true;
852
857
  if (debounceTimer) clearTimeout(debounceTimer);
853
- if (timeoutTimer) clearTimeout(timeoutTimer);
854
- unsub?.();
858
+ clearTimeout(timeoutTimer);
859
+ unsub();
855
860
  resolve(result);
856
861
  };
857
862
  const schedule = () => {
@@ -866,13 +871,11 @@ export async function create(input: CreateInput): Promise<LspClient> {
866
871
  );
867
872
  };
868
873
 
869
- const timeoutTimer = setTimeout(() => finish(false), request.timeout);
870
874
  const listener = (event: { path: string; serverID: string }) => {
871
875
  if (event.path !== request.path || event.serverID !== input.serverID) return;
872
876
  schedule();
873
877
  };
874
878
  diagnosticListeners.add(listener);
875
- const unsub = () => diagnosticListeners.delete(listener);
876
879
  schedule();
877
880
  });
878
881
  }
@@ -216,7 +216,7 @@ async function probeSymbolCandidates(options: {
216
216
  }
217
217
  const groups = new Map<string, { output: InspectOutput; candidates: LspPosition[] }>();
218
218
  for (const [index, candidate] of candidates.entries()) {
219
- const output = outputs[index];
219
+ const output = outputs.at(index);
220
220
  if (!output) continue;
221
221
  const group = groups.get(output.text);
222
222
  if (group) group.candidates.push(candidate);
@@ -234,7 +234,7 @@ async function probeSymbolCandidates(options: {
234
234
  `Ambiguous symbol on line ${options.line} of ${options.filePath}: several distinct symbols share the name "${options.symbol}". Retry with 'character' (1-based) to pick one:\n${listing}`,
235
235
  );
236
236
  }
237
- const first = [...groups.values()][0];
237
+ const first = [...groups.values()].at(0);
238
238
  if (!first) throw new Error("LSP inspect returned no result");
239
239
  return first.output;
240
240
  }
@@ -2,7 +2,7 @@
2
2
  * 文件扩展名 → LSP languageId 映射,用于 didOpen 通知。
3
3
  * 移植自 opencode packages/opencode/src/lsp/language.ts。
4
4
  */
5
- export const LANGUAGE_EXTENSIONS: Record<string, string> = {
5
+ export const LANGUAGE_EXTENSIONS: Record<string, string | undefined> = {
6
6
  ".abap": "abap",
7
7
  ".bat": "bat",
8
8
  ".bib": "bibtex",
@@ -18,7 +18,7 @@
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";
@@ -90,12 +90,19 @@ export type LspConfig = Static<typeof lspConfigSchema>;
90
90
 
91
91
  /** "500" → 500、"5s" → 5000、"1m" → 60000;无效字符串返回 NaN(由 toMs 过滤)。 */
92
92
  function parseTimeoutString(value: string): number {
93
- 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());
94
95
  if (!match) return NaN;
95
96
  const amount = Number(match[1]);
96
- const unit = match[2] ?? "ms";
97
- const factors: Record<string, number> = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000 };
98
- 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);
99
106
  }
100
107
 
101
108
  function toMs(value: number | string | undefined): number | undefined {
@@ -843,71 +850,154 @@ export interface LspServiceOptions {
843
850
  globalConfigPath?: string;
844
851
  }
845
852
 
846
- /** 创建 LSP service 并注册 pi 的进程级清理生命周期。 */
847
- export function registerLsp(pi: ExtensionAPI, options?: LspServiceOptions): LspService {
848
- const service = createLspService(options?.adapters, options?.globalConfigPath);
849
- pi.on?.("session_shutdown", () => service.shutdownAll());
850
- // session 开始是最早能拿到本地配置 cwd 的时机:预加载并校验,配置错误立即通知
851
- pi.on?.("session_start", (_event, ctx) => {
852
- void loadLspConfig(ctx.cwd, options?.globalConfigPath)
853
- .then((config) => validateConfig(config, options?.adapters))
854
- .catch((error: unknown) => {
855
- 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 */
856
916
  });
857
- // footer status 显示当前所有 LSP server 状态(无 UI 时不显示)
858
- service.attachStatus(
859
- ctx.ui?.setStatus
860
- ? (text) => ctx.ui.setStatus("lsp", text ? ctx.ui.theme.fg("accent", text) : undefined)
861
- : undefined,
862
- );
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
+ }
934
+ });
935
+
936
+ pi.on("session_shutdown", () => {
937
+ void service?.shutdownAll();
863
938
  });
939
+
864
940
  // agent 生命周期边界显式刷新 status:agent 运行中 LSP server 才被惰性
865
941
  // spawn(首次工具调用),start/end 时保证 footer 反映当前实际状态。
866
- pi.on?.("agent_start", () => service.refreshStatus());
867
- pi.on?.("agent_end", () => service.refreshStatus());
942
+ pi.on("agent_start", () => service?.refreshStatus());
943
+ pi.on("agent_end", () => service?.refreshStatus());
868
944
 
869
- pi.registerCommand?.("lsp-stop", {
945
+ pi.registerCommand("lsp-stop", {
870
946
  description: "Stop all LSP servers and disable LSP until /lsp-start or /lsp-reload",
871
947
  handler: async (_args, ctx) => {
948
+ if (service === undefined) {
949
+ ctx.ui.notify("LSP not configured: nothing to stop", "warning");
950
+ return;
951
+ }
872
952
  await service.stop();
873
- ctx.ui.notify?.("LSP disabled: all servers stopped", "info");
953
+ ctx.ui.notify("LSP disabled: all servers stopped", "info");
874
954
  },
875
955
  });
876
956
 
877
- pi.registerCommand?.("lsp-start", {
957
+ pi.registerCommand("lsp-start", {
878
958
  description: "Re-enable LSP; servers start on the next tool call",
879
959
  handler: (_args, ctx) => {
960
+ if (service === undefined) {
961
+ ctx.ui.notify("LSP not configured: nothing to enable", "warning");
962
+ return Promise.resolve();
963
+ }
880
964
  service.start();
881
- 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");
882
966
  return Promise.resolve();
883
967
  },
884
968
  });
885
969
 
886
- pi.registerCommand?.("lsp-reload", {
970
+ pi.registerCommand("lsp-reload", {
887
971
  description: "Restart a specific LSP server: /lsp-reload <server-id>",
888
972
  getArgumentCompletions: (prefix) =>
889
- service
890
- .serverIDs()
973
+ (service?.serverIDs() ?? [])
891
974
  .toSorted()
892
975
  .filter((id) => id.startsWith(prefix))
893
976
  .map((id) => ({ value: id, label: id })),
894
977
  handler: async (args, ctx) => {
978
+ if (service === undefined) {
979
+ ctx.ui.notify("LSP not configured: nothing to reload", "warning");
980
+ return;
981
+ }
895
982
  const serverID = args.trim();
896
983
  const known = service.serverIDs().toSorted();
897
984
  if (!serverID) {
898
- ctx.ui.notify?.(
985
+ ctx.ui.notify(
899
986
  `usage: /lsp-reload <server-id>${known.length > 0 ? ` (known: ${known.join(", ")})` : ""}`,
900
987
  "warning",
901
988
  );
902
989
  return;
903
990
  }
904
991
  await service.reload(serverID);
905
- ctx.ui.notify?.(
992
+ ctx.ui.notify(
906
993
  `LSP server "${serverID}" reloaded: will restart on the next tool call`,
907
994
  "info",
908
995
  );
909
996
  },
910
997
  });
911
998
 
912
- return service;
999
+ return {
1000
+ mustLazyGetService: () => service ?? getNoopService(),
1001
+ haveEnabledLsp: () => service !== undefined,
1002
+ };
913
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;
@@ -31,7 +31,7 @@ import { Type } from "typebox";
31
31
 
32
32
  import { appendLspDiagnosticText } from "../lib/lsp/diagnostic.js";
33
33
  import { registerLspInspectTools } from "../lib/lsp/inspect-tool.js";
34
- import { type LspService, registerLsp } from "../lib/lsp/lsp.js";
34
+ import { createLspManager, type LspService, type LspServiceOptions } from "../lib/lsp/lsp.js";
35
35
  import { registerLspRenameTool } from "../lib/lsp/rename-tool.js";
36
36
  import { formatSubtitlePath } from "../lib/path.js";
37
37
  import { guardWriteAccess } from "../lib/write-guard.js";
@@ -105,8 +105,8 @@ const IMAGE_SIGNATURES: {
105
105
  return (
106
106
  startsWithAscii(buf, 0, "BM") &&
107
107
  buf.length >= 30 &&
108
- (buf[28] ?? 0) === 1 &&
109
- [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])
110
110
  );
111
111
  },
112
112
  mimeType: "image/bmp",
@@ -295,7 +295,7 @@ async function formatDirectoryEntries(dirPath: string): Promise<string[]> {
295
295
  return results;
296
296
  }
297
297
 
298
- function registerReadTool(pi: ExtensionAPI, service: LspService): void {
298
+ function registerReadTool(pi: ExtensionAPI, getService: () => LspService): void {
299
299
  pi.registerTool({
300
300
  name: "read",
301
301
  label: "read",
@@ -434,9 +434,11 @@ function registerReadTool(pi: ExtensionAPI, service: LspService): void {
434
434
  content = [{ type: "text", text: outputText }];
435
435
 
436
436
  // opencode: LSP 文件事件通知是后台任务,失败不影响读取(read 不驻留文档)
437
- void service.notifyFile(absolutePath, ctx.cwd).catch(() => {
438
- // 后台通知失败不影响读取
439
- });
437
+ void getService()
438
+ .notifyFile(absolutePath, ctx.cwd)
439
+ .catch(() => {
440
+ // 后台通知失败不影响读取
441
+ });
440
442
 
441
443
  return {
442
444
  content,
@@ -462,7 +464,7 @@ const editSchema = Type.Object({
462
464
  ),
463
465
  });
464
466
 
465
- function registerEditTool(pi: ExtensionAPI, service: LspService): void {
467
+ function registerEditTool(pi: ExtensionAPI, getService: () => LspService): void {
466
468
  pi.registerTool({
467
469
  name: "edit",
468
470
  label: "edit",
@@ -525,7 +527,7 @@ function registerEditTool(pi: ExtensionAPI, service: LspService): void {
525
527
  text: diagnosticText,
526
528
  errorCount,
527
529
  warningCount,
528
- } = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd, { signal });
530
+ } = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, { signal });
529
531
  return [
530
532
  "Edit applied successfully.",
531
533
  { diff: "", patch: "", firstChangedLine: 0 },
@@ -567,7 +569,7 @@ function registerEditTool(pi: ExtensionAPI, service: LspService): void {
567
569
  text: diagnosticText,
568
570
  errorCount,
569
571
  warningCount,
570
- } = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd, { signal });
572
+ } = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, { signal });
571
573
  return [
572
574
  "Edit applied successfully.",
573
575
  { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
@@ -615,7 +617,7 @@ export function resolveBom(
615
617
  return { bom: sourceBom || nextBom, text };
616
618
  }
617
619
 
618
- function registerWriteTool(pi: ExtensionAPI, service: LspService): void {
620
+ function registerWriteTool(pi: ExtensionAPI, getService: () => LspService): void {
619
621
  pi.registerTool({
620
622
  name: "write",
621
623
  label: "write",
@@ -667,7 +669,7 @@ function registerWriteTool(pi: ExtensionAPI, service: LspService): void {
667
669
  text: diagnosticText,
668
670
  errorCount,
669
671
  warningCount,
670
- } = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd, { signal });
672
+ } = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, { signal });
671
673
 
672
674
  return [
673
675
  "Wrote file successfully.",
@@ -694,19 +696,25 @@ function registerWriteTool(pi: ExtensionAPI, service: LspService): void {
694
696
 
695
697
  // ── 入口 ─────────────────────────────────────────────────────────────────────
696
698
 
697
- export function registerFileTools(pi: ExtensionAPI, service: LspService): void {
698
- registerReadTool(pi, service);
699
- registerEditTool(pi, service);
700
- registerWriteTool(pi, service);
701
- // 工具壳与 claude-code 共享(lib/lsp/rename-tool.ts);opencode 不跟踪
702
- // read-before-write 状态,不传 recordReads hook。
703
- registerLspRenameTool(pi, service);
704
- // 只读符号查询工具(find-definition / find-reference / inspect)与 claude-code 共享。
705
- registerLspInspectTools(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 回调注册,不在这里注册。
706
704
  }
707
705
 
708
- /** 独立入口:创建 LSP service(闭包共享给三个工具)并注册。 */
709
- export default function opencodeFileTools(pi: ExtensionAPI): void {
710
- const service = registerLsp(pi);
711
- 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());
712
720
  }
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
  }