@trim21/personal-pi-extensions 0.1.529 → 0.1.531
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 +1 -1
- package/src/bwrap/runtime.ts +61 -78
- package/src/lib/ui.ts +0 -51
- package/src/spawn-agent.ts +25 -6
package/package.json
CHANGED
package/src/bwrap/runtime.ts
CHANGED
|
@@ -18,12 +18,7 @@ import { type TObject, Type } from "typebox";
|
|
|
18
18
|
import { type CommandSpec, parseCommand } from "../lib/cli.js";
|
|
19
19
|
import { fenceCodeBlock } from "../lib/markdown.js";
|
|
20
20
|
import { formatDisplayPath } from "../lib/path.js";
|
|
21
|
-
import {
|
|
22
|
-
type CheckboxAction,
|
|
23
|
-
type SelectAction,
|
|
24
|
-
selectCheckboxActions,
|
|
25
|
-
selectWithOptionalInput,
|
|
26
|
-
} from "../lib/ui.js";
|
|
21
|
+
import { type SelectAction, selectMultiple, selectWithOptionalInput } from "../lib/ui.js";
|
|
27
22
|
import { type ApprovalRule, evaluateBashApproval, matchRule } from "./approval-rules.js";
|
|
28
23
|
import { commandPatternsFor } from "./approval-suggest.js";
|
|
29
24
|
import {
|
|
@@ -55,24 +50,19 @@ export function resolveEscalation(opts: { hasUI: boolean }): EscalationDecision
|
|
|
55
50
|
|
|
56
51
|
/** 全权限审批对话框的选项 label(也作为 switch 匹配键与测试引用)。 */
|
|
57
52
|
export const ALLOW_ONCE = "Allow once";
|
|
58
|
-
export const ALLOW_FOREVER = "Allow forever";
|
|
59
53
|
export const DENY = "Deny";
|
|
60
54
|
export const DENY_WITH_REASON = "Deny with reason";
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
{ label: ALLOW_FOREVER },
|
|
66
|
-
{ label: DENY },
|
|
67
|
-
{ label: DENY_WITH_REASON, inputPrompt: "Why was this denied?" },
|
|
68
|
-
];
|
|
55
|
+
/** 第一层的折叠入口:进入按 pattern 勾选持久化规则的子菜单。 */
|
|
56
|
+
export const EDIT_RULES = "Edit approval rules";
|
|
57
|
+
/** 规则子菜单的返回项:结束勾选,回到第一层做放行/拒绝决策。 */
|
|
58
|
+
export const BACK = "Back";
|
|
69
59
|
|
|
70
60
|
/**
|
|
71
61
|
* 全权限审批 UI 的决策结果:业务层(execute/approveFullAccess)据此
|
|
72
62
|
* 决定放行、拒绝并持久化勾选的规则,UI 层不直接产生副作用。
|
|
73
63
|
*/
|
|
74
64
|
export interface FullAccessUIDecision {
|
|
75
|
-
/** 用户选择的动作 label(ALLOW_ONCE /
|
|
65
|
+
/** 用户选择的动作 label(ALLOW_ONCE / DENY / DENY_WITH_REASON)。 */
|
|
76
66
|
result: string;
|
|
77
67
|
/** 用户勾选、需持久化为 allow 规则的 pattern;未勾选时为空数组。 */
|
|
78
68
|
foreverApprovedPattern: string[];
|
|
@@ -511,10 +501,6 @@ export class BwrapRuntime {
|
|
|
511
501
|
}
|
|
512
502
|
return;
|
|
513
503
|
}
|
|
514
|
-
case ALLOW_FOREVER: {
|
|
515
|
-
await this.persistAllowRule(ctx, command, foreverApprovedPattern);
|
|
516
|
-
return;
|
|
517
|
-
}
|
|
518
504
|
case DENY: {
|
|
519
505
|
if (foreverApprovedPattern.length > 0) {
|
|
520
506
|
await this.persistAllowRule(ctx, command, foreverApprovedPattern);
|
|
@@ -546,25 +532,29 @@ export class BwrapRuntime {
|
|
|
546
532
|
reason: string | undefined,
|
|
547
533
|
execCwd: string,
|
|
548
534
|
): Promise<FullAccessUIDecision | undefined> {
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
//
|
|
535
|
+
// 弹框前解析命令的持久化规则:`echo 1 | head` → `echo *`、`head *`。
|
|
536
|
+
// 持久化规则的勾选折叠进 EDIT_RULES 子菜单,主决策列表只保留放行/拒绝,
|
|
537
|
+
// 避免一屏 checkbox 淹没决策项。
|
|
552
538
|
const patterns = await commandPatternsFor(command);
|
|
553
|
-
//
|
|
539
|
+
// 子菜单只列出未命中 allow 规则的 pattern:已提前允许的部分自动放行,
|
|
554
540
|
// 无需再展示或重复勾选持久化(deny 命中的命令在 evaluate 阶段已被拒绝)。
|
|
555
541
|
const rules = this.resolve(ctx).approvalRules;
|
|
556
|
-
const unallowedPatterns =
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
542
|
+
const unallowedPatterns = [
|
|
543
|
+
...new Set(
|
|
544
|
+
patterns.filter((pattern) => {
|
|
545
|
+
const rule = rules.findLast((r) => matchRule(pattern, r.pattern));
|
|
546
|
+
return rule?.action !== "allow";
|
|
547
|
+
}),
|
|
548
|
+
),
|
|
549
|
+
];
|
|
560
550
|
// dcg 扫描建议是可选的参考文本:未安装时静默跳过;已安装但扫描失败
|
|
561
551
|
// 时 notify 提示,弹窗本身与无 dcg 时一致
|
|
562
552
|
const outcome = await dcgSuggestion(command);
|
|
563
553
|
if (outcome.kind === "failed") {
|
|
564
554
|
ctx.ui.notify(`dcg 扫描失败,本次无破坏性命令建议: ${outcome.detail}`, "warning");
|
|
565
555
|
}
|
|
566
|
-
// 弹框主体按行组织('\n' join),便于 review;suggestion
|
|
567
|
-
//
|
|
556
|
+
// 弹框主体按行组织('\n' join),便于 review;suggestion 块带前导空行 +
|
|
557
|
+
// 尾部 "---" 分隔。
|
|
568
558
|
const lines: string[] = [
|
|
569
559
|
"Allow this command to run without sandbox?",
|
|
570
560
|
"---",
|
|
@@ -575,13 +565,6 @@ export class BwrapRuntime {
|
|
|
575
565
|
if (outcome.kind === "suggestion") {
|
|
576
566
|
lines.push("", outcome.suggestion.text, "---");
|
|
577
567
|
}
|
|
578
|
-
if (unallowedPatterns.length > 0) {
|
|
579
|
-
lines.push(
|
|
580
|
-
"",
|
|
581
|
-
"勾选规则将持久化为允许规则(后续同模式命令自动放行),未勾选规则仅本次处理:",
|
|
582
|
-
"---",
|
|
583
|
-
);
|
|
584
|
-
}
|
|
585
568
|
lines.push(fenceCodeBlock(command));
|
|
586
569
|
// 执行目录与工作区不同时,提示实际执行目录(execCwd 是解析后的绝对路径,
|
|
587
570
|
// 显示用 pretty path 风格:home 内 `~/…`,否则绝对路径)
|
|
@@ -590,51 +573,51 @@ export class BwrapRuntime {
|
|
|
590
573
|
}
|
|
591
574
|
const description = lines.join("\n");
|
|
592
575
|
|
|
593
|
-
//
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
576
|
+
// 主决策列表:允许一次 / 拒绝 / 拒绝并附理由。有可持久化的 pattern 时
|
|
577
|
+
// 追加折叠入口,进入子菜单逐项勾选。
|
|
578
|
+
const actions: SelectAction[] = [
|
|
579
|
+
{ label: ALLOW_ONCE },
|
|
580
|
+
{ label: DENY },
|
|
581
|
+
{ label: DENY_WITH_REASON, inputPrompt: "Why was this denied?" },
|
|
582
|
+
];
|
|
583
|
+
if (unallowedPatterns.length > 0) {
|
|
584
|
+
actions.push({ label: EDIT_RULES });
|
|
585
|
+
}
|
|
586
|
+
// 子菜单沿用同一份说明,并补上勾选规则的语义提示。
|
|
587
|
+
const editDescription = [
|
|
588
|
+
"勾选要持久化为允许规则的命令模式(后续同模式命令自动放行,未勾选仅本次处理):",
|
|
589
|
+
"---",
|
|
590
|
+
"",
|
|
591
|
+
description,
|
|
592
|
+
].join("\n");
|
|
593
|
+
|
|
594
|
+
// 两层循环:子菜单勾选后回到主决策,直到用户在 Allow once / Deny 系列中做出
|
|
595
|
+
// 选择。勾选的规则在放行时持久化;Deny 系列同样持久化(用户确认该模式可信,
|
|
596
|
+
// 只是本次命令不执行)。子菜单关闭 = 返回主决策;主决策关闭 = 取消(上层按拒绝处理)。
|
|
597
|
+
let selected: string[] = [];
|
|
598
|
+
for (;;) {
|
|
599
|
+
const pending =
|
|
600
|
+
selected.length > 0
|
|
601
|
+
? `\n\n将持久化为允许规则: ${selected.map((pattern) => escapeHtml(pattern)).join(", ")}`
|
|
602
|
+
: "";
|
|
603
|
+
const verdict = await selectWithOptionalInput(description + pending, actions, ctx.ui, {
|
|
597
604
|
signal: ctx.signal,
|
|
598
605
|
});
|
|
599
|
-
// 关闭对话框 = 中断并拒绝,不循环重问
|
|
600
606
|
if (verdict === undefined) return undefined;
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
607
|
+
if (verdict.label !== EDIT_RULES) {
|
|
608
|
+
return {
|
|
609
|
+
result: verdict.label,
|
|
610
|
+
foreverApprovedPattern: selected,
|
|
611
|
+
reason: verdict.input,
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
selected = await selectMultiple(
|
|
615
|
+
editDescription,
|
|
616
|
+
unallowedPatterns.map((pattern) => ({ label: pattern })),
|
|
617
|
+
ctx.ui,
|
|
618
|
+
{ signal: ctx.signal, doneLabel: BACK },
|
|
619
|
+
);
|
|
606
620
|
}
|
|
607
|
-
|
|
608
|
-
// 每个识别到的 pattern 一个 checkbox:勾选 = 持久化为 allow 规则。
|
|
609
|
-
// Allow once = 执行本次并持久化勾选的规则;Deny 系列 = 拒绝本次,
|
|
610
|
-
// 勾选的规则仍持久化(用户确认该模式可信,只是本次命令不执行)。
|
|
611
|
-
const actions = [
|
|
612
|
-
{ action: "allow-once", label: ALLOW_ONCE },
|
|
613
|
-
{ action: "deny", label: DENY },
|
|
614
|
-
{
|
|
615
|
-
action: "deny-with-reason",
|
|
616
|
-
label: DENY_WITH_REASON,
|
|
617
|
-
inputPrompt: "Why was this denied?",
|
|
618
|
-
},
|
|
619
|
-
] as const satisfies readonly CheckboxAction<"allow-once" | "deny" | "deny-with-reason">[];
|
|
620
|
-
const verdict = await selectCheckboxActions(
|
|
621
|
-
description,
|
|
622
|
-
[...new Set(unallowedPatterns)].map((pattern) => ({ label: pattern })),
|
|
623
|
-
actions,
|
|
624
|
-
ctx.ui,
|
|
625
|
-
{ signal: ctx.signal },
|
|
626
|
-
);
|
|
627
|
-
if (verdict === undefined) return undefined;
|
|
628
|
-
const resultByAction = {
|
|
629
|
-
"allow-once": ALLOW_ONCE,
|
|
630
|
-
deny: DENY,
|
|
631
|
-
"deny-with-reason": DENY_WITH_REASON,
|
|
632
|
-
} as const;
|
|
633
|
-
return {
|
|
634
|
-
result: resultByAction[verdict.action],
|
|
635
|
-
foreverApprovedPattern: verdict.selected,
|
|
636
|
-
reason: verdict.input,
|
|
637
|
-
};
|
|
638
621
|
}
|
|
639
622
|
|
|
640
623
|
/** 把命令的权限模式写入项目 bwrap.json 的 approvalRules(allow forever)。 */
|
package/src/lib/ui.ts
CHANGED
|
@@ -111,54 +111,3 @@ export async function selectMultiple(
|
|
|
111
111
|
}
|
|
112
112
|
return selected;
|
|
113
113
|
}
|
|
114
|
-
|
|
115
|
-
/** 结束动作:选中即结束多选循环;带 inputPrompt 时先弹输入框,内容经 input 返回。 */
|
|
116
|
-
export interface CheckboxAction<T extends string> {
|
|
117
|
-
action: T;
|
|
118
|
-
label: string;
|
|
119
|
-
inputPrompt?: string;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export interface CheckboxActionResult<T extends string> {
|
|
123
|
-
/** 勾选的条目 label(勾选顺序,经 inputPrompt 输入的条目已并入)。 */
|
|
124
|
-
selected: string[];
|
|
125
|
-
/** 用户选中的结束动作。 */
|
|
126
|
-
action: T;
|
|
127
|
-
/** 结束动作带 inputPrompt 时的输入内容:undefined=取消输入,""=空提交。 */
|
|
128
|
-
input?: string;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Checkbox 多选 + 结束动作的组合对话框(selectMultiple 的带操作变体)。
|
|
133
|
-
*
|
|
134
|
-
* 循环列出全部 `actions`(固定操作,选中即结束循环)与全部 `entries`
|
|
135
|
-
* (☑/☐ 前缀切换勾选);对话框关闭(dismiss)返回 undefined。操作在前、
|
|
136
|
-
* checkbox 条目在后,用户先决定本次动作,需要时再勾选要持久化的规则。
|
|
137
|
-
*/
|
|
138
|
-
export async function selectCheckboxActions<T extends string>(
|
|
139
|
-
title: string,
|
|
140
|
-
entries: readonly SelectAction[],
|
|
141
|
-
actions: readonly CheckboxAction<T>[],
|
|
142
|
-
ui: ExtensionContext["ui"],
|
|
143
|
-
opts: { signal?: AbortSignal } = {},
|
|
144
|
-
): Promise<CheckboxActionResult<T> | undefined> {
|
|
145
|
-
const selected: string[] = [];
|
|
146
|
-
for (;;) {
|
|
147
|
-
const selectedSet = new Set(selected);
|
|
148
|
-
const displayToLabel = new Map<string, string>();
|
|
149
|
-
const round: SelectAction[] = [];
|
|
150
|
-
for (const entry of entries) {
|
|
151
|
-
const display = `${selectedSet.has(entry.label) ? CHECKED_PREFIX : UNCHECKED_PREFIX}${entry.label}`;
|
|
152
|
-
displayToLabel.set(display, entry.label);
|
|
153
|
-
round.push({ ...entry, label: display });
|
|
154
|
-
}
|
|
155
|
-
const result = await selectWithOptionalInput(title, [...actions, ...round], ui, opts);
|
|
156
|
-
if (result === undefined) return undefined;
|
|
157
|
-
const action = actions.find((candidate) => candidate.label === result.label);
|
|
158
|
-
if (action !== undefined) return { selected, action: action.action, input: result.input };
|
|
159
|
-
const label = displayToLabel.get(result.label);
|
|
160
|
-
if (label === undefined) continue;
|
|
161
|
-
if (selectedSet.has(label)) selected.splice(selected.indexOf(label), 1);
|
|
162
|
-
else selected.push(label);
|
|
163
|
-
}
|
|
164
|
-
}
|
package/src/spawn-agent.ts
CHANGED
|
@@ -17,9 +17,12 @@
|
|
|
17
17
|
* single `tool:` line (`read x 2, glob`) and over-long line content is
|
|
18
18
|
* folded to the first/last 9 chars joined by `…`, so a burst of tool calls
|
|
19
19
|
* or a long text block does not flood the window; any text block starts a
|
|
20
|
-
* new line.
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* new line. Line content is sanitized first: markdown marker characters are
|
|
21
|
+
* stripped and whitespace (including newlines) is collapsed to single spaces,
|
|
22
|
+
* so one log entry is always exactly one rendered line. The final line is
|
|
23
|
+
* always the subagent name as a code span (`` `scout` ``), followed by the
|
|
24
|
+
* live usage stats when there are any; it rides outside the rolling window so
|
|
25
|
+
* it is never trimmed.
|
|
23
26
|
*
|
|
24
27
|
* Security default: without an explicit `tools:` in the frontmatter, the
|
|
25
28
|
* subagent only gets read-only tools (read/grep/find/ls) — no bash/write/edit.
|
|
@@ -67,6 +70,11 @@ const DEFAULT_TOOLS = ["read", "grep", "find", "ls"];
|
|
|
67
70
|
const MAX_PROGRESS_LINES = 5;
|
|
68
71
|
/** Progress line content (without the `tool:` / `text:` prefix) is capped at 21 chars; longer text is folded to the first/last 9 chars joined by ` … `. */
|
|
69
72
|
const MAX_PROGRESS_CHARS_PER_LINE = 21;
|
|
73
|
+
/**
|
|
74
|
+
* 进度内容会被 pi 按 markdown 渲染,这些标记字符会改变显示效果(代码块、粗体、
|
|
75
|
+
* 链接、标题等),因此在进日志前统一删掉。
|
|
76
|
+
*/
|
|
77
|
+
const PROGRESS_MARKDOWN_MARKERS_RE = /[`*_~[\]<>#|]/g;
|
|
70
78
|
/** 错误消息里 stderr 的展示上限。 */
|
|
71
79
|
const MAX_STDERR_ERROR_BYTES = 4 * 1024;
|
|
72
80
|
/** 全局默认配置:~/.pi/agent/spawn-agent.json,字段可被 frontmatter 覆盖。 */
|
|
@@ -184,6 +192,15 @@ function foldProgressLine(text: string): string {
|
|
|
184
192
|
return `${text.slice(0, keep)} … ${text.slice(-keep)}`;
|
|
185
193
|
}
|
|
186
194
|
|
|
195
|
+
/**
|
|
196
|
+
* 进度行是「单行内容 + markdown 渲染」:内容里的换行会打乱按行滚动的窗口,
|
|
197
|
+
* markdown 标记会改变渲染效果。先删掉标记字符,再把换行/制表符/连续空格折成
|
|
198
|
+
* 单个空格并去掉首尾空白,保证一条日志恒为一行。
|
|
199
|
+
*/
|
|
200
|
+
function sanitizeProgressLine(text: string): string {
|
|
201
|
+
return text.replaceAll(PROGRESS_MARKDOWN_MARKERS_RE, "").replaceAll(/\s+/g, " ").trim();
|
|
202
|
+
}
|
|
203
|
+
|
|
187
204
|
function formatTokens(count: number): string {
|
|
188
205
|
if (count < 1000) return count.toString();
|
|
189
206
|
if (count < 10_000) return `${(count / 1000).toFixed(1)}k`;
|
|
@@ -373,7 +390,8 @@ export async function runAgent(
|
|
|
373
390
|
toolLine = undefined;
|
|
374
391
|
};
|
|
375
392
|
|
|
376
|
-
const appendToolLine = (
|
|
393
|
+
const appendToolLine = (rawName: string) => {
|
|
394
|
+
const name = sanitizeProgressLine(rawName);
|
|
377
395
|
const firstInBatch = toolLine === undefined;
|
|
378
396
|
if (toolLine === undefined) {
|
|
379
397
|
toolLineSegments = [];
|
|
@@ -402,7 +420,8 @@ export async function runAgent(
|
|
|
402
420
|
// 一眼能看出属于哪个 subagent;usage 与它同行,TUI 始终能看到实时 token 开销。
|
|
403
421
|
// 这行位于滚动窗口之外,因此永远不会被挤掉。
|
|
404
422
|
const usageLine = formatUsageStats(result.usage, result.model);
|
|
405
|
-
const
|
|
423
|
+
const name = sanitizeProgressLine(result.agent);
|
|
424
|
+
const footer = usageLine ? `\`${name}\` ${usageLine}` : `\`${name}\``;
|
|
406
425
|
onUpdate?.({
|
|
407
426
|
content: [{ type: "text", text: [...logLines, footer].join("\n") }],
|
|
408
427
|
details: { ...result },
|
|
@@ -416,7 +435,7 @@ export async function runAgent(
|
|
|
416
435
|
// `text:` log line. Deltas/thinking are intentionally not logged.
|
|
417
436
|
const delta = event.assistantMessageEvent;
|
|
418
437
|
if (delta.type === "text_end") {
|
|
419
|
-
pushLogLine(`text: ${foldProgressLine(delta.content)}`);
|
|
438
|
+
pushLogLine(`text: ${foldProgressLine(sanitizeProgressLine(delta.content))}`);
|
|
420
439
|
emitUpdate();
|
|
421
440
|
}
|
|
422
441
|
|