@trim21/personal-pi-extensions 0.0.351 → 0.0.353

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.351",
3
+ "version": "0.0.353",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -22,7 +22,12 @@ import {
22
22
  selectCheckboxActions,
23
23
  selectWithOptionalInput,
24
24
  } from "../lib/ui.js";
25
- import { type ApprovalRule, commandPatternsFor, evaluateBashApproval } from "./approval-rules.js";
25
+ import {
26
+ type ApprovalRule,
27
+ commandPatternsFor,
28
+ evaluateBashApproval,
29
+ matchRule,
30
+ } from "./approval-rules.js";
26
31
  import {
27
32
  type BwrapMode,
28
33
  createBwrapBashOperations,
@@ -63,6 +68,19 @@ export const FULL_ACCESS_CHOICES: readonly SelectAction[] = [
63
68
  { label: DENY_WITH_REASON, inputPrompt: "Why was this denied?" },
64
69
  ];
65
70
 
71
+ /**
72
+ * 全权限审批 UI 的决策结果:业务层(execute/approveFullAccess)据此
73
+ * 决定放行、拒绝并持久化勾选的规则,UI 层不直接产生副作用。
74
+ */
75
+ export interface FullAccessUIDecision {
76
+ /** 用户选择的动作 label(ALLOW_ONCE / ALLOW_FOREVER / DENY / DENY_WITH_REASON)。 */
77
+ result: string;
78
+ /** 用户勾选、需持久化为 allow 规则的 pattern;未勾选时为空数组。 */
79
+ foreverApprovedPattern: string[];
80
+ /** DENY_WITH_REASON 时用户输入的理由。 */
81
+ reason?: string;
82
+ }
83
+
66
84
  export interface BwrapExecutionRequest {
67
85
  toolCallId: string;
68
86
  command: string;
@@ -425,10 +443,66 @@ export class BwrapRuntime {
425
443
  ): Promise<void> {
426
444
  const policy = resolveEscalation({ hasUI: ctx.hasUI });
427
445
  if (policy.kind === "deny") throw new Error(policy.reason);
446
+ const decision = await this.approveFullAccessUI(ctx, command, reason, workdir);
447
+ // 关闭对话框 = 中断并拒绝,不循环重问
448
+ if (decision === undefined) {
449
+ ctx.abort();
450
+ throw new Error("User denied the command execution.");
451
+ }
452
+ const { result, foreverApprovedPattern } = decision;
453
+ switch (result) {
454
+ case ALLOW_ONCE: {
455
+ if (foreverApprovedPattern.length > 0) {
456
+ await this.persistAllowRule(ctx, command, foreverApprovedPattern);
457
+ }
458
+ return;
459
+ }
460
+ case ALLOW_FOREVER: {
461
+ await this.persistAllowRule(ctx, command, foreverApprovedPattern);
462
+ return;
463
+ }
464
+ case DENY: {
465
+ if (foreverApprovedPattern.length > 0) {
466
+ await this.persistAllowRule(ctx, command, foreverApprovedPattern);
467
+ }
468
+ throw new Error("User denied unsandboxed execution.");
469
+ }
470
+ case DENY_WITH_REASON: {
471
+ if (foreverApprovedPattern.length > 0) {
472
+ await this.persistAllowRule(ctx, command, foreverApprovedPattern);
473
+ }
474
+ const feedback = decision.reason?.trim() ?? "";
475
+ throw new Error(
476
+ feedback
477
+ ? `User denied unsandboxed execution: ${feedback}`
478
+ : "User denied unsandboxed execution.",
479
+ );
480
+ }
481
+ }
482
+ }
483
+
484
+ /**
485
+ * 全权限审批的 UI 层:弹对话框收集用户决策并返回结构化结果,副作用
486
+ * (abort / throw / 持久化规则)由调用方根据结果处理。
487
+ * 返回 undefined 表示对话框被关闭(用户取消)。
488
+ */
489
+ private async approveFullAccessUI(
490
+ ctx: ExtensionContext,
491
+ command: string,
492
+ reason: string | undefined,
493
+ workdir?: string,
494
+ ): Promise<FullAccessUIDecision | undefined> {
428
495
  // 弹框前解析命令的持久化规则(勾选的 pattern 会写入),在弹框里以
429
496
  // checkbox 列出:`echo 1 | head` → `echo *`、`head *`,逐项决定是否
430
497
  // allow forever,避免用户对"永久允许"持久化什么一无所知。
431
498
  const patterns = await commandPatternsFor(command);
499
+ // checkbox 只列出未命中 allow 规则的 pattern:已提前允许的部分自动放行,
500
+ // 无需再展示或重复勾选持久化(deny 命中的命令在 evaluate 阶段已被拒绝)。
501
+ const rules = this.resolve(ctx).approvalRules;
502
+ const unallowedPatterns = patterns.filter((pattern) => {
503
+ const rule = rules.findLast((r) => matchRule(pattern, r.pattern));
504
+ return rule?.action !== "allow";
505
+ });
432
506
  // dcg 扫描建议是可选的参考文本:未安装时静默跳过;已安装但扫描失败
433
507
  // 时 notify 提示,弹窗本身与无 dcg 时一致
434
508
  const outcome = await dcgSuggestion(command);
@@ -447,7 +521,7 @@ export class BwrapRuntime {
447
521
  if (outcome.kind === "suggestion") {
448
522
  lines.push("", outcome.suggestion.text, "---");
449
523
  }
450
- if (patterns.length > 0) {
524
+ if (unallowedPatterns.length > 0) {
451
525
  lines.push(
452
526
  "",
453
527
  "勾选规则将持久化为允许规则(后续同模式命令自动放行),未勾选规则仅本次处理:",
@@ -468,30 +542,12 @@ export class BwrapRuntime {
468
542
  signal: ctx.signal,
469
543
  });
470
544
  // 关闭对话框 = 中断并拒绝,不循环重问
471
- if (verdict === undefined) {
472
- ctx.abort();
473
- throw new Error("User denied the command execution.");
474
- }
475
- switch (verdict.label) {
476
- case ALLOW_ONCE: {
477
- return;
478
- }
479
- case ALLOW_FOREVER: {
480
- await this.persistAllowRule(ctx, command, patterns);
481
- return;
482
- }
483
- case DENY: {
484
- throw new Error("User denied unsandboxed execution.");
485
- }
486
- case DENY_WITH_REASON: {
487
- const feedback = verdict.input?.trim() ?? "";
488
- throw new Error(
489
- feedback
490
- ? `User denied unsandboxed execution: ${feedback}`
491
- : "User denied unsandboxed execution.",
492
- );
493
- }
494
- }
545
+ if (verdict === undefined) return undefined;
546
+ return {
547
+ result: verdict.label,
548
+ foreverApprovedPattern: [],
549
+ reason: verdict.input,
550
+ };
495
551
  }
496
552
 
497
553
  // 每个识别到的 pattern 一个 checkbox:勾选 = 持久化为 allow 规则。
@@ -508,41 +564,22 @@ export class BwrapRuntime {
508
564
  ] as const satisfies readonly CheckboxAction<"allow-once" | "deny" | "deny-with-reason">[];
509
565
  const verdict = await selectCheckboxActions(
510
566
  description,
511
- [...new Set(patterns)].map((pattern) => ({ label: pattern })),
567
+ [...new Set(unallowedPatterns)].map((pattern) => ({ label: pattern })),
512
568
  actions,
513
569
  ctx.ui,
514
570
  { signal: ctx.signal },
515
571
  );
516
- // 关闭对话框 = 中断并拒绝,不循环重问
517
- if (verdict === undefined) {
518
- ctx.abort();
519
- throw new Error("User denied the command execution.");
520
- }
521
- switch (verdict.action) {
522
- case "allow-once": {
523
- if (verdict.selected.length > 0) {
524
- await this.persistAllowRule(ctx, command, verdict.selected);
525
- }
526
- return;
527
- }
528
- case "deny": {
529
- if (verdict.selected.length > 0) {
530
- await this.persistAllowRule(ctx, command, verdict.selected);
531
- }
532
- throw new Error("User denied unsandboxed execution.");
533
- }
534
- case "deny-with-reason": {
535
- if (verdict.selected.length > 0) {
536
- await this.persistAllowRule(ctx, command, verdict.selected);
537
- }
538
- const feedback = verdict.input?.trim() ?? "";
539
- throw new Error(
540
- feedback
541
- ? `User denied unsandboxed execution: ${feedback}`
542
- : "User denied unsandboxed execution.",
543
- );
544
- }
545
- }
572
+ if (verdict === undefined) return undefined;
573
+ const resultByAction = {
574
+ "allow-once": ALLOW_ONCE,
575
+ deny: DENY,
576
+ "deny-with-reason": DENY_WITH_REASON,
577
+ } as const;
578
+ return {
579
+ result: resultByAction[verdict.action],
580
+ foreverApprovedPattern: verdict.selected,
581
+ reason: verdict.input,
582
+ };
546
583
  }
547
584
 
548
585
  /** 把命令的权限模式写入项目 bwrap.json 的 approvalRules(allow forever)。 */
@@ -392,6 +392,7 @@ export function registerFileTools(
392
392
  throwIfAborted(signal);
393
393
  const diagnosticText = await service.lspDiagnosticsForFile(filePath, ctx.cwd, {
394
394
  notify: (message, level) => ctx.ui.notify(message, level),
395
+ signal,
395
396
  });
396
397
  return [
397
398
  `The file ${filePath} has been updated successfully.`,
@@ -1,35 +1,14 @@
1
1
  /**
2
- * Minimal command-line argument parsing for `/command` handlers.
2
+ * Shell-like tokenizer for `/command` handlers.
3
3
  *
4
4
  * The pi command API passes handlers a raw string (current versions) or a
5
- * token array (newer ones); this helper accepts either and yields
6
- * positionals plus flags, so handlers never care about the input shape.
7
- *
8
- * A raw string is split with shell-like rules first: whitespace separates
9
- * tokens, single quotes preserve everything literally, double quotes allow
10
- * `\"` / `\\` escapes, and a backslash outside quotes escapes the next
11
- * character. Unlike bash, empty tokens are dropped and an unterminated
12
- * quote raises a SyntaxError.
13
- *
14
- * Flags (only long form, `--name`):
15
- * --name value flag "name" = "value" (value may not start with `--`)
16
- * --name=value same
17
- * --flag boolean flag = true
18
- * -- everything after is a positional
5
+ * token array (newer ones); `shlexSplit` turns the raw string into tokens:
6
+ * whitespace separates tokens, single quotes preserve everything literally,
7
+ * double quotes allow `\"` / `\\` escapes, and a backslash outside quotes
8
+ * escapes the next character. Unlike bash, empty tokens are dropped and an
9
+ * unterminated quote raises a SyntaxError.
19
10
  */
20
11
 
21
- export interface ParsedArgs {
22
- /** Non-flag arguments, in order. */
23
- positionals: string[];
24
- /** `--name value` / `--name=value` → string; `--flag` → true. */
25
- flags: Record<string, string | boolean>;
26
- }
27
-
28
- /** Does this token look like a long flag (`--x`, but not the bare `--`)? */
29
- function isFlagToken(token: string): boolean {
30
- return token.startsWith("--") && token !== "--";
31
- }
32
-
33
12
  /** Shell-like tokenizer for a raw command line. Empty tokens are dropped. */
34
13
  export function shlexSplit(raw: string): string[] {
35
14
  const tokens: string[] = [];
@@ -77,40 +56,3 @@ export function shlexSplit(raw: string): string[] {
77
56
  if (cur) tokens.push(cur);
78
57
  return tokens;
79
58
  }
80
-
81
- export function parseArgs(input: string | string[]): ParsedArgs {
82
- const tokens = Array.isArray(input) ? input : shlexSplit(input);
83
- const positionals: string[] = [];
84
- const flags: Record<string, string | boolean> = {};
85
- let positionalOnly = false;
86
- for (let i = 0; i < tokens.length; i++) {
87
- const token = tokens[i];
88
- if (positionalOnly) {
89
- positionals.push(token);
90
- continue;
91
- }
92
- if (token === "--") {
93
- positionalOnly = true;
94
- continue;
95
- }
96
- const m = /^--([a-zA-Z0-9][a-zA-Z0-9-]*)(?:=(.*))?$/.exec(token);
97
- if (!m) {
98
- positionals.push(token);
99
- continue;
100
- }
101
- const name = m[1];
102
- if (m[2] !== undefined) {
103
- flags[name] = m[2];
104
- continue;
105
- }
106
- // `--name value` form: consume the next token unless it is itself a flag.
107
- const next = tokens[i + 1];
108
- if (next !== undefined && !isFlagToken(next)) {
109
- flags[name] = next;
110
- i++;
111
- } else {
112
- flags[name] = true;
113
- }
114
- }
115
- return { positionals, flags };
116
- }
@@ -186,6 +186,20 @@ interface LspState {
186
186
 
187
187
  export interface LspRequestOptions {
188
188
  notify?: ExtensionUIContext["notify"];
189
+ /** 中止时提前结束诊断等待(已中止时直接跳过诊断)。 */
190
+ signal?: AbortSignal;
191
+ }
192
+
193
+ /** 让 promise 在 signal 中止时提前结算;用于中断 LSP 诊断等待。 */
194
+ async function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<void> {
195
+ if (!signal || signal.aborted) {
196
+ await promise;
197
+ return;
198
+ }
199
+ const abort = new Promise<void>((resolve) => {
200
+ signal.addEventListener("abort", () => resolve(), { once: true });
201
+ });
202
+ await Promise.race([promise, abort]);
189
203
  }
190
204
 
191
205
  export interface LspService {
@@ -333,7 +347,10 @@ export function createLspService(
333
347
  const after = Date.now();
334
348
  const version = await client.notify.open({ path: file });
335
349
  if (!diagnostics) return;
336
- await client.waitForDiagnostics({ path: file, version, mode: diagnostics, after });
350
+ await abortable(
351
+ client.waitForDiagnostics({ path: file, version, mode: diagnostics, after }),
352
+ options?.signal,
353
+ );
337
354
  }),
338
355
  ).catch(() => {
339
356
  // 诊断等待失败不影响写操作本身
@@ -360,6 +377,7 @@ export function createLspService(
360
377
  cwd: string,
361
378
  options?: LspRequestOptions,
362
379
  ): Promise<string> {
380
+ if (options?.signal?.aborted) return "";
363
381
  await touchFile(file, cwd, "document", options);
364
382
  const all = await diagnostics();
365
383
  const normalized = normalize(file);
@@ -19,7 +19,7 @@
19
19
 
20
20
  import { constants } from "node:fs";
21
21
  import { access, mkdir, open, readdir, readFile, stat, writeFile } from "node:fs/promises";
22
- import { basename, dirname, isAbsolute, resolve as resolvePath, sep } from "node:path";
22
+ import { basename, dirname, extname, isAbsolute, resolve as resolvePath, sep } from "node:path";
23
23
 
24
24
  import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
25
25
  import {
@@ -154,9 +154,7 @@ async function detectImageMimeTypeFromFile(filePath: string): Promise<string | n
154
154
  }
155
155
 
156
156
  function isBinaryExtension(filePath: string): boolean {
157
- const dotIndex = filePath.lastIndexOf(".");
158
- if (dotIndex === -1) return false;
159
- return BINARY_EXTENSIONS.has(filePath.slice(dotIndex).toLowerCase());
157
+ return BINARY_EXTENSIONS.has(extname(filePath).toLowerCase());
160
158
  }
161
159
 
162
160
  function isBinaryFileBySample(sample: Uint8Array): boolean {
@@ -308,9 +306,7 @@ function registerReadTool(pi: ExtensionAPI, service: LspService): void {
308
306
  ),
309
307
  }),
310
308
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
311
- if (signal?.aborted) {
312
- throw new Error("Operation aborted");
313
- }
309
+ signal?.throwIfAborted();
314
310
 
315
311
  const { filePath: rawPath, offset, limit } = params;
316
312
 
@@ -502,14 +498,10 @@ function registerEditTool(pi: ExtensionAPI, service: LspService): void {
502
498
  change: { oldText: oldString, newText: newString, replaceAll },
503
499
  });
504
500
 
505
- const throwIfAborted = (): void => {
506
- if (signal?.aborted) throw new Error("Operation aborted");
507
- };
508
-
509
501
  const [message, details, diagnosticText] = await withFileMutationQueue(
510
502
  absolutePath,
511
503
  async () => {
512
- throwIfAborted();
504
+ signal?.throwIfAborted();
513
505
 
514
506
  // opencode: 前置校验,先于空 oldString 分支
515
507
  if (oldString === newString) {
@@ -524,7 +516,6 @@ function registerEditTool(pi: ExtensionAPI, service: LspService): void {
524
516
  } catch {
525
517
  exists = false;
526
518
  }
527
- throwIfAborted();
528
519
  if (exists) {
529
520
  throw new Error(
530
521
  "oldString cannot be empty when editing an existing file. Provide the exact text to replace, or use write for an intentional full-file replacement.",
@@ -532,10 +523,11 @@ function registerEditTool(pi: ExtensionAPI, service: LspService): void {
532
523
  }
533
524
  // opencode: writeWithDirs 自动创建父目录;newString 开头的 BOM 原样保留
534
525
  await mkdir(dirname(absolutePath), { recursive: true });
535
- throwIfAborted();
526
+ signal?.throwIfAborted();
536
527
  await writeFile(absolutePath, newString, "utf8");
537
- throwIfAborted();
538
- const diagnosticText = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd);
528
+ const diagnosticText = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd, {
529
+ signal,
530
+ });
539
531
  return [
540
532
  "Edit applied successfully.",
541
533
  { diff: "", patch: "", firstChangedLine: 0 },
@@ -546,18 +538,15 @@ function registerEditTool(pi: ExtensionAPI, service: LspService): void {
546
538
  try {
547
539
  await access(absolutePath, constants.R_OK | constants.W_OK);
548
540
  } catch (error: unknown) {
549
- throwIfAborted();
550
541
  const msg =
551
542
  error instanceof Error && "code" in error && typeof error.code === "string"
552
543
  ? `Error code: ${error.code}`
553
544
  : String(error);
554
545
  throw new Error(`Could not edit file: ${filePath}. ${msg}.`, { cause: error });
555
546
  }
556
- throwIfAborted();
557
547
 
558
548
  const buffer = await readFile(absolutePath);
559
549
  const rawContent = buffer.toString("utf8");
560
- throwIfAborted();
561
550
 
562
551
  // Strip BOM then normalize line endings to LF.
563
552
  // The opencode replacers split on \n and expect only LF.
@@ -566,16 +555,16 @@ function registerEditTool(pi: ExtensionAPI, service: LspService): void {
566
555
  const normalizedContent = normalizeToLF(content);
567
556
 
568
557
  const newContent = replace(normalizedContent, oldString, newString, replaceAll);
569
- throwIfAborted();
558
+ signal?.throwIfAborted();
570
559
 
571
560
  const finalContent = bom + restoreLineEndings(newContent, originalEnding);
572
561
  await writeFile(absolutePath, finalContent, "utf8");
573
- throwIfAborted();
574
562
 
575
563
  const diffResult = generateDiffString(normalizedContent, newContent);
576
564
  const patch = generateUnifiedPatch(filePath, normalizedContent, newContent);
577
- throwIfAborted();
578
- const diagnosticText = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd);
565
+ const diagnosticText = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd, {
566
+ signal,
567
+ });
579
568
  return [
580
569
  "Edit applied successfully.",
581
570
  { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
@@ -640,14 +629,10 @@ function registerWriteTool(pi: ExtensionAPI, service: LspService): void {
640
629
  });
641
630
  const dir = dirname(absolutePath);
642
631
 
643
- const throwIfAborted = () => {
644
- if (signal?.aborted) throw new Error("Operation aborted");
645
- };
646
-
647
632
  const [message, details, diagnosticText] = await withFileMutationQueue(
648
633
  absolutePath,
649
634
  async () => {
650
- throwIfAborted();
635
+ signal?.throwIfAborted();
651
636
 
652
637
  // opencode: desiredBom = source.bom || next.bom —— 保留原文件 BOM,
653
638
  // 否则用新内容自带的 BOM
@@ -664,14 +649,14 @@ function registerWriteTool(pi: ExtensionAPI, service: LspService): void {
664
649
  } catch {
665
650
  // 文件不存在:无旧 BOM
666
651
  }
667
- throwIfAborted();
668
652
  const { bom: desiredBom, text: nextText } = resolveBom(existing, content);
669
653
 
670
654
  await mkdir(dir, { recursive: true });
671
- throwIfAborted();
655
+ signal?.throwIfAborted();
672
656
  await writeFile(absolutePath, desiredBom + nextText, "utf8");
673
- throwIfAborted();
674
- const diagnosticText = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd);
657
+ const diagnosticText = await service.lspDiagnosticsForFile(absolutePath, ctx.cwd, {
658
+ signal,
659
+ });
675
660
 
676
661
  return ["Wrote file successfully.", undefined, diagnosticText] as const;
677
662
  },
package/src/talk/index.ts CHANGED
@@ -25,7 +25,7 @@ import { type TObject, Type } from "typebox";
25
25
  import { type CommandResult, type CommandSpec, parseCommand } from "../lib/cli.js";
26
26
  import { resolveHomePath } from "../lib/path.js";
27
27
  import { restoreTalkAgentId, TALK_JOIN_ENTRY_TYPE, TalkCore } from "./core.js";
28
- import { formatDelivery } from "./format.js";
28
+ import { age, formatDelivery } from "./format.js";
29
29
  import type { Letter } from "./mailbox.js";
30
30
  import { type AgentRecord, deriveAddr } from "./registry.js";
31
31
  import { SqliteTalkStorage } from "./storage.js";
@@ -68,14 +68,6 @@ function shortCwd(cwd: string): string {
68
68
  return sanitizeTerminal(display);
69
69
  }
70
70
 
71
- function relativeTime(ts: number, now: number = Date.now()): string {
72
- const s = Math.max(0, Math.round((now - ts) / 1000));
73
- if (s < 60) return `${s}s ago`;
74
- const m = Math.round(s / 60);
75
- if (m < 60) return `${m}m ago`;
76
- return `${Math.round(m / 60)}h ago`;
77
- }
78
-
79
71
  /** Presentation metadata for a delivery. `details` is never sent to the LLM. */
80
72
  interface DeliveryDetails {
81
73
  id: string;
@@ -483,7 +475,7 @@ export default function talk(pi: ExtensionAPI) {
483
475
  const idTail = d.id.slice(-8);
484
476
  const chip = theme.inverse(` ${d.kind.toUpperCase()} `);
485
477
  const header = `${theme.fg("accent", theme.bold(displayName(d.from.name)))} ${theme.fg("dim", `(${shortCwd(d.from.cwd)})`)} ${chip}`;
486
- const footer = theme.fg("dim", `id ${idTail} · ${d.kind} · ${relativeTime(d.ts)}`);
478
+ const footer = theme.fg("dim", `id ${idTail} · ${d.kind} · ${age(d.ts)}`);
487
479
  const out = [header, sanitizeTerminal(d.body), "", footer];
488
480
  // plain 组件:不引入 pi-tui,直接输出带背景色的文本行
489
481
  const text = out.join("\n");