@oh-my-pi/pi-coding-agent 3.0.1337 → 3.1.1337

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/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [3.1.1337] - 2026-01-03
6
+ ### Added
7
+
8
+ - Added `spawns` frontmatter field for agent definitions to control which sub-agents can be spawned
9
+ - Added spawn restriction enforcement preventing agents from spawning unauthorized sub-agents
10
+
11
+ ### Fixed
12
+
13
+ - Fixed duplicate skill loading when the same SKILL.md file was discovered through multiple paths
14
+
5
15
  ## [3.0.1337] - 2026-01-03
6
16
 
7
17
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "3.0.1337",
3
+ "version": "3.1.1337",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -39,9 +39,9 @@
39
39
  "prepublishOnly": "bun run generate-template && bun run clean && bun run build"
40
40
  },
41
41
  "dependencies": {
42
- "@oh-my-pi/pi-agent-core": "3.0.1337",
43
- "@oh-my-pi/pi-ai": "3.0.1337",
44
- "@oh-my-pi/pi-tui": "3.0.1337",
42
+ "@oh-my-pi/pi-agent-core": "3.1.1337",
43
+ "@oh-my-pi/pi-ai": "3.1.1337",
44
+ "@oh-my-pi/pi-tui": "3.1.1337",
45
45
  "@sinclair/typebox": "^0.34.46",
46
46
  "ajv": "^8.17.1",
47
47
  "chalk": "^5.5.0",
@@ -46,6 +46,30 @@ export interface LoadSkillsFromDirOptions {
46
46
  export function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkillsResult {
47
47
  const skills: Skill[] = [];
48
48
  const warnings: SkillWarning[] = [];
49
+ const seenPaths = new Set<string>();
50
+
51
+ function addSkill(skillFile: string, skillDir: string, dirName: string) {
52
+ if (seenPaths.has(skillFile)) return;
53
+ try {
54
+ const content = readFileSync(skillFile, "utf-8");
55
+ const { frontmatter } = parseFrontmatter(content);
56
+ const name = (frontmatter.name as string) || dirName;
57
+ const description = frontmatter.description as string;
58
+
59
+ if (description) {
60
+ seenPaths.add(skillFile);
61
+ skills.push({
62
+ name,
63
+ description,
64
+ filePath: skillFile,
65
+ baseDir: skillDir,
66
+ source: options.source,
67
+ });
68
+ }
69
+ } catch {
70
+ // Skip invalid skills
71
+ }
72
+ }
49
73
 
50
74
  function scanDir(dir: string) {
51
75
  try {
@@ -59,45 +83,14 @@ export function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkills
59
83
  try {
60
84
  const stat = statSync(skillFile);
61
85
  if (stat.isFile()) {
62
- const content = readFileSync(skillFile, "utf-8");
63
- const { frontmatter } = parseFrontmatter(content);
64
- const name = (frontmatter.name as string) || entry.name;
65
- const description = frontmatter.description as string;
66
-
67
- if (description) {
68
- skills.push({
69
- name,
70
- description,
71
- filePath: skillFile,
72
- baseDir: fullPath,
73
- source: options.source,
74
- });
75
- }
86
+ addSkill(skillFile, fullPath, entry.name);
76
87
  }
77
88
  } catch {
78
- // Skip invalid skills
89
+ // No SKILL.md in this directory
79
90
  }
80
-
81
91
  scanDir(fullPath);
82
92
  } else if (entry.isFile() && entry.name === "SKILL.md") {
83
- try {
84
- const content = readFileSync(fullPath, "utf-8");
85
- const { frontmatter } = parseFrontmatter(content);
86
- const name = (frontmatter.name as string) || basename(dir);
87
- const description = frontmatter.description as string;
88
-
89
- if (description) {
90
- skills.push({
91
- name,
92
- description,
93
- filePath: fullPath,
94
- baseDir: dir,
95
- source: options.source,
96
- });
97
- }
98
- } catch {
99
- // Skip invalid skills
100
- }
93
+ addSkill(fullPath, dir, basename(dir));
101
94
  }
102
95
  }
103
96
  } catch (err) {
@@ -117,6 +110,30 @@ export function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkills
117
110
  function scanDirectoryForSkills(dir: string): LoadSkillsResult {
118
111
  const skills: Skill[] = [];
119
112
  const warnings: SkillWarning[] = [];
113
+ const seenPaths = new Set<string>();
114
+
115
+ function addSkill(skillFile: string, skillDir: string, dirName: string) {
116
+ if (seenPaths.has(skillFile)) return;
117
+ try {
118
+ const content = readFileSync(skillFile, "utf-8");
119
+ const { frontmatter } = parseFrontmatter(content);
120
+ const name = (frontmatter.name as string) || dirName;
121
+ const description = frontmatter.description as string;
122
+
123
+ if (description) {
124
+ seenPaths.add(skillFile);
125
+ skills.push({
126
+ name,
127
+ description,
128
+ filePath: skillFile,
129
+ baseDir: skillDir,
130
+ source: "custom",
131
+ });
132
+ }
133
+ } catch {
134
+ // Skip invalid skills
135
+ }
136
+ }
120
137
 
121
138
  function scanDir(currentDir: string) {
122
139
  try {
@@ -130,45 +147,14 @@ function scanDirectoryForSkills(dir: string): LoadSkillsResult {
130
147
  try {
131
148
  const stat = statSync(skillFile);
132
149
  if (stat.isFile()) {
133
- const content = readFileSync(skillFile, "utf-8");
134
- const { frontmatter } = parseFrontmatter(content);
135
- const name = (frontmatter.name as string) || entry.name;
136
- const description = frontmatter.description as string;
137
-
138
- if (description) {
139
- skills.push({
140
- name,
141
- description,
142
- filePath: skillFile,
143
- baseDir: fullPath,
144
- source: "custom",
145
- });
146
- }
150
+ addSkill(skillFile, fullPath, entry.name);
147
151
  }
148
152
  } catch {
149
- // Skip invalid skills
153
+ // No SKILL.md in this directory
150
154
  }
151
-
152
155
  scanDir(fullPath);
153
156
  } else if (entry.isFile() && entry.name === "SKILL.md") {
154
- try {
155
- const content = readFileSync(fullPath, "utf-8");
156
- const { frontmatter } = parseFrontmatter(content);
157
- const name = (frontmatter.name as string) || basename(currentDir);
158
- const description = frontmatter.description as string;
159
-
160
- if (description) {
161
- skills.push({
162
- name,
163
- description,
164
- filePath: fullPath,
165
- baseDir: currentDir,
166
- source: "custom",
167
- });
168
- }
169
- } catch {
170
- // Skip invalid skills
171
- }
157
+ addSkill(fullPath, currentDir, basename(currentDir));
172
158
  }
173
159
  }
174
160
  } catch (err) {
@@ -68,6 +68,26 @@ function parseAgent(fileName: string, content: string, source: AgentSource): Age
68
68
  .map((t) => t.trim())
69
69
  .filter(Boolean);
70
70
 
71
+ // Parse spawns field
72
+ let spawns: string[] | "*" | undefined;
73
+ if (frontmatter.spawns !== undefined) {
74
+ const spawnsRaw = frontmatter.spawns.trim();
75
+ if (spawnsRaw === "*") {
76
+ spawns = "*";
77
+ } else if (spawnsRaw) {
78
+ spawns = spawnsRaw
79
+ .split(",")
80
+ .map((s) => s.trim())
81
+ .filter(Boolean);
82
+ if (spawns.length === 0) spawns = undefined;
83
+ }
84
+ }
85
+
86
+ // Backward compat: infer spawns: "*" when tools includes "task"
87
+ if (spawns === undefined && tools?.includes("task")) {
88
+ spawns = "*";
89
+ }
90
+
71
91
  const recursive =
72
92
  frontmatter.recursive === undefined ? false : frontmatter.recursive === "true" || frontmatter.recursive === "1";
73
93
 
@@ -75,6 +95,7 @@ function parseAgent(fileName: string, content: string, source: AgentSource): Age
75
95
  name: frontmatter.name,
76
96
  description: frontmatter.description,
77
97
  tools: tools && tools.length > 0 ? tools : undefined,
98
+ spawns,
78
99
  model: frontmatter.model,
79
100
  recursive,
80
101
  systemPrompt: body,
@@ -1,7 +1,8 @@
1
1
  ---
2
2
  name: reviewer
3
3
  description: Code review specialist for quality and security analysis
4
- tools: read, grep, find, ls, bash, task, report_finding, submit_review
4
+ tools: read, grep, find, ls, bash, report_finding, submit_review
5
+ spawns: explore
5
6
  model: pi/slow, gpt-5.2-codex, gpt-5.2, codex, gpt
6
7
  ---
7
8
 
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: task
3
3
  description: General-purpose subagent with full capabilities for delegated multi-step tasks
4
+ spawns: explore
4
5
  model: default
5
6
  ---
6
7
 
@@ -106,6 +106,26 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentDefinition[]
106
106
  .map((t) => t.trim())
107
107
  .filter(Boolean);
108
108
 
109
+ // Parse spawns field
110
+ let spawns: string[] | "*" | undefined;
111
+ if (frontmatter.spawns !== undefined) {
112
+ const spawnsRaw = frontmatter.spawns.trim();
113
+ if (spawnsRaw === "*") {
114
+ spawns = "*";
115
+ } else if (spawnsRaw) {
116
+ spawns = spawnsRaw
117
+ .split(",")
118
+ .map((s) => s.trim())
119
+ .filter(Boolean);
120
+ if (spawns.length === 0) spawns = undefined;
121
+ }
122
+ }
123
+
124
+ // Backward compat: infer spawns: "*" when tools includes "task"
125
+ if (spawns === undefined && tools?.includes("task")) {
126
+ spawns = "*";
127
+ }
128
+
109
129
  const recursive =
110
130
  frontmatter.recursive === undefined
111
131
  ? undefined
@@ -115,6 +135,7 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentDefinition[]
115
135
  name: frontmatter.name,
116
136
  description: frontmatter.description,
117
137
  tools: tools && tools.length > 0 ? tools : undefined,
138
+ spawns,
118
139
  model: frontmatter.model,
119
140
  recursive,
120
141
  systemPrompt: body,
@@ -19,6 +19,7 @@ import {
19
19
  MAX_OUTPUT_BYTES,
20
20
  MAX_OUTPUT_LINES,
21
21
  OMP_BLOCKED_AGENT_ENV,
22
+ OMP_SPAWNS_ENV,
22
23
  type SingleResult,
23
24
  } from "./types";
24
25
 
@@ -193,7 +194,12 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
193
194
 
194
195
  // Add tools if specified
195
196
  if (agent.tools && agent.tools.length > 0) {
196
- args.push("--tools", agent.tools.join(","));
197
+ let toolList = agent.tools;
198
+ // Auto-include task tool if spawns defined but task not in tools
199
+ if (agent.spawns !== undefined && !toolList.includes("task")) {
200
+ toolList = [...toolList, "task"];
201
+ }
202
+ args.push("--tools", toolList.join(","));
197
203
  }
198
204
 
199
205
  // Resolve and add model
@@ -220,6 +226,15 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
220
226
  env[OMP_BLOCKED_AGENT_ENV] = agent.name;
221
227
  }
222
228
 
229
+ // Propagate spawn restrictions to subprocess
230
+ if (agent.spawns === undefined) {
231
+ env[OMP_SPAWNS_ENV] = ""; // No spawns = deny all
232
+ } else if (agent.spawns === "*") {
233
+ env[OMP_SPAWNS_ENV] = "*";
234
+ } else {
235
+ env[OMP_SPAWNS_ENV] = agent.spawns.join(",");
236
+ }
237
+
223
238
  // Spawn subprocess
224
239
  const proc = spawn(OMP_CMD, args, {
225
240
  cwd,
@@ -27,6 +27,7 @@ import {
27
27
  MAX_PARALLEL_TASKS,
28
28
  OMP_BLOCKED_AGENT_ENV,
29
29
  OMP_NO_SUBAGENTS_ENV,
30
+ OMP_SPAWNS_ENV,
30
31
  type TaskToolDetails,
31
32
  taskSchema,
32
33
  } from "./types";
@@ -321,6 +322,30 @@ export function createTaskTool(
321
322
  }
322
323
  }
323
324
 
325
+ // Check spawn restrictions from parent
326
+ const parentSpawns = process.env[OMP_SPAWNS_ENV];
327
+ const isSpawnAllowed = (agentName: string): boolean => {
328
+ if (parentSpawns === undefined) return true; // Root = allow all
329
+ if (parentSpawns === "") return false; // Empty = deny all
330
+ if (parentSpawns === "*") return true; // Wildcard = allow all
331
+ const allowed = new Set(parentSpawns.split(",").map((s) => s.trim()));
332
+ return allowed.has(agentName);
333
+ };
334
+
335
+ for (const task of tasks) {
336
+ if (!isSpawnAllowed(task.agent)) {
337
+ const allowed = parentSpawns === "" ? "none (spawns disabled for this agent)" : parentSpawns;
338
+ return {
339
+ content: [{ type: "text", text: `Cannot spawn '${task.agent}'. Allowed: ${allowed}` }],
340
+ details: {
341
+ projectAgentsDir,
342
+ results: [],
343
+ totalDurationMs: Date.now() - startTime,
344
+ },
345
+ };
346
+ }
347
+ }
348
+
324
349
  // Initialize progress for all tasks
325
350
  for (let i = 0; i < tasks.length; i++) {
326
351
  const agentCfg = getAgent(agents, tasks[i].agent);
@@ -33,6 +33,9 @@ export const OMP_NO_SUBAGENTS_ENV = "OMP_NO_SUBAGENTS";
33
33
  /** Environment variable containing blocked agent name (self-recursion prevention) */
34
34
  export const OMP_BLOCKED_AGENT_ENV = "OMP_BLOCKED_AGENT";
35
35
 
36
+ /** Environment variable containing allowed spawn list (propagated to subprocesses) */
37
+ export const OMP_SPAWNS_ENV = "OMP_SPAWNS";
38
+
36
39
  /** Task tool parameters */
37
40
  export const taskSchema = Type.Object({
38
41
  context: Type.Optional(Type.String({ description: "Shared context prepended to all task prompts" })),
@@ -74,6 +77,7 @@ export interface AgentDefinition {
74
77
  description: string;
75
78
  systemPrompt: string;
76
79
  tools?: string[];
80
+ spawns?: string[] | "*";
77
81
  model?: string;
78
82
  recursive?: boolean;
79
83
  source: AgentSource;
@@ -2414,10 +2414,30 @@ export class InteractiveMode {
2414
2414
 
2415
2415
  const renderSourceText = (text: string): string => text.replace(/\bvia\b/, theme.italic("via"));
2416
2416
 
2417
- const truncateText = (text: string, maxLen: number): string => {
2418
- if (text.length <= maxLen) return text;
2419
- if (maxLen <= 3) return text.slice(0, Math.max(0, maxLen));
2420
- return `${text.slice(0, maxLen - 3)}...`;
2417
+ const truncateText = (text: string, maxWidth: number): string => {
2418
+ const textWidth = visibleWidth(text);
2419
+ if (textWidth <= maxWidth) return text;
2420
+ if (maxWidth <= 3) {
2421
+ let acc = "";
2422
+ let width = 0;
2423
+ for (const char of text) {
2424
+ const charWidth = visibleWidth(char);
2425
+ if (width + charWidth > maxWidth) break;
2426
+ width += charWidth;
2427
+ acc += char;
2428
+ }
2429
+ return acc;
2430
+ }
2431
+ const targetWidth = maxWidth - 3;
2432
+ let acc = "";
2433
+ let width = 0;
2434
+ for (const char of text) {
2435
+ const charWidth = visibleWidth(char);
2436
+ if (width + charWidth > targetWidth) break;
2437
+ width += charWidth;
2438
+ acc += char;
2439
+ }
2440
+ return `${acc}...`;
2421
2441
  };
2422
2442
 
2423
2443
  // Helper to format a section with consistent column alignment
@@ -2438,26 +2458,28 @@ export class InteractiveMode {
2438
2458
  return { name, sourceText, nameWithSource, desc };
2439
2459
  });
2440
2460
 
2441
- const maxNameWidth = Math.min(60, Math.max(...lineItems.map((line) => line.nameWithSource.length)));
2461
+ const maxNameWidth = Math.min(
2462
+ 60,
2463
+ Math.max(...lineItems.map((line) => visibleWidth(line.nameWithSource))),
2464
+ );
2442
2465
  const formattedLines = lineItems.map((line) => {
2443
2466
  let nameText = line.name;
2444
2467
  let sourceText = line.sourceText;
2445
2468
 
2446
2469
  if (sourceText) {
2447
- let availableForName = maxNameWidth - sourceText.length - 1;
2448
- if (availableForName < 1) {
2449
- sourceText = truncateText(sourceText, Math.max(0, maxNameWidth - 4));
2450
- availableForName = maxNameWidth - sourceText.length - 1;
2451
- }
2452
- nameText = truncateText(nameText, Math.max(1, availableForName));
2453
- } else {
2454
- nameText = truncateText(nameText, maxNameWidth);
2470
+ const maxSourceWidth = Math.max(0, maxNameWidth - 2);
2471
+ sourceText = truncateText(sourceText, maxSourceWidth);
2455
2472
  }
2473
+ const sourceWidth = sourceText ? visibleWidth(sourceText) : 0;
2474
+ const availableForName = sourceText
2475
+ ? Math.max(1, maxNameWidth - sourceWidth - 1)
2476
+ : maxNameWidth;
2477
+ nameText = truncateText(nameText, availableForName);
2456
2478
 
2457
2479
  const nameWithSourcePlain = sourceText ? `${nameText} ${sourceText}` : nameText;
2458
2480
  const sourceRendered = sourceText ? renderSourceText(sourceText) : "";
2459
2481
  const nameRendered = sourceText ? `${theme.bold(nameText)} ${sourceRendered}` : theme.bold(nameText);
2460
- const pad = Math.max(0, maxNameWidth - nameWithSourcePlain.length);
2482
+ const pad = Math.max(0, maxNameWidth - visibleWidth(nameWithSourcePlain));
2461
2483
  const desc = line.desc?.trim();
2462
2484
  const descPart = desc ? ` ${theme.fg("dim", desc.slice(0, 50) + (desc.length > 50 ? "..." : ""))}` : "";
2463
2485
  return ` ${nameRendered}${" ".repeat(pad)}${descPart}`;