@trim21/personal-pi-extensions 0.0.421 → 0.0.423

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.0.421",
3
+ "version": "0.0.423",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -1,13 +1,13 @@
1
1
  /**
2
- * approval-rules —— bash 命令审核组件(对齐 opencode 的权限方法):
3
- * 用 tree-sitter 解析命令(含嵌套 `$(...)`),按 BashArity 生成命令模式
4
- * (`git checkout main` → `git checkout *`),再用通配匹配对 allow/deny
5
- * 规则求值。接入 bwrap 的 `dangerouslyDisableSandbox` 审批:命中规则自动
2
+ * approval-rules —— bash 命令匹配引擎(对齐 opencode 的权限方法):
3
+ * 用 tree-sitter 解析命令(含嵌套 `$(...)`),对每条命令的原文做通配
4
+ * 匹配,对 allow/deny 规则求值。规则匹配不经过任何命令归一——BashArity
5
+ * 归一模式只用于 "allow forever" 的建议规则(见 approval-suggest.ts)。
6
+ * 接入 bwrap 的 `dangerouslyDisableSandbox` 审批:命中规则自动
6
7
  * 放行/拒绝,未命中才弹审批对话框。文件输出重定向(`>` / `>>` / `&>`
7
8
  * 等)不会因命令规则自动放行,避免 `echo *` 把 `echo '' > file` 带过。
8
9
  *
9
10
  * 参考实现:
10
- * - opencode packages/opencode/src/permission/arity.ts(BashArity 表)
11
11
  * - opencode packages/core/src/util/wildcard.ts(通配匹配)
12
12
  * - opencode packages/opencode/src/tool/shell.ts(tree-sitter 命令提取)
13
13
  */
@@ -173,34 +173,6 @@ export async function parseBashCommands(command: string): Promise<ParsedBash> {
173
173
  }
174
174
  }
175
175
 
176
- // ── BashArity:命令前缀 → token 数(参考 opencode arity.ts)─────────────────
177
-
178
- /**
179
- * 命令前缀 → 定义该命令的 token 数。`git checkout main` → `git` 的 arity 2,
180
- * 权限模式取前 2 个 token + `*`(`git checkout *`),避免具体参数进规则。
181
- * 表来自 opencode packages/opencode/src/permission/arity.ts(Apache-2.0),
182
- * 数据存放在 arity.json(所有 key 带引号)。
183
- */
184
- const ARITY = JSON.parse(readFileSync(new URL("arity.json", import.meta.url), "utf8")) as Record<
185
- string,
186
- number
187
- >;
188
-
189
- /**
190
- * 生成命令的权限模式:BashArity 前缀 + `*`。
191
- * `git checkout main` → `git checkout *`;未收录的命令 → 命令名 + `*`。
192
- */
193
- export function commandPattern(command: BashCommand): string {
194
- const tokens = [command.name, ...command.args];
195
- for (let len = tokens.length; len > 0; len--) {
196
- const prefix = tokens.slice(0, len).join(" ");
197
- const arity = ARITY[prefix];
198
- if (arity !== undefined) return [...tokens.slice(0, arity), "*"].join(" ");
199
- }
200
- if (tokens.length === 0) return "*";
201
- return [tokens[0], "*"].join(" ");
202
- }
203
-
204
176
  // ── 通配匹配(参考 opencode wildcard.ts)────────────────────────────────────
205
177
 
206
178
  /**
@@ -218,31 +190,15 @@ export function matchRule(input: string, pattern: string): boolean {
218
190
 
219
191
  // ── 规则求值 ────────────────────────────────────────────────────────────────
220
192
 
221
- /**
222
- * 命令(含所有嵌套命令)的权限模式列表(命令替换里的命令也展开)。
223
- * 供规则求值与"allow forever"写规则复用。
224
- */
225
- function patternsFromCommands(commands: BashCommand[]): string[] {
226
- const flat: string[] = [];
227
- const visit = (cmd: BashCommand) => {
228
- flat.push(commandPattern(cmd));
229
- for (const nested of cmd.nested) visit(nested);
230
- };
231
- for (const cmd of commands) visit(cmd);
232
- return flat;
233
- }
234
-
235
- export async function commandPatternsFor(command: string): Promise<string[]> {
236
- const parsed = await parseBashCommands(command);
237
- return patternsFromCommands(parsed.commands);
238
- }
239
-
240
193
  /**
241
194
  * 对命令(含所有嵌套命令)求值:
242
195
  * - deny 优先:任一命令命中 deny 规则即整体拒绝
243
196
  * - 文件输出重定向不自动放行:即使命令规则全匹配,也返回 undefined 交人审
244
197
  * - allow 需全量:所有命令都命中 allow 规则才整体放行,否则返回
245
198
  * undefined(有命令未命中规则,交给人审),避免未允许的命令被同链放行带过。
199
+ * - 匹配输入是命令原文(tree-sitter command 节点 text,含嵌套逐条展开),
200
+ * 对齐 opencode shell.ts 的 patterns;通配规则按字面写,`--` 与普通
201
+ * token 无区别。
246
202
  * 规则内后写优先(findLast,对齐 opencode PermissionV2)。
247
203
  */
