Skip to content

Commit 87617b0

Browse files
author
jetnet
committed
fix: harden subagent spawning across 4 extensions
agent-team.ts, agent-chain.ts, pi-pi.ts, subagent-widget.ts: - Deduplicate parseAgentFile() → use shared agent-loader (SEC-001, PR disler#3) - Clamp renderCard width to prevent RangeError: Invalid count -1 on narrow terminals (Issue disler#17, PR disler#20) - Use process.execPath + findPiCli() for snap-safe subprocess spawning (PR disler#13) - Forward damage-control.ts extension to subagent processes when present, falling back to --no-extensions (Issue disler#24) - Normalize CRLF line endings in all readFileSync calls (PR disler#1) - Wrap 'No experts found' message with truncateToWidth() (PR disler#20)
1 parent e7d810b commit 87617b0

4 files changed

Lines changed: 117 additions & 107 deletions

File tree

extensions/agent-chain.ts

Lines changed: 25 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,22 @@ import { spawn } from "child_process";
2828
import { readFileSync, existsSync, readdirSync, mkdirSync, unlinkSync } from "fs";
2929
import { join, resolve } from "path";
3030
import { applyExtensionDefaults } from "./themeMap.ts";
31+
import { loadAgentFile, formatIssues, type AgentDef } from "./utils/agent-loader.ts";
32+
33+
// Resolve pi CLI script for snap-safe subprocess spawning (PR #13)
34+
function findPiCli(): { cmd: string; prefixArgs: string[] } {
35+
try {
36+
const piPath = require.resolve("@mariozechner/pi-coding-agent/dist/cli.js");
37+
return { cmd: process.execPath, prefixArgs: [piPath] };
38+
} catch {
39+
try {
40+
const piPath = require.resolve("@earendil-works/pi-coding-agent/dist/cli.js");
41+
return { cmd: process.execPath, prefixArgs: [piPath] };
42+
} catch {
43+
return { cmd: "pi", prefixArgs: [] };
44+
}
45+
}
46+
}
3147

3248
// ── Types ────────────────────────────────────────
3349

@@ -42,12 +58,7 @@ interface ChainDef {
4258
steps: ChainStep[];
4359
}
4460

45-
interface AgentDef {
46-
name: string;
47-
description: string;
48-
tools: string;
49-
systemPrompt: string;
50-
}
61+
// AgentDef is now imported from utils/agent-loader.ts
5162

5263
interface StepState {
5364
agent: string;
@@ -133,30 +144,12 @@ function parseChainYaml(raw: string): ChainDef[] {
133144
// ── Frontmatter Parser ───────────────────────────
134145

135146
function parseAgentFile(filePath: string): AgentDef | null {
136-
try {
137-
const raw = readFileSync(filePath, "utf-8");
138-
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
139-
if (!match) return null;
140-
141-
const frontmatter: Record<string, string> = {};
142-
for (const line of match[1].split("\n")) {
143-
const idx = line.indexOf(":");
144-
if (idx > 0) {
145-
frontmatter[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
146-
}
147-
}
148-
149-
if (!frontmatter.name) return null;
150-
151-
return {
152-
name: frontmatter.name,
153-
description: frontmatter.description || "",
154-
tools: frontmatter.tools || "read,grep,find,ls",
155-
systemPrompt: match[2].trim(),
156-
};
157-
} catch {
158-
return null;
147+
const { agent, issues } = loadAgentFile(filePath);
148+
if (issues.length > 0) {
149+
const warnings = formatIssues(issues.filter(i => i.severity === "warning"), filePath);
150+
if (warnings) console.error(`[agent-chain] ${warnings}`);
159151
}
152+
return agent;
160153
}
161154

162155
function scanAgentDirs(cwd: string): Map<string, AgentDef> {
@@ -216,7 +209,7 @@ export default function (pi: ExtensionAPI) {
216209
const chainPath = join(cwd, ".pi", "agents", "agent-chain.yaml");
217210
if (existsSync(chainPath)) {
218211
try {
219-
chains = parseChainYaml(readFileSync(chainPath, "utf-8"));
212+
chains = parseChainYaml(readFileSync(chainPath, "utf-8").replace(/\r\n/g, "\n").replace(/\r/g, "\n"));
220213
} catch {
221214
chains = [];
222215
}
@@ -364,7 +357,8 @@ export default function (pi: ExtensionAPI) {
364357
const state = stepStates[stepIndex];
365358

366359
return new Promise((resolve) => {
367-
const proc = spawn("pi", args, {
360+
const piCli = findPiCli();
361+
const proc = spawn(piCli.cmd, [...piCli.prefixArgs, ...args], {
368362
stdio: ["ignore", "pipe", "pipe"],
369363
env: { ...process.env },
370364
});

extensions/agent-team.ts

Lines changed: 39 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -22,19 +22,35 @@ import { Type } from "@sinclair/typebox";
2222
import { Text, type AutocompleteItem, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
2323
import { spawn } from "child_process";
2424
import { readdirSync, readFileSync, existsSync, mkdirSync, unlinkSync } from "fs";
25-
import { join, resolve } from "path";
25+
import { join, resolve, dirname } from "path";
26+
import { fileURLToPath } from "url";
2627
import { applyExtensionDefaults } from "./themeMap.ts";
28+
import { loadAgentFile, formatIssues, type AgentDef } from "./utils/agent-loader.ts";
2729

28-
// ── Types ────────────────────────────────────────
30+
// Resolve damage-control extension path for forwarding to subagents
31+
const __extensionsDir = dirname(fileURLToPath(import.meta.url));
32+
const DAMAGE_CONTROL_PATH = join(__extensionsDir, "damage-control.ts");
2933

30-
interface AgentDef {
31-
name: string;
32-
description: string;
33-
tools: string;
34-
systemPrompt: string;
35-
file: string;
34+
// Resolve pi CLI script for snap-safe subprocess spawning (PR #13)
35+
function findPiCli(): { cmd: string; prefixArgs: string[] } {
36+
try {
37+
const piPath = require.resolve("@mariozechner/pi-coding-agent/dist/cli.js");
38+
return { cmd: process.execPath, prefixArgs: [piPath] };
39+
} catch {
40+
// Fallback: require.resolve not available, try @earendil-works variant
41+
try {
42+
const piPath = require.resolve("@earendil-works/pi-coding-agent/dist/cli.js");
43+
return { cmd: process.execPath, prefixArgs: [piPath] };
44+
} catch {
45+
return { cmd: "pi", prefixArgs: [] };
46+
}
47+
}
3648
}
3749

50+
// ── Types ────────────────────────────────────────
51+
52+
// AgentDef is now imported from utils/agent-loader.ts
53+
3854
interface AgentState {
3955
def: AgentDef;
4056
status: "idle" | "running" | "done" | "error";
@@ -74,34 +90,15 @@ function parseTeamsYaml(raw: string): Record<string, string[]> {
7490
return teams;
7591
}
7692

77-
// ── Frontmatter Parser ───────────────────────────
93+
// ── Frontmatter Parser (delegates to shared validated loader) ────
7894

7995
function parseAgentFile(filePath: string): AgentDef | null {
80-
try {
81-
const raw = readFileSync(filePath, "utf-8");
82-
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
83-
if (!match) return null;
84-
85-
const frontmatter: Record<string, string> = {};
86-
for (const line of match[1].split("\n")) {
87-
const idx = line.indexOf(":");
88-
if (idx > 0) {
89-
frontmatter[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
90-
}
91-
}
92-
93-
if (!frontmatter.name) return null;
94-
95-
return {
96-
name: frontmatter.name,
97-
description: frontmatter.description || "",
98-
tools: frontmatter.tools || "read,grep,find,ls",
99-
systemPrompt: match[2].trim(),
100-
file: filePath,
101-
};
102-
} catch {
103-
return null;
96+
const { agent, issues } = loadAgentFile(filePath);
97+
if (issues.length > 0) {
98+
const warnings = formatIssues(issues.filter(i => i.severity === "warning"), filePath);
99+
if (warnings) console.error(`[agent-team] ${warnings}`);
104100
}
101+
return agent;
105102
}
106103

107104
function scanAgentDirs(cwd: string): AgentDef[] {
@@ -158,7 +155,7 @@ export default function (pi: ExtensionAPI) {
158155
const teamsPath = join(cwd, ".pi", "agents", "teams.yaml");
159156
if (existsSync(teamsPath)) {
160157
try {
161-
teams = parseTeamsYaml(readFileSync(teamsPath, "utf-8"));
158+
teams = parseTeamsYaml(readFileSync(teamsPath, "utf-8").replace(/\r\n/g, "\n").replace(/\r/g, "\n"));
162159
} catch {
163160
teams = {};
164161
}
@@ -204,7 +201,7 @@ export default function (pi: ExtensionAPI) {
204201
// ── Grid Rendering ───────────────────────────
205202

206203
function renderCard(state: AgentState, colWidth: number, theme: any): string[] {
207-
const w = colWidth - 2;
204+
const w = Math.max(1, colWidth - 2);
208205
const truncate = (s: string, max: number) => s.length > max ? s.slice(0, max - 3) + "..." : s;
209206

210207
const statusColor = state.status === "idle" ? "dim"
@@ -267,7 +264,7 @@ export default function (pi: ExtensionAPI) {
267264

268265
const cols = Math.min(gridCols, agentStates.size);
269266
const gap = 1;
270-
const colWidth = Math.floor((width - gap * (cols - 1)) / cols);
267+
const colWidth = Math.max(3, Math.floor((width - gap * (cols - 1)) / cols));
271268
const agents = Array.from(agentStates.values());
272269
const rows: string[][] = [];
273270

@@ -344,10 +341,13 @@ export default function (pi: ExtensionAPI) {
344341
const agentSessionFile = join(sessionDir, `${agentKey}.json`);
345342

346343
// Build args — first run creates session, subsequent runs resume
344+
// Forward damage-control extension to subagents if it exists
345+
const dcExists = existsSync(DAMAGE_CONTROL_PATH);
346+
347347
const args = [
348348
"--mode", "json",
349349
"-p",
350-
"--no-extensions",
350+
...(dcExists ? ["-e", DAMAGE_CONTROL_PATH] : ["--no-extensions"]),
351351
"--model", model,
352352
"--tools", state.def.tools,
353353
"--thinking", "off",
@@ -365,7 +365,8 @@ export default function (pi: ExtensionAPI) {
365365
const textChunks: string[] = [];
366366

367367
return new Promise((resolve) => {
368-
const proc = spawn("pi", args, {
368+
const piCli = findPiCli();
369+
const proc = spawn(piCli.cmd, [...piCli.prefixArgs, ...args], {
369370
stdio: ["ignore", "pipe", "pipe"],
370371
env: { ...process.env },
371372
});

extensions/pi-pi.ts

Lines changed: 29 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,27 @@ import { spawn } from "child_process";
2222
import { readdirSync, readFileSync, existsSync, mkdirSync } from "fs";
2323
import { join, resolve } from "path";
2424
import { applyExtensionDefaults } from "./themeMap.ts";
25+
import { loadAgentFile, formatIssues, type AgentDef } from "./utils/agent-loader.ts";
26+
27+
// Resolve pi CLI script for snap-safe subprocess spawning (PR #13)
28+
function findPiCli(): { cmd: string; prefixArgs: string[] } {
29+
try {
30+
const piPath = require.resolve("@mariozechner/pi-coding-agent/dist/cli.js");
31+
return { cmd: process.execPath, prefixArgs: [piPath] };
32+
} catch {
33+
try {
34+
const piPath = require.resolve("@earendil-works/pi-coding-agent/dist/cli.js");
35+
return { cmd: process.execPath, prefixArgs: [piPath] };
36+
} catch {
37+
return { cmd: "pi", prefixArgs: [] };
38+
}
39+
}
40+
}
2541

2642
// ── Types ────────────────────────────────────────
2743

28-
interface ExpertDef {
29-
name: string;
30-
description: string;
31-
tools: string;
32-
systemPrompt: string;
33-
file: string;
34-
}
44+
// ExpertDef reuses the validated AgentDef shape from shared loader
45+
type ExpertDef = AgentDef;
3546

3647
interface ExpertState {
3748
def: ExpertDef;
@@ -50,31 +61,12 @@ function displayName(name: string): string {
5061
}
5162

5263
function parseAgentFile(filePath: string): ExpertDef | null {
53-
try {
54-
const raw = readFileSync(filePath, "utf-8");
55-
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
56-
if (!match) return null;
57-
58-
const frontmatter: Record<string, string> = {};
59-
for (const line of match[1].split("\n")) {
60-
const idx = line.indexOf(":");
61-
if (idx > 0) {
62-
frontmatter[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
63-
}
64-
}
65-
66-
if (!frontmatter.name) return null;
67-
68-
return {
69-
name: frontmatter.name,
70-
description: frontmatter.description || "",
71-
tools: frontmatter.tools || "read,grep,find,ls",
72-
systemPrompt: match[2].trim(),
73-
file: filePath,
74-
};
75-
} catch {
76-
return null;
64+
const { agent, issues } = loadAgentFile(filePath);
65+
if (issues.length > 0) {
66+
const warnings = formatIssues(issues.filter(i => i.severity === "warning"), filePath);
67+
if (warnings) console.error(`[pi-pi] ${warnings}`);
7768
}
69+
return agent;
7870
}
7971

8072
// ── Expert card colors ────────────────────────────
@@ -134,7 +126,7 @@ export default function (pi: ExtensionAPI) {
134126
// ── Grid Rendering ───────────────────────────
135127

136128
function renderCard(state: ExpertState, colWidth: number, theme: any): string[] {
137-
const w = colWidth - 2;
129+
const w = Math.max(1, colWidth - 2);
138130
const truncate = (s: string, max: number) => s.length > max ? s.slice(0, max - 3) + "..." : s;
139131

140132
const statusColor = state.status === "idle" ? "dim"
@@ -202,13 +194,13 @@ export default function (pi: ExtensionAPI) {
202194
return {
203195
render(width: number): string[] {
204196
if (experts.size === 0) {
205-
return ["", theme.fg("dim", " No experts found. Add agent .md files to .pi/agents/pi-pi/")];
197+
return ["", truncateToWidth(theme.fg("dim", " No experts found. Add agent .md files to .pi/agents/pi-pi/"), width, "")];
206198
}
207199

208200
const cols = Math.min(gridCols, experts.size);
209201
const gap = 1;
210202
// avoid Text component's ANSI-width miscounting by returning raw lines
211-
const colWidth = Math.floor((width - gap * (cols - 1)) / cols) - 1;
203+
const colWidth = Math.max(3, Math.floor((width - gap * (cols - 1)) / cols) - 1);
212204
const allExperts = Array.from(experts.values());
213205

214206
const lines: string[] = [""]; // top margin
@@ -291,7 +283,8 @@ export default function (pi: ExtensionAPI) {
291283
const textChunks: string[] = [];
292284

293285
return new Promise((resolve) => {
294-
const proc = spawn("pi", args, {
286+
const piCli = findPiCli();
287+
const proc = spawn(piCli.cmd, [...piCli.prefixArgs, ...args], {
295288
stdio: ["ignore", "pipe", "pipe"],
296289
env: { ...process.env },
297290
});
@@ -564,7 +557,7 @@ Ask specific questions about what you need to BUILD. Each expert will return doc
564557
const orchestratorPath = join(_ctx.cwd, ".pi", "agents", "pi-pi", "pi-orchestrator.md");
565558
let systemPrompt = "";
566559
try {
567-
const raw = readFileSync(orchestratorPath, "utf-8");
560+
const raw = readFileSync(orchestratorPath, "utf-8").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
568561
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
569562
const template = match ? match[2].trim() : raw;
570563

extensions/subagent-widget.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,24 @@ import * as os from "os";
2222
import * as path from "path";
2323
import { applyExtensionDefaults } from "./themeMap.ts";
2424

25+
// Resolve damage-control extension path for forwarding to subagents
26+
const DAMAGE_CONTROL_PATH = path.join(path.dirname(import.meta.url.replace("file://", "")), "damage-control.ts");
27+
28+
// Resolve pi CLI script for snap-safe subprocess spawning (PR #13)
29+
function findPiCli(): { cmd: string; prefixArgs: string[] } {
30+
try {
31+
const piPath = require.resolve("@mariozechner/pi-coding-agent/dist/cli.js");
32+
return { cmd: process.execPath, prefixArgs: [piPath] };
33+
} catch {
34+
try {
35+
const piPath = require.resolve("@earendil-works/pi-coding-agent/dist/cli.js");
36+
return { cmd: process.execPath, prefixArgs: [piPath] };
37+
} catch {
38+
return { cmd: "pi", prefixArgs: [] };
39+
}
40+
}
41+
}
42+
2543
interface SubState {
2644
id: number;
2745
status: "running" | "done" | "error";
@@ -139,11 +157,15 @@ export default function (pi: ExtensionAPI) {
139157
: "openrouter/google/gemini-3-flash-preview";
140158

141159
return new Promise<void>((resolve) => {
142-
const proc = spawn("pi", [
160+
// Forward damage-control extension to subagents if it exists
161+
const dcExists = fs.existsSync(DAMAGE_CONTROL_PATH);
162+
163+
const piCli = findPiCli();
164+
const proc = spawn(piCli.cmd, [...piCli.prefixArgs,
143165
"--mode", "json",
144166
"-p",
145167
"--session", state.sessionFile, // persistent session for /subcont resumption
146-
"--no-extensions",
168+
...(dcExists ? ["-e", DAMAGE_CONTROL_PATH] : ["--no-extensions"]),
147169
"--model", model,
148170
"--tools", "read,bash,grep,find,ls",
149171
"--thinking", "off",

0 commit comments

Comments
 (0)