@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 CHANGED
@@ -4,13 +4,12 @@
4
4
 
5
5
  ## 扩展概览
6
6
 
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 秒) |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.0.156",
3
+ "version": "0.0.161",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -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
- if (noBwrap) {
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" + SANDBOX_PROMPT + `\n\nCurrent mode: **${r.mode}**\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
 
@@ -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
+ }
@@ -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
- `\nWorkspace write protection is active. ` +
183
- `write and edit to paths inside the workspace "${currentCwd}" or /tmp are auto-allowed. ` +
184
- `Paths outside require user approval before execution.`,
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
- if (!ctx.hasUI) {
199
- return {
200
- block: true,
201
- reason: `Path "${rawPath}" is outside workspace. No UI available for approval.`,
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
- }