@trim21/personal-pi-extensions 0.0.271 → 0.0.273

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.271",
3
+ "version": "0.0.273",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -224,6 +224,12 @@ export class BwrapRuntime {
224
224
  this.sandboxDisabled = pi.getFlag("no-bwrap") === true && ctx.hasUI;
225
225
  this.resolved = undefined;
226
226
  this.bwrapUnavailable = false;
227
+ if (process.platform === "win32") {
228
+ // Windows 没有 bubblewrap:不做 bwrap 检测、不显示 bwrap 状态,
229
+ // 每条 bash 命令在 execute 时逐条人工审批,模型无需知道 bwrap 的存在。
230
+ ctx.ui.notify("Every bash command requires user approval before it runs.", "info");
231
+ return;
232
+ }
227
233
  const runtime = this.resolve(ctx);
228
234
  if (runtime.bwrapEnabled) {
229
235
  try {
@@ -254,12 +260,18 @@ export class BwrapRuntime {
254
260
 
255
261
  pi.on("before_agent_start", (event, ctx) => {
256
262
  const runtime = this.resolve(ctx);
263
+ const isWindows = process.platform === "win32";
257
264
  const modeText = ctx.hasUI
258
- ? `Current bwrap mode: **${runtime.mode}**. The bwrap runtime selects sandboxing and, when requested, user approval for unsandboxed execution.`
259
- : "This headless session is forced into bwrap readonly mode. Unsandboxed execution cannot be approved.";
260
- const unavailableText = this.bwrapUnavailable
261
- ? " bwrap is unavailable (binary not found): bash commands are refused unless the user explicitly approves unsandboxed execution."
262
- : "";
265
+ ? isWindows
266
+ ? "Every bash command requires user approval before it runs."
267
+ : `Current bwrap mode: **${runtime.mode}**. The bwrap runtime selects sandboxing and, when requested, user approval for unsandboxed execution.`
268
+ : isWindows
269
+ ? "This headless session cannot approve commands: bash commands are refused."
270
+ : "This headless session is forced into bwrap readonly mode. Unsandboxed execution cannot be approved.";
271
+ const unavailableText =
272
+ !isWindows && this.bwrapUnavailable
273
+ ? " bwrap is unavailable (binary not found): bash commands are refused unless the user explicitly approves unsandboxed execution."
274
+ : "";
263
275
  return {
264
276
  systemPrompt:
265
277
  event.systemPrompt + `\n\n## Command Execution\n${modeText}${unavailableText}\n`,
@@ -283,13 +295,24 @@ export class BwrapRuntime {
283
295
 
284
296
  async execute(request: BwrapExecutionRequest): Promise<BwrapExecutionResult> {
285
297
  const runtime = this.resolve(request.ctx);
286
- if (this.bwrapUnavailable && runtime.bwrapEnabled && request.requestFullAccess !== true) {
298
+ const isWindows = process.platform === "win32";
299
+ // 非 Windows:bwrap 缺失时 fail closed,普通命令一律拒绝(除非显式 full-access 审批)。
300
+ // Windows:没有 bubblewrap,这是预期状态,降级为每条命令都走人工审核。
301
+ if (
302
+ !isWindows &&
303
+ this.bwrapUnavailable &&
304
+ runtime.bwrapEnabled &&
305
+ request.requestFullAccess !== true
306
+ ) {
287
307
  throw new Error(
288
308
  "bwrap (bubblewrap) not found; refusing to execute commands without sandboxing. " +
289
309
  "Install bubblewrap and restart the session, or pass --no-bwrap to disable the sandbox explicitly.",
290
310
  );
291
311
  }
292
- if (request.requestFullAccess === true && runtime.bwrapEnabled) {
312
+ // 需要人工审批:非 Windows requestFullAccess;Windows 上默认所有命令
313
+ // (allow-all 模式是显式 opt-out,仍直接执行)。
314
+ const needsApproval = request.requestFullAccess === true || (isWindows && runtime.bwrapEnabled);
315
+ if (needsApproval && runtime.bwrapEnabled) {
293
316
  // 先按 approvalRules 自动判定:allow 直接放行,deny 直接拒绝,未命中才弹框
294
317
  const decision = await evaluateBashApproval(request.command, runtime.approvalRules);
295
318
  if (decision === "deny") {
@@ -300,9 +323,9 @@ export class BwrapRuntime {
300
323
  }
301
324
  }
302
325
  const operations =
303
- runtime.bwrapEnabled && request.requestFullAccess !== true
304
- ? createBwrapBashOperations(runtime)
305
- : createLocalBashOperations();
326
+ isWindows || needsApproval || !runtime.bwrapEnabled
327
+ ? createLocalBashOperations()
328
+ : createBwrapBashOperations(runtime);
306
329
  const output = new BashOutput();
307
330
  const { onUpdate } = request;
308
331
 
@@ -389,15 +412,36 @@ export class BwrapRuntime {
389
412
  ): Promise<void> {
390
413
  const policy = resolveEscalation({ hasUI: ctx.hasUI });
391
414
  if (policy.kind === "deny") throw new Error(policy.reason);
415
+ // 弹框前解析命令的持久化规则(Allow forever 会写入),在弹框里预览给
416
+ // 用户:`echo 1` → `echo *`,避免用户对"永久允许"持久化什么一无所知。
417
+ const patterns = await commandPatternsFor(command);
392
418
  // dcg 扫描建议是可选的参考文本:未安装时静默跳过;已安装但扫描失败
393
419
  // 时 notify 提示,弹窗本身与无 dcg 时一致
394
420
  const outcome = await dcgSuggestion(command);
395
- const suggestionBlock =
396
- outcome.kind === "suggestion" ? `\n${outcome.suggestion.text}\n---\n` : "";
397
421
  if (outcome.kind === "failed") {
398
422
  ctx.ui.notify(`dcg 扫描失败,本次无破坏性命令建议: ${outcome.detail}`, "warning");
399
423
  }
400
- const description = `Allow this command to run without sandbox?\n---\n\nReason: ${escapeHtml(reason ?? "(No reason provided by model)")}\n---\n${suggestionBlock}${fenceCodeBlock(command)}`;
424
+ // 弹框主体按行组织('\n' join),便于 review;suggestion forever 预览
425
+ // 块带前导空行 + 尾部 "---" 分隔,输出与历史逐字符一致。
426
+ const lines: string[] = [
427
+ "Allow this command to run without sandbox?",
428
+ "---",
429
+ "",
430
+ `Reason: ${escapeHtml(reason ?? "(No reason provided by model)")}`,
431
+ "---",
432
+ ];
433
+ if (outcome.kind === "suggestion") {
434
+ lines.push("", outcome.suggestion.text, "---");
435
+ }
436
+ if (patterns.length > 0) {
437
+ lines.push(
438
+ "",
439
+ `Allow forever 将持久化规则: ${patterns.map((pattern) => `\`${pattern}\``).join(", ")}`,
440
+ "---",
441
+ );
442
+ }
443
+ lines.push(fenceCodeBlock(command));
444
+ const description = lines.join("\n");
401
445
 
402
446
  // 单选:允许一次 / 永久允许(写入规则)/ 拒绝 / 拒绝并附理由(弹输入框)
403
447
  const verdict = await selectWithOptionalInput(description, FULL_ACCESS_CHOICES, ctx.ui, {
@@ -413,7 +457,7 @@ export class BwrapRuntime {
413
457
  return;
414
458
  }
415
459
  case ALLOW_FOREVER: {
416
- await this.persistAllowRule(ctx, command);
460
+ await this.persistAllowRule(ctx, command, patterns);
417
461
  return;
418
462
  }
419
463
  case DENY: {
@@ -431,10 +475,14 @@ export class BwrapRuntime {
431
475
  }
432
476
 
433
477
  /** 把命令的权限模式写入项目 bwrap.json 的 approvalRules(allow forever)。 */
434
- private async persistAllowRule(ctx: ExtensionContext, command: string): Promise<void> {
435
- const patterns = await commandPatternsFor(command);
436
- if (patterns.length === 0) return; // 解析失败:本次放行,不写规则
437
- const newRules: ApprovalRule[] = patterns.map((pattern) => ({ action: "allow", pattern }));
478
+ private async persistAllowRule(
479
+ ctx: ExtensionContext,
480
+ command: string,
481
+ patterns?: string[],
482
+ ): Promise<void> {
483
+ const rulePatterns = patterns ?? (await commandPatternsFor(command));
484
+ if (rulePatterns.length === 0) return; // 解析失败:本次放行,不写规则
485
+ const newRules: ApprovalRule[] = rulePatterns.map((pattern) => ({ action: "allow", pattern }));
438
486
  const { project } = getBwrapConfigPaths(ctx.cwd);
439
487
  let config: Record<string, unknown> = {};
440
488
  if (existsSync(project)) {
@@ -34,10 +34,12 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
34
34
  import { homedir } from "node:os";
35
35
  import { delimiter, dirname, join, resolve } from "node:path";
36
36
 
37
- import { type ExtensionAPI, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
37
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
38
38
  import { Type } from "typebox";
39
39
  import { Value } from "typebox/value";
40
40
 
41
+ import { createSeqState } from "./lib/seq-state.js";
42
+
41
43
  interface GhResult {
42
44
  stdout: string;
43
45
  stderr: string;
@@ -380,8 +382,9 @@ export function stepsDetail(
380
382
  }));
381
383
  }
382
384
 
383
- /** In-flight dedup map to avoid concurrent fetches of the same log. */
384
- const inflightLogs = new Map<string, Promise<string>>();
385
+ // 模块级串行状态:同一资源(如 CI 日志)的请求排队执行,配合函数内部的
386
+ // 缓存检查避免重复网络请求。闭包状态不与其他扩展共享,key 无需全局前缀。
387
+ const seq = createSeqState();
385
388
 
386
389
  async function getJobLog(
387
390
  runId: string,
@@ -393,13 +396,10 @@ async function getJobLog(
393
396
  ): Promise<string> {
394
397
  const cacheDir = join(homedir(), ".cache", "pi", "ci-logs", runId);
395
398
  const cacheFile = join(cacheDir, `${jobId}.log`);
396
- const key = `${runId}:${jobId}`;
397
-
398
- // Check in-flight dedup map
399
- const inflight = inflightLogs.get(key);
400
- if (inflight) return inflight;
401
399
 
402
- const fetchAndCache = async (): Promise<string> => {
400
+ // 同一 runId:jobId 的请求串行执行:后一个进入时缓存已写入,直接命中缓存,
401
+ // 不会重复发网络请求;串行也保证不会有两个并发写同一 cache 文件。
402
+ return seq.execute(`${runId}:${jobId}`, async () => {
403
403
  // Check file cache
404
404
  try {
405
405
  return await readFile(cacheFile, "utf8");
@@ -424,20 +424,10 @@ async function getJobLog(
424
424
 
425
425
  // Write to cache
426
426
  await mkdir(cacheDir, { recursive: true });
427
- await withFileMutationQueue(cacheFile, async () => {
428
- await writeFile(cacheFile, log);
429
- });
427
+ await writeFile(cacheFile, log);
430
428
 
431
429
  return log;
432
- };
433
-
434
- const promise = fetchAndCache();
435
- inflightLogs.set(key, promise);
436
- try {
437
- return await promise;
438
- } finally {
439
- inflightLogs.delete(key);
440
- }
430
+ });
441
431
  }
442
432
 
443
433
  async function resolveRepo(
@@ -1061,9 +1051,7 @@ export async function writeLogFile(
1061
1051
 
1062
1052
  const target = resolve(cwd ?? process.cwd(), outputFile);
1063
1053
  await mkdir(dirname(target), { recursive: true });
1064
- await withFileMutationQueue(target, async () => {
1065
- await writeFile(target, content);
1066
- });
1054
+ await writeFile(target, content);
1067
1055
 
1068
1056
  const lines = content.split("\n").length;
1069
1057
  const bytes = Buffer.byteLength(content, "utf8");
@@ -1098,6 +1086,15 @@ export async function writeLogFile(
1098
1086
  // ── tools ────────────────────────────────────────────────────────────────────
1099
1087
 
1100
1088
  export default function ghReadonlyTools(pi: ExtensionAPI) {
1089
+ // Windows 上禁用:gh 可执行文件的探测(无扩展名 + POSIX 路径)与进程
1090
+ // 管理(SIGTERM 信号语义)都是 POSIX 假设,不做 Windows 适配。
1091
+ if (process.platform === "win32") {
1092
+ pi.on("session_start", (_event, ctx) => {
1093
+ ctx.ui.notify("gh-readonly tools are disabled on Windows.", "warning");
1094
+ });
1095
+ return;
1096
+ }
1097
+
1101
1098
  // Fail fast: the `gh` CLI is the only backend for these tools. Without it the
1102
1099
  // extension registers nothing and reports the problem at session start, so
1103
1100
  // the user gets one clear error instead of a dozen failing tool calls.
@@ -0,0 +1,34 @@
1
+ /**
2
+ * 按 key 串行化并发任务:同一 key 的调用排队执行,前一个 settle(无论成败)
3
+ * 后才执行下一个。key 由调用方自定义(如资源 id),每个 `createSeqState()`
4
+ * 返回独立的闭包状态,不同扩展之间不串扰。
5
+ */
6
+
7
+ export interface SeqState {
8
+ execute<T>(key: string, fn: () => Promise<T>): Promise<T>;
9
+ }
10
+
11
+ export function createSeqState(): SeqState {
12
+ const tails = new Map<string, Promise<unknown>>();
13
+
14
+ return {
15
+ execute<T>(key: string, fn: () => Promise<T>): Promise<T> {
16
+ const previous = tails.get(key) ?? Promise.resolve();
17
+
18
+ // 等前一个任务 settle 后执行本任务。previous 恒为 resolve(tail 吞掉了错误),
19
+ // 失败不会阻塞后续任务。
20
+ const run = previous.then(() => fn());
21
+
22
+ // 本任务的完成占位:永远 resolve,作为下一个任务的等待点。
23
+ // eslint-disable-next-line unicorn/no-useless-undefined -- 显式返回 undefined:吞掉前序错误并归一化为成功占位
24
+ const tail = run.catch(() => undefined);
25
+
26
+ tails.set(key, tail);
27
+ void tail.finally(() => {
28
+ if (tails.get(key) === tail) tails.delete(key);
29
+ });
30
+
31
+ return run;
32
+ },
33
+ };
34
+ }
@@ -6,6 +6,8 @@
6
6
  * - Paths outside require user approval via a confirmation dialog showing a
7
7
  * `diff` code block preview of the pending change.
8
8
  * - Headless sessions (no UI) reject outside writes outright.
9
+ * - Windows (no sandbox fallback): writes are restricted to the workspace;
10
+ * outside paths are rejected outright, with no approval path.
9
11
  *
10
12
  * Callers have already parsed their tool arguments, so the guard only takes the
11
13
  * resolved pieces: the raw target path and the pending change (oldText/newText).
@@ -122,6 +124,14 @@ export async function guardWriteAccess(
122
124
  const { absolutePath } = opts;
123
125
  if (isPathAllowed(absolutePath, ctx.cwd)) return;
124
126
 
127
+ if (process.platform === "win32") {
128
+ // Windows 上退化为「只能写工作区」:无沙箱兜底,工作区外写入一律拒绝,
129
+ // 不提供审批路径。
130
+ throw new Error(
131
+ `Path "${absolutePath}" is outside workspace. Writes outside the workspace are not allowed on Windows.`,
132
+ );
133
+ }
134
+
125
135
  if (!ctx.hasUI || !ctx.ui) {
126
136
  throw new Error(`Path "${absolutePath}" is outside workspace. No UI available for approval.`);
127
137
  }
@@ -544,6 +544,16 @@ export function formatAgentListSection(agents: AgentConfig[]): string {
544
544
  // ── extension ────────────────────────────────────────────────────────────────
545
545
 
546
546
  export default function spawnAgent(pi: ExtensionAPI) {
547
+ // Windows 上禁用:子代理进程的派生(node/bun 运行时下回退到 `pi` 命令,
548
+ // 而 npm 安装的 pi 是 .cmd shim,spawn 无法直接启动)与信号管理都是
549
+ // POSIX 假设,不做 Windows 适配。
550
+ if (process.platform === "win32") {
551
+ pi.on("session_start", (_event, ctx) => {
552
+ ctx.ui.notify("spawn-agent is disabled on Windows.", "warning");
553
+ });
554
+ return;
555
+ }
556
+
547
557
  // Discover the available subagent types once at extension startup. The
548
558
  // extension owns this discovery: the model never has to guess agent names
549
559
  // or read the agent directory itself. Editing ~/.pi/agent/agents/*.md