@trim21/personal-pi-extensions 0.0.156 → 0.0.161
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/README.md +6 -24
- package/package.json +1 -1
- package/src/bwrap/index.ts +86 -6
- package/src/gh-readonly.ts +1 -1
- package/src/spawn-agent-agents.ts +106 -0
- package/src/spawn-agent.ts +452 -0
- package/src/workspace-guard.ts +50 -11
- package/src/agents-md-user-message.ts +0 -71
package/README.md
CHANGED
|
@@ -4,13 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
## 扩展概览
|
|
6
6
|
|
|
7
|
-
| 扩展
|
|
8
|
-
|
|
|
9
|
-
| [bwrap](#bwrap)
|
|
10
|
-
| [workspace-guard](#workspace-guard)
|
|
11
|
-
| [opencode-edit](#opencode-edit)
|
|
12
|
-
| [bash-default-timeout](#bash-default-timeout)
|
|
13
|
-
| [agents-md-user-message](#agents-md-user-message) | 将项目级 AGENTS.md 移至 user message,避免占用 system prompt |
|
|
7
|
+
| 扩展 | 描述 |
|
|
8
|
+
| --------------------------------------------- | ------------------------------------------------------ |
|
|
9
|
+
| [bwrap](#bwrap) | 基于 bubblewrap 的 OS 级沙箱,提供文件系统和网络隔离 |
|
|
10
|
+
| [workspace-guard](#workspace-guard) | 限制文件写入在 workspace 内,外部写入需用户审批 |
|
|
11
|
+
| [opencode-edit](#opencode-edit) | 替换内置 edit 工具,使用 opencode 的 schema 和匹配引擎 |
|
|
12
|
+
| [bash-default-timeout](#bash-default-timeout) | 为 bash 工具设置默认超时(180 秒) |
|
|
14
13
|
|
|
15
14
|
---
|
|
16
15
|
|
|
@@ -134,23 +133,6 @@ pi -e ./src/bash-default-timeout.ts
|
|
|
134
133
|
|
|
135
134
|
---
|
|
136
135
|
|
|
137
|
-
## agents-md-user-message
|
|
138
|
-
|
|
139
|
-
将项目级 `AGENTS.md` 从 system prompt 移至 user message,减少 system prompt 占用。
|
|
140
|
-
|
|
141
|
-
- 项目级 `AGENTS.md` → 放入 user message(仅首次消息注入一次)
|
|
142
|
-
- 全局 `~/.pi/agent/AGENTS.md` → 保留在 system prompt
|
|
143
|
-
|
|
144
|
-
需要配合 `--no-context-files` 参数禁用 pi 默认的上下文文件加载。
|
|
145
|
-
|
|
146
|
-
### 使用
|
|
147
|
-
|
|
148
|
-
```bash
|
|
149
|
-
pi -e ./src/agents-md-user-message.ts --no-context-files
|
|
150
|
-
```
|
|
151
|
-
|
|
152
|
-
---
|
|
153
|
-
|
|
154
136
|
## 安装
|
|
155
137
|
|
|
156
138
|
### 通过 npm/git 包
|
package/package.json
CHANGED
package/src/bwrap/index.ts
CHANGED
|
@@ -104,8 +104,29 @@ work inside the sandbox, run it WITHOUT full access first. If it fails with
|
|
|
104
104
|
and set \`request_full_access_reason\` to describe the failure.
|
|
105
105
|
`;
|
|
106
106
|
|
|
107
|
+
const SUBAGENT_SANDBOX_PROMPT = `
|
|
108
|
+
## Command Execution (subagent sandbox)
|
|
109
|
+
|
|
110
|
+
You are running inside a read-only sandbox in a subagent session.
|
|
111
|
+
|
|
112
|
+
- The bash tool is read-only: no filesystem writes, no network access, and
|
|
113
|
+
\`request_full_access\` is denied. Do not pass \`request_full_access\`.
|
|
114
|
+
- Use the write/edit tools for file changes inside your workspace.
|
|
115
|
+
- Writes outside the workspace are rejected. If a change outside the
|
|
116
|
+
workspace is needed, ask the parent session to apply it.
|
|
117
|
+
- If a command truly requires network or system-level access, stop and ask
|
|
118
|
+
the parent session to run it, where the user can approve it.
|
|
119
|
+
`;
|
|
120
|
+
|
|
107
121
|
const PROTECTED_DIRS = [".git", ".pi", ".agent"];
|
|
108
122
|
|
|
123
|
+
/**
|
|
124
|
+
* pi-subagents sets PI_SUBAGENT_CHILD=1 in every spawned child session
|
|
125
|
+
* (foreground and async alike). Subagent sessions are headless and must not
|
|
126
|
+
* be able to bypass the sandbox, so bwrap forces read-only bash there.
|
|
127
|
+
*/
|
|
128
|
+
const isSubagentChild = process.env.PI_SUBAGENT_CHILD === "1";
|
|
129
|
+
|
|
109
130
|
const bwrapPath = findDefaultBwrap();
|
|
110
131
|
|
|
111
132
|
function findDefaultBwrap(): string {
|
|
@@ -134,7 +155,7 @@ function findBwrap(override?: string): string {
|
|
|
134
155
|
|
|
135
156
|
type BwrapMode = "allow-all" | "workspace-write" | "readonly";
|
|
136
157
|
|
|
137
|
-
interface BwrapConfig {
|
|
158
|
+
export interface BwrapConfig {
|
|
138
159
|
mode: BwrapMode;
|
|
139
160
|
bwrapPath?: string;
|
|
140
161
|
writablePaths?: string[];
|
|
@@ -143,7 +164,7 @@ interface BwrapConfig {
|
|
|
143
164
|
extraArgs?: string[];
|
|
144
165
|
}
|
|
145
166
|
|
|
146
|
-
interface ResolvedBwrap {
|
|
167
|
+
export interface ResolvedBwrap {
|
|
147
168
|
mode: BwrapMode;
|
|
148
169
|
bwrapEnabled: boolean;
|
|
149
170
|
network: boolean;
|
|
@@ -176,6 +197,22 @@ function resolveBwrap(config: BwrapConfig): ResolvedBwrap {
|
|
|
176
197
|
}
|
|
177
198
|
}
|
|
178
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Subagent sessions are forced read-only regardless of config: no writable
|
|
202
|
+
* paths (including configured extraWritablePaths), no network, and no
|
|
203
|
+
* user-supplied extra args that could add writable mounts.
|
|
204
|
+
*/
|
|
205
|
+
export function resolveSubagentBwrap(config: BwrapConfig): ResolvedBwrap {
|
|
206
|
+
return resolveBwrap({
|
|
207
|
+
...config,
|
|
208
|
+
mode: "readonly",
|
|
209
|
+
writablePaths: [],
|
|
210
|
+
extraWritablePaths: [],
|
|
211
|
+
tmpfsPaths: [],
|
|
212
|
+
extraArgs: [],
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
179
216
|
const DEFAULT_CONFIG: BwrapConfig = {
|
|
180
217
|
mode: "workspace-write",
|
|
181
218
|
writablePaths: [".", "/tmp"],
|
|
@@ -483,6 +520,36 @@ function notifyMode(
|
|
|
483
520
|
ctx.ui.notify(labels[mode], "info");
|
|
484
521
|
}
|
|
485
522
|
|
|
523
|
+
export type EscalationDecision = { kind: "dialog" } | { kind: "deny"; reason: string };
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Escalation (`request_full_access`) policy:
|
|
527
|
+
* - Subagent sessions are always denied: bash is read-only there and there is
|
|
528
|
+
* no approval path (headless), so escalation would silently bypass the sandbox.
|
|
529
|
+
* - Any other headless session is denied: there is no user to approve.
|
|
530
|
+
* - Interactive sessions require the user approval dialog.
|
|
531
|
+
*/
|
|
532
|
+
export function resolveEscalation(opts: {
|
|
533
|
+
hasUI: boolean;
|
|
534
|
+
isSubagentChild: boolean;
|
|
535
|
+
}): EscalationDecision {
|
|
536
|
+
if (opts.isSubagentChild) {
|
|
537
|
+
return {
|
|
538
|
+
kind: "deny",
|
|
539
|
+
reason:
|
|
540
|
+
"request_full_access is disabled in subagent sessions: the bash tool is read-only and cannot escalate. Ask the parent session to run this command.",
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
if (!opts.hasUI) {
|
|
544
|
+
return {
|
|
545
|
+
kind: "deny",
|
|
546
|
+
reason:
|
|
547
|
+
"request_full_access requires an interactive session with user approval; no UI is available in this session.",
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
return { kind: "dialog" };
|
|
551
|
+
}
|
|
552
|
+
|
|
486
553
|
const sandboxedBashSchema = Type.Object({
|
|
487
554
|
command: Type.String({ description: "Bash command to execute" }),
|
|
488
555
|
timeout: Type.Optional(
|
|
@@ -517,6 +584,10 @@ export default function bwrapExtension(pi: ExtensionAPI) {
|
|
|
517
584
|
let resolved: ResolvedBwrap | null = null;
|
|
518
585
|
|
|
519
586
|
function getResolved(): ResolvedBwrap {
|
|
587
|
+
// Subagents ignore config and runtime switches: bash is always read-only.
|
|
588
|
+
if (isSubagentChild) {
|
|
589
|
+
return resolveSubagentBwrap(loadConfig(localCwd));
|
|
590
|
+
}
|
|
520
591
|
if (!resolved) {
|
|
521
592
|
resolved = resolveBwrap(loadConfig(localCwd));
|
|
522
593
|
}
|
|
@@ -524,6 +595,7 @@ export default function bwrapExtension(pi: ExtensionAPI) {
|
|
|
524
595
|
}
|
|
525
596
|
|
|
526
597
|
function setMode(mode: BwrapMode) {
|
|
598
|
+
if (isSubagentChild) return; // mode switching is not allowed in subagents
|
|
527
599
|
const config = loadConfig(localCwd);
|
|
528
600
|
config.mode = mode;
|
|
529
601
|
resolved = resolveBwrap(config);
|
|
@@ -550,6 +622,11 @@ export default function bwrapExtension(pi: ExtensionAPI) {
|
|
|
550
622
|
const escalate = params.request_full_access === true;
|
|
551
623
|
|
|
552
624
|
if (escalate) {
|
|
625
|
+
const policy = resolveEscalation({ hasUI: ctx?.hasUI ?? false, isSubagentChild });
|
|
626
|
+
if (policy.kind === "deny") {
|
|
627
|
+
throw new Error(policy.reason);
|
|
628
|
+
}
|
|
629
|
+
|
|
553
630
|
if (ctx?.hasUI) {
|
|
554
631
|
const reason = params.request_full_access_reason;
|
|
555
632
|
const reasonText = reason
|
|
@@ -595,7 +672,9 @@ export default function bwrapExtension(pi: ExtensionAPI) {
|
|
|
595
672
|
pi.on("session_start", (_event, ctx) => {
|
|
596
673
|
const noBwrap = pi.getFlag("no-bwrap") === true;
|
|
597
674
|
|
|
598
|
-
|
|
675
|
+
// Subagent sessions are always sandboxed read-only; --no-bwrap must not
|
|
676
|
+
// disable that (the flag is only honored in the parent session).
|
|
677
|
+
if (noBwrap && !isSubagentChild) {
|
|
599
678
|
resolved = null;
|
|
600
679
|
ctx.ui.notify("bwrap sandbox disabled via --no-bwrap", "warning");
|
|
601
680
|
return;
|
|
@@ -612,7 +691,7 @@ export default function bwrapExtension(pi: ExtensionAPI) {
|
|
|
612
691
|
}
|
|
613
692
|
|
|
614
693
|
const config = loadConfig(ctx.cwd);
|
|
615
|
-
resolved = resolveBwrap(config);
|
|
694
|
+
resolved = isSubagentChild ? resolveSubagentBwrap(config) : resolveBwrap(config);
|
|
616
695
|
|
|
617
696
|
if (resolved.bwrapEnabled) {
|
|
618
697
|
try {
|
|
@@ -644,8 +723,9 @@ export default function bwrapExtension(pi: ExtensionAPI) {
|
|
|
644
723
|
const r = getResolved();
|
|
645
724
|
|
|
646
725
|
return {
|
|
647
|
-
systemPrompt:
|
|
648
|
-
event.systemPrompt + "\n\n" +
|
|
726
|
+
systemPrompt: isSubagentChild
|
|
727
|
+
? event.systemPrompt + "\n\n" + SUBAGENT_SANDBOX_PROMPT
|
|
728
|
+
: event.systemPrompt + "\n\n" + SANDBOX_PROMPT + `\n\nCurrent mode: **${r.mode}**\n`,
|
|
649
729
|
};
|
|
650
730
|
});
|
|
651
731
|
|
package/src/gh-readonly.ts
CHANGED
|
@@ -177,7 +177,7 @@ export class GhError extends Error {
|
|
|
177
177
|
/** Run `gh` and return stdout. On non-zero exit, throws a `GhError` carrying the toolcall input and raw command. */
|
|
178
178
|
export async function ghExec(
|
|
179
179
|
args: string[],
|
|
180
|
-
ctx: { cwd?: string; signal?: AbortSignal; input?: unknown },
|
|
180
|
+
ctx: { cwd?: string; signal?: AbortSignal; input?: unknown; timeout?: number },
|
|
181
181
|
): Promise<string> {
|
|
182
182
|
const result = await runGh(args, ctx);
|
|
183
183
|
if (result.code !== 0) {
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent discovery for the `spawn_agent` tool.
|
|
3
|
+
*
|
|
4
|
+
* Subagents are defined as markdown files in `~/.pi/agent/agents/*.md`
|
|
5
|
+
* (user-level only; project-local agents are intentionally not supported).
|
|
6
|
+
* Each file carries YAML frontmatter plus a system-prompt body:
|
|
7
|
+
*
|
|
8
|
+
* ---
|
|
9
|
+
* name: scout
|
|
10
|
+
* description: Fast codebase recon
|
|
11
|
+
* tools:
|
|
12
|
+
* - read
|
|
13
|
+
* - grep
|
|
14
|
+
* - find
|
|
15
|
+
* - ls
|
|
16
|
+
* model: claude-haiku-4-5 # optional
|
|
17
|
+
* thinkingLevel: high # optional; applied as "model:high"
|
|
18
|
+
* ---
|
|
19
|
+
* System prompt for the agent goes here.
|
|
20
|
+
*
|
|
21
|
+
* Frontmatter is validated with a typebox schema; files that fail validation
|
|
22
|
+
* (missing name/description, wrong field types) are skipped. If `tools` is
|
|
23
|
+
* omitted, the subagent runs with the read-only default toolset from the
|
|
24
|
+
* spawn-agent config (read/grep/find/ls) unless overridden there.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
|
|
30
|
+
import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
31
|
+
import { Type } from "typebox";
|
|
32
|
+
import { Value } from "typebox/value";
|
|
33
|
+
|
|
34
|
+
/** Valid thinking levels, mirroring pi's ThinkingLevel type. */
|
|
35
|
+
const THINKING_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
36
|
+
|
|
37
|
+
const agentFrontmatterSchema = Type.Object({
|
|
38
|
+
name: Type.String({ minLength: 1 }),
|
|
39
|
+
description: Type.String({ minLength: 1 }),
|
|
40
|
+
tools: Type.Optional(Type.Array(Type.String())),
|
|
41
|
+
model: Type.Optional(Type.String()),
|
|
42
|
+
thinkingLevel: Type.Optional(Type.Union(THINKING_LEVELS.map((level) => Type.Literal(level)))),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function parseAgentFrontmatter(frontmatter: unknown) {
|
|
46
|
+
try {
|
|
47
|
+
return Value.Parse(agentFrontmatterSchema, frontmatter);
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface AgentConfig {
|
|
54
|
+
name: string;
|
|
55
|
+
description: string;
|
|
56
|
+
/** Toolset from the frontmatter; undefined means "use the config default". */
|
|
57
|
+
tools?: string[];
|
|
58
|
+
model?: string;
|
|
59
|
+
/** Thinking level, applied as a ":level" suffix on the model id. */
|
|
60
|
+
thinkingLevel?: (typeof THINKING_LEVELS)[number];
|
|
61
|
+
systemPrompt: string;
|
|
62
|
+
filePath: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function discoverAgents(dir = join(getAgentDir(), "agents")): AgentConfig[] {
|
|
66
|
+
let entries;
|
|
67
|
+
try {
|
|
68
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
69
|
+
} catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const agents: AgentConfig[] = [];
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
if (!entry.name.endsWith(".md")) continue;
|
|
76
|
+
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
|
|
77
|
+
|
|
78
|
+
const filePath = join(dir, entry.name);
|
|
79
|
+
let content: string;
|
|
80
|
+
try {
|
|
81
|
+
content = readFileSync(filePath, "utf8");
|
|
82
|
+
} catch {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
87
|
+
const fm = parseAgentFrontmatter(frontmatter);
|
|
88
|
+
if (!fm) continue; // missing name/description or wrong field types → not an agent
|
|
89
|
+
|
|
90
|
+
agents.push({
|
|
91
|
+
name: fm.name,
|
|
92
|
+
description: fm.description,
|
|
93
|
+
tools: fm.tools,
|
|
94
|
+
model: fm.model,
|
|
95
|
+
thinkingLevel: fm.thinkingLevel,
|
|
96
|
+
systemPrompt: body,
|
|
97
|
+
filePath,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return agents;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function formatAgentList(agents: AgentConfig[]): string {
|
|
104
|
+
if (agents.length === 0) return "none";
|
|
105
|
+
return agents.map((a) => `${a.name}: ${a.description}`).join("; ");
|
|
106
|
+
}
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* spawn_agent tool — delegate a task to a subagent running in a separate pi
|
|
3
|
+
* process with an isolated context window.
|
|
4
|
+
*
|
|
5
|
+
* The subagent definition comes from `~/.pi/agent/agents/*.md` (markdown with
|
|
6
|
+
* YAML frontmatter, see spawn-agent-agents.ts). The extension discovers the
|
|
7
|
+
* available subagent types once at startup and appends them to the system
|
|
8
|
+
* prompt on every agent start (same pattern as the bwrap extension), so the
|
|
9
|
+
* model always knows which `agent` names it can pass to the tool. Execution
|
|
10
|
+
* is blocking: the tool awaits the subagent process until it exits and
|
|
11
|
+
* returns its final output to the parent model. Progress is streamed through
|
|
12
|
+
* `onUpdate`, the same channel the built-in bash tool uses for live output.
|
|
13
|
+
*
|
|
14
|
+
* Security default: without an explicit `tools:` in the frontmatter, the
|
|
15
|
+
* subagent only gets read-only tools (read/grep/find/ls) — no bash/write/edit.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
19
|
+
import { existsSync } from "node:fs";
|
|
20
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { basename, dirname, join } from "node:path";
|
|
23
|
+
|
|
24
|
+
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
25
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
26
|
+
import {
|
|
27
|
+
type ExtensionAPI,
|
|
28
|
+
getMarkdownTheme,
|
|
29
|
+
truncateTail,
|
|
30
|
+
withFileMutationQueue,
|
|
31
|
+
} from "@earendil-works/pi-coding-agent";
|
|
32
|
+
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
33
|
+
import { Type } from "typebox";
|
|
34
|
+
|
|
35
|
+
import { type AgentConfig, discoverAgents, formatAgentList } from "./spawn-agent-agents.js";
|
|
36
|
+
|
|
37
|
+
// ── constants ────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/** Subagent output returned to the parent model is capped at 50KB. */
|
|
40
|
+
const MAX_OUTPUT_BYTES = 50 * 1024;
|
|
41
|
+
/** Read-only toolset used when an agent does not declare `tools`. */
|
|
42
|
+
const DEFAULT_TOOLS = ["read", "grep", "find", "ls"];
|
|
43
|
+
|
|
44
|
+
// ── schema ───────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
const spawnAgentSchema = Type.Object({
|
|
47
|
+
agent: Type.String({
|
|
48
|
+
description:
|
|
49
|
+
"Name of the subagent type to invoke. Choose one of the available subagent types listed in your system prompt.",
|
|
50
|
+
}),
|
|
51
|
+
task: Type.String({ description: "Task to delegate to the subagent" }),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ── result types ─────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
interface UsageStats {
|
|
57
|
+
input: number;
|
|
58
|
+
output: number;
|
|
59
|
+
cacheRead: number;
|
|
60
|
+
cacheWrite: number;
|
|
61
|
+
cost: number;
|
|
62
|
+
contextTokens: number;
|
|
63
|
+
turns: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface SubagentDetails {
|
|
67
|
+
agent: string;
|
|
68
|
+
task: string;
|
|
69
|
+
exitCode: number;
|
|
70
|
+
messages: Message[];
|
|
71
|
+
stderr: string;
|
|
72
|
+
usage: UsageStats;
|
|
73
|
+
model?: string;
|
|
74
|
+
stopReason?: string;
|
|
75
|
+
errorMessage?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
function getFinalOutput(messages: Message[]): string {
|
|
81
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
82
|
+
const msg = messages[i];
|
|
83
|
+
if (msg.role === "assistant") {
|
|
84
|
+
for (const part of msg.content) {
|
|
85
|
+
if (part.type === "text") return part.text;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return "";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function formatTokens(count: number): string {
|
|
93
|
+
if (count < 1000) return count.toString();
|
|
94
|
+
if (count < 10_000) return `${(count / 1000).toFixed(1)}k`;
|
|
95
|
+
if (count < 1_000_000) return `${Math.round(count / 1000)}k`;
|
|
96
|
+
return `${(count / 1_000_000).toFixed(1)}M`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function formatUsageStats(usage: UsageStats, model?: string): string {
|
|
100
|
+
const parts: string[] = [];
|
|
101
|
+
if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
|
|
102
|
+
if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
|
|
103
|
+
if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
|
|
104
|
+
if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
|
|
105
|
+
if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
|
|
106
|
+
if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
|
|
107
|
+
if (usage.contextTokens > 0) parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
|
|
108
|
+
if (model) parts.push(model);
|
|
109
|
+
return parts.join(" ");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolve how to spawn the subagent process. Running through the current
|
|
114
|
+
* entry script (when available) keeps model/tool/extension config identical
|
|
115
|
+
* to the parent; otherwise fall back to the `pi` binary on PATH.
|
|
116
|
+
*/
|
|
117
|
+
function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
118
|
+
const currentScript = process.argv[1];
|
|
119
|
+
const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
|
|
120
|
+
if (currentScript && !isBunVirtualScript && existsSync(currentScript)) {
|
|
121
|
+
return { command: process.execPath, args: [currentScript, ...args] };
|
|
122
|
+
}
|
|
123
|
+
const execName = basename(process.execPath).toLowerCase();
|
|
124
|
+
const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
|
|
125
|
+
if (!isGenericRuntime) return { command: process.execPath, args };
|
|
126
|
+
return { command: "pi", args };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function writePromptToTempFile(agentName: string, prompt: string): Promise<string> {
|
|
130
|
+
const dir = await mkdtemp(join(tmpdir(), "pi-spawn-agent-"));
|
|
131
|
+
const safeName = agentName.replaceAll(/[^\w.-]+/g, "_");
|
|
132
|
+
const filePath = join(dir, `prompt-${safeName}.md`);
|
|
133
|
+
await withFileMutationQueue(filePath, async () => {
|
|
134
|
+
await writeFile(filePath, prompt, { encoding: "utf8", mode: 0o600 });
|
|
135
|
+
});
|
|
136
|
+
return filePath;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function buildSubagentArgs(
|
|
140
|
+
agent: AgentConfig,
|
|
141
|
+
task: string,
|
|
142
|
+
systemPromptPath: string | undefined,
|
|
143
|
+
): string[] {
|
|
144
|
+
// --mode json: emit events as JSON lines; -p: single-shot answer;
|
|
145
|
+
// --no-session: ephemeral, do not persist. --no-extensions keeps the
|
|
146
|
+
// subagent clean (no recursive spawn_agent, no sandbox surprises).
|
|
147
|
+
const args: string[] = ["--mode", "json", "-p", "--no-session", "--no-extensions"];
|
|
148
|
+
// Thinking level rides on the model shorthand ("model:level"); it cannot be
|
|
149
|
+
// set without a model, so a level without a model is ignored.
|
|
150
|
+
const model =
|
|
151
|
+
agent.model !== undefined && agent.thinkingLevel !== undefined
|
|
152
|
+
? `${agent.model}:${agent.thinkingLevel}`
|
|
153
|
+
: agent.model;
|
|
154
|
+
if (model) args.push("--model", model);
|
|
155
|
+
// Read-only default unless the agent explicitly declares a toolset.
|
|
156
|
+
const tools = agent.tools ?? DEFAULT_TOOLS;
|
|
157
|
+
args.push("--tools", tools.join(","));
|
|
158
|
+
if (systemPromptPath) args.push("--append-system-prompt", systemPromptPath);
|
|
159
|
+
args.push(`Task: ${task}`);
|
|
160
|
+
return args;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── subagent runner ──────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
|
|
166
|
+
|
|
167
|
+
export async function runAgent(
|
|
168
|
+
agent: AgentConfig,
|
|
169
|
+
task: string,
|
|
170
|
+
cwd: string,
|
|
171
|
+
signal: AbortSignal | undefined,
|
|
172
|
+
onUpdate: OnUpdateCallback | undefined,
|
|
173
|
+
): Promise<SubagentDetails> {
|
|
174
|
+
const result: SubagentDetails = {
|
|
175
|
+
agent: agent.name,
|
|
176
|
+
task,
|
|
177
|
+
exitCode: 0,
|
|
178
|
+
messages: [],
|
|
179
|
+
stderr: "",
|
|
180
|
+
usage: {
|
|
181
|
+
input: 0,
|
|
182
|
+
output: 0,
|
|
183
|
+
cacheRead: 0,
|
|
184
|
+
cacheWrite: 0,
|
|
185
|
+
cost: 0,
|
|
186
|
+
contextTokens: 0,
|
|
187
|
+
turns: 0,
|
|
188
|
+
},
|
|
189
|
+
model: agent.model,
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
let tmpPromptPath: string | null = null;
|
|
193
|
+
try {
|
|
194
|
+
if (agent.systemPrompt.trim()) {
|
|
195
|
+
tmpPromptPath = await writePromptToTempFile(agent.name, agent.systemPrompt);
|
|
196
|
+
}
|
|
197
|
+
const args = buildSubagentArgs(agent, task, tmpPromptPath ?? undefined);
|
|
198
|
+
const invocation = getPiInvocation(args);
|
|
199
|
+
const proc = spawn(invocation.command, invocation.args, {
|
|
200
|
+
cwd,
|
|
201
|
+
shell: false,
|
|
202
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
203
|
+
// Mark the child as a subagent so extensions running inside it (e.g.
|
|
204
|
+
// bwrap's subagent policy) can recognize and treat it accordingly.
|
|
205
|
+
env: { ...process.env, PI_SUBAGENT_CHILD: "1" },
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const emitUpdate = () => {
|
|
209
|
+
onUpdate?.({
|
|
210
|
+
content: [{ type: "text", text: getFinalOutput(result.messages) || "(running...)" }],
|
|
211
|
+
details: { ...result },
|
|
212
|
+
});
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
let buffer = "";
|
|
216
|
+
|
|
217
|
+
const processLine = (line: string) => {
|
|
218
|
+
if (!line.trim()) return;
|
|
219
|
+
let event: unknown;
|
|
220
|
+
try {
|
|
221
|
+
event = JSON.parse(line);
|
|
222
|
+
} catch {
|
|
223
|
+
return; // not a JSON event line
|
|
224
|
+
}
|
|
225
|
+
if (!isRecord(event)) return;
|
|
226
|
+
|
|
227
|
+
if (event.type === "message_end" && isRecord(event.message)) {
|
|
228
|
+
const msg = event.message as unknown as Message;
|
|
229
|
+
result.messages.push(msg);
|
|
230
|
+
if (msg.role === "assistant") {
|
|
231
|
+
result.usage.turns++;
|
|
232
|
+
const usage: Record<string, unknown> = isRecord(msg.usage) ? msg.usage : {};
|
|
233
|
+
result.usage.input += num(usage.input);
|
|
234
|
+
result.usage.output += num(usage.output);
|
|
235
|
+
result.usage.cacheRead += num(usage.cacheRead);
|
|
236
|
+
result.usage.cacheWrite += num(usage.cacheWrite);
|
|
237
|
+
result.usage.cost += num(isRecord(usage.cost) ? usage.cost.total : undefined);
|
|
238
|
+
result.usage.contextTokens = num(usage.totalTokens);
|
|
239
|
+
if (!result.model && typeof msg.model === "string") result.model = msg.model;
|
|
240
|
+
if (typeof msg.stopReason === "string") result.stopReason = msg.stopReason;
|
|
241
|
+
if (typeof msg.errorMessage === "string") result.errorMessage = msg.errorMessage;
|
|
242
|
+
}
|
|
243
|
+
emitUpdate();
|
|
244
|
+
} else if (event.type === "tool_result_end" && isRecord(event.message)) {
|
|
245
|
+
result.messages.push(event.message as unknown as Message);
|
|
246
|
+
emitUpdate();
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
proc.stdout.on("data", (data: Buffer) => {
|
|
251
|
+
buffer += data.toString();
|
|
252
|
+
const lines = buffer.split("\n");
|
|
253
|
+
buffer = lines.pop() ?? "";
|
|
254
|
+
for (const line of lines) processLine(line);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
proc.stderr.on("data", (data: Buffer) => {
|
|
258
|
+
result.stderr += data.toString();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const exitCode = await new Promise<number>((resolve) => {
|
|
262
|
+
proc.on("close", (code) => {
|
|
263
|
+
if (buffer.trim()) processLine(buffer);
|
|
264
|
+
resolve(code ?? 0);
|
|
265
|
+
});
|
|
266
|
+
proc.on("error", () => resolve(1));
|
|
267
|
+
|
|
268
|
+
const kill = () => {
|
|
269
|
+
proc.kill("SIGTERM");
|
|
270
|
+
setTimeout(() => {
|
|
271
|
+
if (!proc.killed) proc.kill("SIGKILL");
|
|
272
|
+
}, 5000);
|
|
273
|
+
};
|
|
274
|
+
if (signal) {
|
|
275
|
+
if (signal.aborted) kill();
|
|
276
|
+
else signal.addEventListener("abort", kill, { once: true });
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
result.exitCode = exitCode;
|
|
281
|
+
return result;
|
|
282
|
+
} finally {
|
|
283
|
+
if (tmpPromptPath) {
|
|
284
|
+
try {
|
|
285
|
+
await rm(tmpPromptPath, { force: true });
|
|
286
|
+
await rm(dirname(tmpPromptPath), { recursive: true, force: true });
|
|
287
|
+
} catch {
|
|
288
|
+
// best-effort cleanup
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
295
|
+
return typeof v === "object" && v !== null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function num(v: unknown): number {
|
|
299
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Session entry customType used to mark the injected subagent list. */
|
|
303
|
+
export function formatAgentListSection(agents: AgentConfig[]): string {
|
|
304
|
+
const lines = agents.map((a) => `- \`${a.name}\`: ${a.description}`);
|
|
305
|
+
return [
|
|
306
|
+
"## Available subagents",
|
|
307
|
+
"",
|
|
308
|
+
"You can delegate tasks to the following subagent types by calling the `spawn_agent` tool with their name in the `agent` parameter:",
|
|
309
|
+
"",
|
|
310
|
+
...lines,
|
|
311
|
+
].join("\n");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── extension ────────────────────────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
export default function spawnAgent(pi: ExtensionAPI) {
|
|
317
|
+
// Discover the available subagent types once at extension startup. The
|
|
318
|
+
// extension owns this discovery: the model never has to guess agent names
|
|
319
|
+
// or read the agent directory itself. Editing ~/.pi/agent/agents/*.md
|
|
320
|
+
// requires /reload to take effect.
|
|
321
|
+
const agents = discoverAgents();
|
|
322
|
+
const agentListSection = agents.length > 0 ? formatAgentListSection(agents) : null;
|
|
323
|
+
|
|
324
|
+
if (agentListSection) {
|
|
325
|
+
// Same pattern as the bwrap extension: append the list to the system
|
|
326
|
+
// prompt on every agent start. The system prompt is rebuilt each turn
|
|
327
|
+
// anyway, so a persistent per-session injection would add no value.
|
|
328
|
+
pi.on("before_agent_start", (event) => {
|
|
329
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${agentListSection}` };
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
pi.registerTool<typeof spawnAgentSchema, SubagentDetails>({
|
|
334
|
+
name: "spawn_agent",
|
|
335
|
+
label: "spawn_agent",
|
|
336
|
+
description: [
|
|
337
|
+
"Delegate a task to a subagent running in a separate pi process with an isolated context window.",
|
|
338
|
+
"The `agent` parameter must be one of the available subagent types listed in the system prompt.",
|
|
339
|
+
`Subagents run read-only (${DEFAULT_TOOLS.join(", ")}) unless the agent declares an explicit toolset.`,
|
|
340
|
+
].join(" "),
|
|
341
|
+
parameters: spawnAgentSchema,
|
|
342
|
+
|
|
343
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
344
|
+
const agent = agents.find((a) => a.name === params.agent);
|
|
345
|
+
if (!agent) {
|
|
346
|
+
return {
|
|
347
|
+
content: [
|
|
348
|
+
{
|
|
349
|
+
type: "text",
|
|
350
|
+
text: `Unknown agent "${params.agent}". Available agents: ${formatAgentList(agents)}`,
|
|
351
|
+
},
|
|
352
|
+
],
|
|
353
|
+
details: {
|
|
354
|
+
agent: params.agent,
|
|
355
|
+
task: params.task,
|
|
356
|
+
exitCode: 1,
|
|
357
|
+
messages: [],
|
|
358
|
+
stderr: "",
|
|
359
|
+
usage: {
|
|
360
|
+
input: 0,
|
|
361
|
+
output: 0,
|
|
362
|
+
cacheRead: 0,
|
|
363
|
+
cacheWrite: 0,
|
|
364
|
+
cost: 0,
|
|
365
|
+
contextTokens: 0,
|
|
366
|
+
turns: 0,
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
isError: true,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const result = await runAgent(agent, params.task, ctx.cwd, signal, onUpdate);
|
|
374
|
+
|
|
375
|
+
const isError =
|
|
376
|
+
result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
|
|
377
|
+
if (isError) {
|
|
378
|
+
const reason =
|
|
379
|
+
result.stopReason ?? (result.exitCode === 0 ? "failed" : `exit ${result.exitCode}`);
|
|
380
|
+
const message =
|
|
381
|
+
result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
|
|
382
|
+
return {
|
|
383
|
+
content: [
|
|
384
|
+
{ type: "text", text: `Subagent "${result.agent}" failed (${reason}): ${message}` },
|
|
385
|
+
],
|
|
386
|
+
details: result,
|
|
387
|
+
isError: true,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const output = getFinalOutput(result.messages) || "(no output)";
|
|
392
|
+
const truncation = truncateTail(output, { maxBytes: MAX_OUTPUT_BYTES });
|
|
393
|
+
const text = truncation.truncated
|
|
394
|
+
? `${truncation.content}\n\n[Output truncated to ${formatTokens(truncation.content.length)} bytes. Full result preserved in tool details.]`
|
|
395
|
+
: output;
|
|
396
|
+
return { content: [{ type: "text", text }], details: result };
|
|
397
|
+
},
|
|
398
|
+
|
|
399
|
+
renderCall(args, theme) {
|
|
400
|
+
const preview = args.task.length > 60 ? `${args.task.slice(0, 60)}...` : args.task;
|
|
401
|
+
let text = theme.fg("toolTitle", theme.bold("spawn_agent ")) + theme.fg("accent", args.agent);
|
|
402
|
+
text += `\n ${theme.fg("dim", preview)}`;
|
|
403
|
+
return new Text(text, 0, 0);
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
renderResult(result, { expanded }, theme) {
|
|
407
|
+
const details = result.details;
|
|
408
|
+
const isError =
|
|
409
|
+
details.exitCode !== 0 ||
|
|
410
|
+
details.stopReason === "error" ||
|
|
411
|
+
details.stopReason === "aborted";
|
|
412
|
+
const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
|
|
413
|
+
const finalOutput = getFinalOutput(details.messages);
|
|
414
|
+
const usageStr = formatUsageStats(details.usage, details.model);
|
|
415
|
+
|
|
416
|
+
if (expanded) {
|
|
417
|
+
const container = new Container();
|
|
418
|
+
const header = `${icon} ${theme.fg("toolTitle", theme.bold(details.agent))}${
|
|
419
|
+
details.stopReason ? ` ${theme.fg("error", `[${details.stopReason}]`)}` : ""
|
|
420
|
+
}`;
|
|
421
|
+
container.addChild(new Text(header, 0, 0));
|
|
422
|
+
if (isError && details.errorMessage) {
|
|
423
|
+
container.addChild(new Text(theme.fg("error", `Error: ${details.errorMessage}`), 0, 0));
|
|
424
|
+
}
|
|
425
|
+
container.addChild(new Spacer(1));
|
|
426
|
+
container.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0));
|
|
427
|
+
container.addChild(new Text(theme.fg("dim", details.task), 0, 0));
|
|
428
|
+
if (finalOutput) {
|
|
429
|
+
container.addChild(new Spacer(1));
|
|
430
|
+
container.addChild(new Text(theme.fg("muted", "─── Output ───"), 0, 0));
|
|
431
|
+
container.addChild(new Markdown(finalOutput.trim(), 0, 0, getMarkdownTheme()));
|
|
432
|
+
}
|
|
433
|
+
if (usageStr) {
|
|
434
|
+
container.addChild(new Spacer(1));
|
|
435
|
+
container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
|
|
436
|
+
}
|
|
437
|
+
return container;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
let text = `${icon} ${theme.fg("toolTitle", theme.bold(details.agent))}`;
|
|
441
|
+
if (isError && details.errorMessage) {
|
|
442
|
+
text += `\n${theme.fg("error", `Error: ${details.errorMessage}`)}`;
|
|
443
|
+
} else if (finalOutput) {
|
|
444
|
+
text += `\n${theme.fg("toolOutput", finalOutput.split("\n").slice(0, 5).join("\n"))}`;
|
|
445
|
+
} else {
|
|
446
|
+
text += `\n${theme.fg("muted", "(no output)")}`;
|
|
447
|
+
}
|
|
448
|
+
if (usageStr) text += `\n${theme.fg("dim", usageStr)}`;
|
|
449
|
+
return new Text(text, 0, 0);
|
|
450
|
+
},
|
|
451
|
+
});
|
|
452
|
+
}
|
package/src/workspace-guard.ts
CHANGED
|
@@ -27,6 +27,13 @@ import { normalizeForEdit, replace } from "./opencode-edit-engine.js";
|
|
|
27
27
|
const WRITE_TOOLS = new Set(["write", "edit"]);
|
|
28
28
|
const ALWAYS_ALLOW = ["/tmp"];
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* pi-subagents sets PI_SUBAGENT_CHILD=1 in every spawned child session.
|
|
32
|
+
* Subagents are headless, so there is no approval path: writes outside the
|
|
33
|
+
* workspace are rejected outright instead of asking for approval.
|
|
34
|
+
*/
|
|
35
|
+
const isSubagentChild = process.env.PI_SUBAGENT_CHILD === "1";
|
|
36
|
+
|
|
30
37
|
/** Maximum lines of the diff preview shown in the approval dialog. */
|
|
31
38
|
const MAX_PREVIEW_LINES = 100;
|
|
32
39
|
|
|
@@ -173,15 +180,48 @@ export async function buildDiffPreview(
|
|
|
173
180
|
return wrapDiff(generateUnifiedPatch(basename(resolvedPath), oldContent, newContent, 2));
|
|
174
181
|
}
|
|
175
182
|
|
|
183
|
+
export interface WriteGuardDecision {
|
|
184
|
+
block: boolean;
|
|
185
|
+
reason?: string;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Decide how to handle a write outside the workspace.
|
|
190
|
+
* Subagent sessions (and any other headless session) have no approval path,
|
|
191
|
+
* so the write is rejected outright.
|
|
192
|
+
*/
|
|
193
|
+
export function decideOutsideWorkspaceWrite(
|
|
194
|
+
rawPath: string,
|
|
195
|
+
opts: { hasUI: boolean; isSubagentChild: boolean },
|
|
196
|
+
): WriteGuardDecision {
|
|
197
|
+
if (opts.isSubagentChild) {
|
|
198
|
+
return {
|
|
199
|
+
block: true,
|
|
200
|
+
reason: `Path "${rawPath}" is outside the subagent workspace. Writes outside the workspace are rejected in subagent sessions; ask the parent session to apply this change.`,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
if (!opts.hasUI) {
|
|
204
|
+
return {
|
|
205
|
+
block: true,
|
|
206
|
+
reason: `Path "${rawPath}" is outside workspace. No UI available for approval.`,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
return { block: false };
|
|
210
|
+
}
|
|
211
|
+
|
|
176
212
|
export default function workspaceGuard(pi: ExtensionAPI) {
|
|
177
213
|
pi.on("before_agent_start", (event, ctx) => {
|
|
178
214
|
const currentCwd = ctx.cwd;
|
|
179
215
|
return {
|
|
180
|
-
systemPrompt:
|
|
181
|
-
event.systemPrompt +
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
216
|
+
systemPrompt: isSubagentChild
|
|
217
|
+
? event.systemPrompt +
|
|
218
|
+
`\nSubagent write protection is active. ` +
|
|
219
|
+
`write and edit are allowed inside "${currentCwd}" and /tmp. ` +
|
|
220
|
+
`Writes outside the workspace are rejected without approval — ask the parent session to make such changes.`
|
|
221
|
+
: event.systemPrompt +
|
|
222
|
+
`\nWorkspace write protection is active. ` +
|
|
223
|
+
`write and edit to paths inside the workspace "${currentCwd}" or /tmp are auto-allowed. ` +
|
|
224
|
+
`Paths outside require user approval before execution.`,
|
|
185
225
|
};
|
|
186
226
|
});
|
|
187
227
|
|
|
@@ -195,12 +235,11 @@ export default function workspaceGuard(pi: ExtensionAPI) {
|
|
|
195
235
|
|
|
196
236
|
if (isPathAllowed(resolved, ctx.cwd)) return;
|
|
197
237
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
}
|
|
238
|
+
const decision = decideOutsideWorkspaceWrite(rawPath, {
|
|
239
|
+
hasUI: ctx.hasUI,
|
|
240
|
+
isSubagentChild,
|
|
241
|
+
});
|
|
242
|
+
if (decision.block) return decision;
|
|
204
243
|
|
|
205
244
|
let choice: string | undefined;
|
|
206
245
|
while (!choice) {
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* AGENTS.md User Message
|
|
3
|
-
*
|
|
4
|
-
* Moves project-level AGENTS.md from system prompt to user message.
|
|
5
|
-
* Global ~/.pi/agent/AGENTS.md stays in system prompt.
|
|
6
|
-
*
|
|
7
|
-
* Requires --no-context-files to disable pi's default context file loading.
|
|
8
|
-
*
|
|
9
|
-
* Usage:
|
|
10
|
-
* pi -e ./agents-md-user-message --no-context-files
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
14
|
-
import { getAgentDir, loadProjectContextFiles } from "@earendil-works/pi-coding-agent";
|
|
15
|
-
|
|
16
|
-
export default function agentsMdUserMessage(pi: ExtensionAPI) {
|
|
17
|
-
let messageInjected = false;
|
|
18
|
-
|
|
19
|
-
pi.on("before_agent_start", (event) => {
|
|
20
|
-
const contextFiles = event.systemPromptOptions.contextFiles ?? [];
|
|
21
|
-
if (contextFiles.length > 0) return; // pi already handled them
|
|
22
|
-
|
|
23
|
-
// --no-context-files was used, we handle everything
|
|
24
|
-
const files = loadProjectContextFiles({
|
|
25
|
-
cwd: event.systemPromptOptions.cwd,
|
|
26
|
-
agentDir: getAgentDir(),
|
|
27
|
-
});
|
|
28
|
-
if (files.length === 0) return;
|
|
29
|
-
|
|
30
|
-
const agentDir = getAgentDir();
|
|
31
|
-
const globalFiles = files.filter((f) => f.path.startsWith(agentDir));
|
|
32
|
-
const projectFiles = files.filter((f) => !f.path.startsWith(agentDir));
|
|
33
|
-
|
|
34
|
-
if (projectFiles.length === 0) return;
|
|
35
|
-
|
|
36
|
-
let systemPrompt = event.systemPrompt;
|
|
37
|
-
if (globalFiles.length > 0) {
|
|
38
|
-
let block = "\n\n<project_context>\n\nProject-specific instructions and guidelines:\n\n";
|
|
39
|
-
for (const { path, content } of globalFiles) {
|
|
40
|
-
block += `<project_instructions path="${path}">\n${content}\n</project_instructions>\n\n`;
|
|
41
|
-
}
|
|
42
|
-
block += "</project_context>\n";
|
|
43
|
-
systemPrompt += block;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
if (!messageInjected) {
|
|
47
|
-
messageInjected = true;
|
|
48
|
-
|
|
49
|
-
const content = projectFiles
|
|
50
|
-
.map(
|
|
51
|
-
(f) => `<project_instructions path="${f.path}">\n${f.content}\n</project_instructions>`,
|
|
52
|
-
)
|
|
53
|
-
.join("\n\n");
|
|
54
|
-
|
|
55
|
-
return {
|
|
56
|
-
systemPrompt,
|
|
57
|
-
message: {
|
|
58
|
-
customType: "agents-md-user",
|
|
59
|
-
content,
|
|
60
|
-
display: true,
|
|
61
|
-
},
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
return { systemPrompt };
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
pi.on("session_shutdown", () => {
|
|
69
|
-
messageInjected = false;
|
|
70
|
-
});
|
|
71
|
-
}
|