248
204
  export async function evaluateBashApproval(
@@ -250,14 +206,19 @@ export async function evaluateBashApproval(
250
206
  rules: readonly ApprovalRule[],
251
207
  ): Promise<ApprovalAction | undefined> {
252
208
  const parsed = await parseBashCommands(command);
253
- const patterns = patternsFromCommands(parsed.commands);
254
- if (patterns.length === 0) return;
209
+ const raws: string[] = [];
210
+ const visit = (cmd: BashCommand) => {
211
+ raws.push(cmd.raw);
212
+ for (const nested of cmd.nested) visit(nested);
213
+ };
214
+ for (const cmd of parsed.commands) visit(cmd);
215
+ if (raws.length === 0) return;
255
216
  let allowed = 0;
256
- for (const pattern of patterns) {
257
- const rule = rules.findLast((r) => matchRule(pattern, r.pattern));
217
+ for (const raw of raws) {
218
+ const rule = rules.findLast((r) => matchRule(raw, r.pattern));
258
219
  if (rule?.action === "deny") return "deny";
259
220
  if (rule?.action === "allow") allowed++;
260
221
  }
261
222
  if (parsed.hasFileOutputRedirect) return;
262
- return allowed === patterns.length ? "allow" : undefined;
223
+ return allowed === raws.length ? "allow" : undefined;
263
224
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * approval-suggest —— "allow forever" 的建议规则生成:
3
+ * 用 BashArity 表把命令归一成模式(`git checkout main` → `git checkout *`),
4
+ * 供用户选择"永久允许"时写入配置。归一模式不参与规则判定——匹配引擎
5
+ * (approval-rules)按命令原文做通配匹配。
6
+ *
7
+ * 参考实现:opencode packages/opencode/src/permission/arity.ts(Apache-2.0)
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+
11
+ import { type BashCommand, parseBashCommands } from "./approval-rules.js";
12
+
13
+ /**
14
+ * 命令前缀 → 定义该命令的 token 数。`git checkout main` → `git` 的 arity 2,
15
+ * 权限模式取前 2 个 token + `*`(`git checkout *`),避免具体参数进规则。
16
+ * 表来自 opencode packages/opencode/src/permission/arity.ts(Apache-2.0),
17
+ * 数据存放在 arity.json(所有 key 带引号)。
18
+ */
19
+ const ARITY = JSON.parse(readFileSync(new URL("arity.json", import.meta.url), "utf8")) as Record<
20
+ string,
21
+ number
22
+ >;
23
+
24
+ /**
25
+ * 生成命令的 BashArity 建议模式:BashArity 前缀 + `*`。
26
+ * `git checkout main` → `git checkout *`;未收录的命令 → 命令名 + `*`。
27
+ */
28
+ export function commandPattern(command: BashCommand): string {
29
+ const tokens = [command.name, ...command.args];
30
+ for (let len = tokens.length; len > 0; len--) {
31
+ const prefix = tokens.slice(0, len).join(" ");
32
+ const arity = ARITY[prefix];
33
+ if (arity !== undefined) return [...tokens.slice(0, arity), "*"].join(" ");
34
+ }
35
+ if (tokens.length === 0) return "*";
36
+ return [tokens[0], "*"].join(" ");
37
+ }
38
+
39
+ /** 命令(含所有嵌套命令)的 BashArity 建议模式列表(命令替换里的命令也展开)。 */
40
+ function patternsFromCommands(commands: BashCommand[]): string[] {
41
+ const flat: string[] = [];
42
+ const visit = (cmd: BashCommand) => {
43
+ flat.push(commandPattern(cmd));
44
+ for (const nested of cmd.nested) visit(nested);
45
+ };
46
+ for (const cmd of commands) visit(cmd);
47
+ return flat;
48
+ }
49
+
50
+ export async function commandPatternsFor(command: string): Promise<string[]> {
51
+ const parsed = await parseBashCommands(command);
52
+ return patternsFromCommands(parsed.commands);
53
+ }
@@ -23,12 +23,8 @@ import {
23
23
  selectCheckboxActions,
24
24
  selectWithOptionalInput,
25
25
  } from "../lib/ui.js";
26
- import {
27
- type ApprovalRule,
28
- commandPatternsFor,
29
- evaluateBashApproval,
30
- matchRule,
31
- } from "./approval-rules.js";
26
+ import { type ApprovalRule, evaluateBashApproval, matchRule } from "./approval-rules.js";
27
+ import { commandPatternsFor } from "./approval-suggest.js";
32
28
  import {
33
29
  type BwrapMode,
34
30
  createBwrapBashOperations,
@@ -175,6 +175,8 @@ interface LspState {
175
175
  broken: Set<string>;
176
176
  spawning: Map<string, Promise<LspClient | undefined>>;
177
177
  closing: boolean;
178
+ /** /lsp-stop 置 true:所有工具调用不再 spawn 服务器,直到 start/reload。 */
179
+ disabled: boolean;
178
180
  /** root+serverID → 服务器状态,用于 footer status 显示。 */
179
181
  servers: Map<string, { serverID: string; root: string; state: "running" | "broken" }>;
180
182
  }
@@ -214,6 +216,17 @@ export interface LspService {
214
216
  options?: LspRequestOptions,
215
217
  ): Promise<{ text: string; errorCount: number; warningCount: number }>;
216
218
  shutdownAll(): Promise<void>;
219
+ /** 停止全部服务器并禁用 LSP:之后工具调用不再 spawn,直到 start/reload。 */
220
+ stop(): Promise<void>;
221
+ /** 解除禁用并清空 broken 缓存;服务器在下次工具调用时惰性启动。 */
222
+ start(): void;
223
+ /**
224
+ * 重启指定服务器:关闭其全部 client、清除对应 broken 记录并解除禁用;
225
+ * 其余服务器不受影响。配置在下次工具调用时重新读取。
226
+ */
227
+ reload(serverID: string): Promise<void>;
228
+ /** 已知服务器 id(running 或 broken 的去重集合),供命令补全与提示。 */
229
+ serverIDs(): string[];
217
230
  /** 注入 status 渲染回调;传入 undefined 表示不再渲染。 */
218
231
  attachStatus(render: StatusRenderer | undefined): void;
219
232
  /** 用当前服务器状态主动刷新一次 status(agent start/end 等生命周期边界)。 */
@@ -247,6 +260,7 @@ export function createLspService(
247
260
  broken: new Set(),
248
261
  spawning: new Map(),
249
262
  closing: false,
263
+ disabled: false,
250
264
  servers: new Map(),
251
265
  };
252
266
 
@@ -255,6 +269,10 @@ export function createLspService(
255
269
  /** 汇总当前所有 LSP server 状态并渲染到 footer status。 */
256
270
  function updateStatusText(): void {
257
271
  if (!renderStatus) return;
272
+ if (state.disabled) {
273
+ renderStatus("lsp: disabled");
274
+ return;
275
+ }
258
276
  if (state.servers.size === 0) {
259
277
  renderStatus(undefined);
260
278
  return;
@@ -275,7 +293,7 @@ export function createLspService(
275
293
  cwd: string,
276
294
  notify?: ExtensionUIContext["notify"],
277
295
  ): Promise<LspClient[]> {
278
- if (state.closing) return [];
296
+ if (state.closing || state.disabled) return [];
279
297
  if (!containsPath(file, cwd)) return [];
280
298
  const config = await loadLspConfig(cwd, globalConfigPath);
281
299
  const timeout = timeoutOptions(config);
@@ -326,7 +344,7 @@ export function createLspService(
326
344
  diagnosticsDocumentWaitTimeoutMs:
327
345
  adapter.diagnosticsWaitMs ?? timeout.diagnosticsDocumentWaitTimeoutMs,
328
346
  });
329
- if (state.closing) {
347
+ if (state.closing || state.disabled) {
330
348
  await client.shutdown();
331
349
  return;
332
350
  }
@@ -426,9 +444,8 @@ export function createLspService(
426
444
  return { text: report(normalized, issues), errorCount, warningCount };
427
445
  }
428
446
 
429
- /** 终止全部服务器进程(session_shutdown 时调用)。 */
430
- async function shutdownAll(): Promise<void> {
431
- if (state.closing) return;
447
+ /** 关闭全部 client 并清空缓存;closing 置 true 让 in-flight spawn 自行退出。 */
448
+ async function closeAll(): Promise<void> {
432
449
  state.closing = true;
433
450
  await Promise.all(state.clients.map((client) => client.shutdown())).catch(() => {
434
451
  // 个别进程退出失败不阻止清理流程
@@ -439,11 +456,55 @@ export function createLspService(
439
456
  updateStatusText();
440
457
  }
441
458
 
459
+ /** 终止全部服务器进程(session_shutdown 时调用,终态)。 */
460
+ async function shutdownAll(): Promise<void> {
461
+ await closeAll();
462
+ }
463
+
464
+ async function stop(): Promise<void> {
465
+ await closeAll();
466
+ state.closing = false;
467
+ state.disabled = true;
468
+ updateStatusText();
469
+ }
470
+
471
+ function start(): void {
472
+ state.closing = false;
473
+ state.disabled = false;
474
+ state.broken.clear();
475
+ updateStatusText();
476
+ }
477
+
478
+ async function reload(serverID: string): Promise<void> {
479
+ state.closing = true;
480
+ const targets = state.clients.filter((client) => client.serverID === serverID);
481
+ await Promise.all(targets.map((client) => client.shutdown())).catch(() => {
482
+ // 个别进程退出失败不阻止清理流程
483
+ });
484
+ state.clients = state.clients.filter((client) => client.serverID !== serverID);
485
+ for (const [key, server] of state.servers) {
486
+ if (server.serverID !== serverID) continue;
487
+ state.broken.delete(key);
488
+ state.servers.delete(key);
489
+ }
490
+ state.closing = false;
491
+ state.disabled = false;
492
+ updateStatusText();
493
+ }
494
+
495
+ function serverIDs(): string[] {
496
+ return [...new Set([...state.servers.values()].map((server) => server.serverID))];
497
+ }
498
+
442
499
  return {
443
500
  touchFile,
444
501
  diagnostics,
445
502
  lspDiagnosticsForFile,
446
503
  shutdownAll,
504
+ stop,
505
+ start,
506
+ reload,
507
+ serverIDs,
447
508
  attachStatus,
448
509
  refreshStatus: updateStatusText,
449
510
  };
@@ -476,5 +537,49 @@ export function registerLsp(pi: ExtensionAPI, options?: LspServiceOptions): LspS
476
537
  // spawn(首次工具调用),start/end 时保证 footer 反映当前实际状态。
477
538
  pi.on?.("agent_start", () => service.refreshStatus());
478
539
  pi.on?.("agent_end", () => service.refreshStatus());
540
+
541
+ pi.registerCommand?.("lsp-stop", {
542
+ description: "Stop all LSP servers and disable LSP until /lsp-start or /lsp-reload",
543
+ handler: async (_args, ctx) => {
544
+ await service.stop();
545
+ ctx.ui.notify?.("LSP disabled: all servers stopped", "info");
546
+ },
547
+ });
548
+
549
+ pi.registerCommand?.("lsp-start", {
550
+ description: "Re-enable LSP; servers start on the next tool call",
551
+ handler: (_args, ctx) => {
552
+ service.start();
553
+ ctx.ui.notify?.("LSP enabled: servers will start on the next tool call", "info");
554
+ return Promise.resolve();
555
+ },
556
+ });
557
+
558
+ pi.registerCommand?.("lsp-reload", {
559
+ description: "Restart a specific LSP server: /lsp-reload <server-id>",
560
+ getArgumentCompletions: (prefix) =>
561
+ service
562
+ .serverIDs()
563
+ .toSorted()
564
+ .filter((id) => id.startsWith(prefix))
565
+ .map((id) => ({ value: id, label: id })),
566
+ handler: async (args, ctx) => {
567
+ const serverID = args.trim();
568
+ const known = service.serverIDs().toSorted();
569
+ if (!serverID) {
570
+ ctx.ui.notify?.(
571
+ `usage: /lsp-reload <server-id>${known.length > 0 ? ` (known: ${known.join(", ")})` : ""}`,
572
+ "warning",
573
+ );
574
+ return;
575
+ }
576
+ await service.reload(serverID);
577
+ ctx.ui.notify?.(
578
+ `LSP server "${serverID}" reloaded: will restart on the next tool call`,
579
+ "info",
580
+ );
581
+ },
582
+ });
583
+
479
584
  return service;
480
585
  }