@bacnh85/pi-subagent 0.6.1 → 0.7.1

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/agent-format.md CHANGED
@@ -21,9 +21,21 @@ description: ... # Required. When to use this agent.
21
21
  tools: read, grep, ... # Optional. Comma-separated tool names. Defaults to all.
22
22
  model: provider/model # Optional. Defaults to parent's model.
23
23
  thinking: low # Optional: off|minimal|low|medium|high|xhigh|max.
24
+ sandbox: read-only # Optional: read-only | workspace-write. Auto-derives tool restrictions.
25
+ color: cyan # Optional: red|blue|green|yellow|purple|orange|pink|cyan.
24
26
  ---
25
27
  ```
26
28
 
29
+ ### `sandbox`
30
+
31
+ - `read-only`: Restricts tools to `read`, `grep`, `find`, `ls`. Overrides any `tools` field.
32
+ - `workspace-write` (default): Uses the agent's `tools` list or defaults to all tools.
33
+
34
+ ### `color`
35
+
36
+ Display color for the agent name in the TUI thread picker, viewer, and result summary.
37
+ Accepted values: `red`, `blue`, `green`, `yellow`, `purple`, `orange`, `pink`, `cyan`.
38
+
27
39
  Only `name` and `description` are required.
28
40
 
29
41
  ## Body
@@ -2,6 +2,7 @@
2
2
  name: general-purpose
3
3
  description: General-purpose sub-agent for any delegated task. Use when no specialized agent fits. Good for complex research, multi-step operations, and code modifications.
4
4
  tools: read, bash, edit, write, grep, find, ls
5
+ color: yellow
5
6
  ---
6
7
 
7
8
  You are a capable coding assistant running as a sub-agent. Complete the delegated task efficiently and return a concise summary of your findings or changes.
@@ -3,6 +3,8 @@ name: reviewer
3
3
  description: Code review specialist. Use for correctness, security, regression, and meaningful test-gap review.
4
4
  tools: read, grep, find, ls
5
5
  thinking: high
6
+ color: purple
7
+ sandbox: read-only
6
8
  ---
7
9
 
8
10
  You are an independent senior code reviewer. Inspect the requested Git scope with read-only tools.
package/agents/scout.md CHANGED
@@ -3,6 +3,8 @@ name: scout
3
3
  description: Fast codebase recon that returns compressed context for handoff. Use for finding files, understanding structure, locating symbols.
4
4
  tools: read, grep, find, ls
5
5
  thinking: low
6
+ color: cyan
7
+ sandbox: read-only
6
8
  ---
7
9
 
8
10
  You are a scout. Quickly investigate a codebase and return structured findings that another agent can use without re-reading everything.
package/agents/worker.md CHANGED
@@ -2,6 +2,7 @@
2
2
  name: worker
3
3
  description: General-purpose coding agent with full tool access. Use only when explicitly requested for isolated implementation.
4
4
  thinking: medium
5
+ color: green
5
6
  ---
6
7
 
7
8
  You are a skilled software engineer. Implement the requested task with care and precision.
@@ -13,12 +13,16 @@ import { CONFIG_DIR_NAME, getAgentDir, parseFrontmatter } from "@earendil-works/
13
13
 
14
14
  export type AgentScope = "user" | "project" | "both";
15
15
 
16
+ export type AgentColor = "red" | "blue" | "green" | "yellow" | "purple" | "orange" | "pink" | "cyan";
17
+
16
18
  export interface AgentConfig {
17
19
  name: string;
18
20
  description: string;
19
21
  tools?: string[];
20
22
  model?: string;
21
23
  thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
24
+ sandbox?: "read-only" | "workspace-write";
25
+ color?: AgentColor;
22
26
  systemPrompt: string;
23
27
  source: "user" | "project" | "bundled";
24
28
  filePath: string;
@@ -160,6 +164,26 @@ function loadAgentsFromDir(
160
164
  }
161
165
  }
162
166
 
167
+ if (typeof frontmatter.sandbox === "string" && frontmatter.sandbox) {
168
+ const validSandboxes = ["read-only", "workspace-write"];
169
+ if (!validSandboxes.includes(frontmatter.sandbox)) {
170
+ diagnostics.push({
171
+ filePath,
172
+ issue: `Invalid sandbox mode "${frontmatter.sandbox}". Valid values: ${validSandboxes.join(", ")}. Ignoring.`,
173
+ severity: "warn",
174
+ });
175
+ }
176
+ }
177
+
178
+ const VALID_COLORS = ["red", "blue", "green", "yellow", "purple", "orange", "pink", "cyan"] as const;
179
+ if (typeof frontmatter.color === "string" && frontmatter.color && !VALID_COLORS.includes(frontmatter.color as any)) {
180
+ diagnostics.push({
181
+ filePath,
182
+ issue: `Invalid color "${frontmatter.color}". Valid values: ${VALID_COLORS.join(", ")}. Ignoring.`,
183
+ severity: "warn",
184
+ });
185
+ }
186
+
163
187
  agents.push({
164
188
  name: frontmatter.name,
165
189
  description: frontmatter.description,
@@ -168,6 +192,12 @@ function loadAgentsFromDir(
168
192
  thinking: typeof frontmatter.thinking === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(frontmatter.thinking)
169
193
  ? frontmatter.thinking as AgentConfig["thinking"]
170
194
  : undefined,
195
+ sandbox: typeof frontmatter.sandbox === "string" && ["read-only", "workspace-write"].includes(frontmatter.sandbox)
196
+ ? frontmatter.sandbox as "read-only" | "workspace-write"
197
+ : undefined,
198
+ color: typeof frontmatter.color === "string" && VALID_COLORS.includes(frontmatter.color as any)
199
+ ? frontmatter.color as AgentColor
200
+ : undefined,
171
201
  systemPrompt: body,
172
202
  source,
173
203
  filePath,
@@ -25,11 +25,12 @@ import {
25
25
  getAgentDir,
26
26
  getMarkdownTheme,
27
27
  ModelRegistry,
28
+ type ThemeColor,
28
29
  } from "@earendil-works/pi-coding-agent";
29
30
  import { Container, Markdown, SelectList, Spacer, Text } from "@earendil-works/pi-tui";
30
31
  import { Type } from "typebox";
31
32
 
32
- import { type AgentConfig, type AgentScope, discoverAgents, formatAgentList, invalidateAgentCache } from "./agents.ts";
33
+ import { type AgentColor, type AgentConfig, type AgentScope, discoverAgents, formatAgentList, invalidateAgentCache } from "./agents.ts";
33
34
  import {
34
35
  type SubAgentResult,
35
36
  getFinalOutput,
@@ -44,6 +45,7 @@ import {
44
45
  validateAgentTools,
45
46
  truncateParallelOutput,
46
47
  validateExecutionRequest,
48
+ READ_ONLY_TOOLS,
47
49
  MAX_CONCURRENCY,
48
50
  MAX_PARALLEL_TASKS,
49
51
  MAX_CHAIN_LENGTH,
@@ -153,21 +155,34 @@ export default function (pi: ExtensionAPI) {
153
155
  threadStore.clear();
154
156
  });
155
157
 
156
- // Proactively steer agents toward sub-agent delegation when users mention it
157
- pi.on("before_agent_start", async (event) => {
158
- const prompt = event.prompt.toLowerCase();
159
- if (/\b(delegate to|use a subagent|run in parallel|spawn an agent|scout|review this|chain|worker agent)\b/.test(prompt)) {
160
- return {
161
- systemPrompt:
162
- event.systemPrompt +
163
- "\n\nThe subagent tool is available for delegating tasks to specialized agents with isolated context. Use /subagent to list available agents. Bundled: scout (fast recon), reviewer (code review), worker (implementation), general-purpose (fallback). Modes: single, parallel (max 8), chain.",
164
- };
165
- }
166
- });
167
-
168
158
  // Resolve bundled agents directory relative to this extension file
169
159
  const bundledAgentsDir = path.resolve(__dirname, "../agents");
170
160
 
161
+ // Inject available agent catalog into system prompt for semantic auto-delegation
162
+ pi.on("before_agent_start", async (event) => {
163
+ const ctx = currentCtx;
164
+ const discovery = discoverAgents(ctx?.cwd ?? process.cwd(), "both", bundledAgentsDir);
165
+ const catalog = discovery.agents
166
+ .map((a) => {
167
+ const modelInfo = a.model ? ` (model: ${a.model})` : " (inherits parent)";
168
+ const thinkingInfo = a.thinking ? `, thinking: ${a.thinking}` : "";
169
+ const sandboxInfo = a.sandbox ? `, sandbox: ${a.sandbox}` : "";
170
+ return `- **${a.name}**: ${a.description}${modelInfo}${thinkingInfo}${sandboxInfo}`;
171
+ })
172
+ .join("\n");
173
+ return {
174
+ systemPrompt:
175
+ event.systemPrompt +
176
+ `\n\n## Available Subagents\n${catalog}\n\n` +
177
+ "The subagent tool can delegate tasks to these specialized agents with isolated context. " +
178
+ "Use for read-heavy exploration, parallel analysis, or work that would flood the main context.\n" +
179
+ "Prefer **scout** for fast read-only exploration. " +
180
+ "Prefer **reviewer** for code review (high thinking, read-only). " +
181
+ "Prefer **worker** for implementation (medium thinking, all tools). " +
182
+ "Modes: single, parallel (max 8 tasks, 4 concurrent), chain.",
183
+ };
184
+ });
185
+
171
186
  // Public one-request/one-response service used by pi-review.
172
187
  pi.events.on(SUBAGENT_REQUEST_EVENT, (raw) => {
173
188
  const request = raw as SubagentRunRequest;
@@ -179,7 +194,7 @@ export default function (pi: ExtensionAPI) {
179
194
  request.respond({ id: request.id, ok: false, error: `Unknown agent: ${request.agent}` });
180
195
  return;
181
196
  }
182
- const thread = threadStore.createThread({ agentName: agent.name, task: request.task, mode: "single" });
197
+ const thread = threadStore.createThread({ agentName: agent.name, task: request.task, mode: "single", color: agent.color ? AGENT_TO_THEME_COLOR[agent.color as AgentColor] : undefined });
183
198
  void runNamedAgent({
184
199
  agent: request.readOnly ? { ...agent, tools: ["read", "grep", "find", "ls"] } : agent,
185
200
  task: request.task,
@@ -276,6 +291,34 @@ export default function (pi: ExtensionAPI) {
276
291
  },
277
292
  });
278
293
 
294
+ /** Map AgentColor (from agent frontmatter) to ThemeColor (for pi TUI). */
295
+ const AGENT_TO_THEME_COLOR: Record<AgentColor, ThemeColor> = {
296
+ red: "error",
297
+ blue: "accent",
298
+ green: "success",
299
+ yellow: "warning",
300
+ purple: "syntaxType",
301
+ orange: "syntaxString",
302
+ pink: "customMessageLabel",
303
+ cyan: "syntaxVariable",
304
+ };
305
+
306
+ /** Resolve agent-defined color to a valid ThemeColor for thread creation. */
307
+ const agentToThemeColor = (agentName: string): ThemeColor | undefined => {
308
+ const ctx = currentCtx;
309
+ if (!ctx) return undefined;
310
+ const agent = discoverAgents(ctx.cwd, "both", bundledAgentsDir).agents.find(a => a.name === agentName);
311
+ return agent?.color ? AGENT_TO_THEME_COLOR[agent.color] : undefined;
312
+ };
313
+
314
+ /** Look up agent color by name for TUI rendering. */
315
+ const resolveAgentColor = (name: string): ThemeColor => {
316
+ const ctx = currentCtx;
317
+ if (!ctx) return "accent";
318
+ const found = discoverAgents(ctx.cwd, "both", bundledAgentsDir).agents.find(a => a.name === name);
319
+ return found?.color ? AGENT_TO_THEME_COLOR[found.color] : "accent";
320
+ };
321
+
279
322
  pi.registerTool({
280
323
  name: "subagent",
281
324
  label: "Subagent",
@@ -425,10 +468,16 @@ export default function (pi: ExtensionAPI) {
425
468
  }
426
469
 
427
470
  // Helper: validate and normalise tools for an agent.
428
- function resolveChildTools(agentTools: string[] | undefined, readOnly?: boolean): string[] {
471
+ function resolveChildTools(agentTools: string[] | undefined, sandbox?: string, readOnly?: boolean): string[] {
429
472
  const defaultTools = ["read", "bash", "edit", "write", "grep", "find", "ls"];
430
- const rawTools = agentTools ?? defaultTools;
431
- const result = validateAgentTools({ tools: rawTools, readOnly });
473
+ let rawTools = agentTools ?? defaultTools;
474
+ // sandbox overrides tools: silently strip mutation tools, not an error
475
+ if (sandbox === "read-only") {
476
+ rawTools = rawTools.filter(t => READ_ONLY_TOOLS.includes(t));
477
+ if (rawTools.length === 0) rawTools = [...READ_ONLY_TOOLS];
478
+ }
479
+ const effectiveReadOnly = readOnly || sandbox === "read-only";
480
+ const result = validateAgentTools({ tools: rawTools, readOnly: effectiveReadOnly });
432
481
  if (result.errors.length > 0) {
433
482
  throw new Error(`Tool validation errors: ${result.errors.join("; ")}`);
434
483
  }
@@ -492,7 +541,7 @@ export default function (pi: ExtensionAPI) {
492
541
  try {
493
542
  // Inject parent's API key so --api-key and other runtime overrides work
494
543
  await injectApiKey(resolved.model);
495
- tools = resolveChildTools(agent.tools, isReadOnly);
544
+ tools = resolveChildTools(agent.tools, agent.sandbox, isReadOnly);
496
545
  effectiveTimeoutMs = resolveChildTimeout(timeoutMs, params.timeout);
497
546
  safeCwd = resolveChildCwd(cwd);
498
547
  } catch (err: unknown) {
@@ -541,6 +590,7 @@ export default function (pi: ExtensionAPI) {
541
590
  task: taskWithContext,
542
591
  mode: "chain-step",
543
592
  toolCallId: _toolCallId,
593
+ color: agentToThemeColor(step.agent),
544
594
  });
545
595
  const result = await runOne(
546
596
  step.agent, taskWithContext, step.cwd,
@@ -632,6 +682,7 @@ export default function (pi: ExtensionAPI) {
632
682
  task: t.task,
633
683
  mode: "parallel-task",
634
684
  toolCallId: _toolCallId,
685
+ color: agentToThemeColor(t.agent),
635
686
  }),
636
687
  );
637
688
 
@@ -746,6 +797,7 @@ export default function (pi: ExtensionAPI) {
746
797
  task: params.task,
747
798
  mode: "single",
748
799
  toolCallId: _toolCallId,
800
+ color: agentToThemeColor(params.agent),
749
801
  });
750
802
  const result = await runOne(
751
803
  params.agent, params.task, params.cwd,
@@ -816,7 +868,7 @@ export default function (pi: ExtensionAPI) {
816
868
  "\n " +
817
869
  fg("muted", `${i + 1}.`) +
818
870
  " " +
819
- fg("accent", step.agent) +
871
+ fg(resolveAgentColor(step.agent), step.agent) +
820
872
  fg("dim", ` ${preview}`);
821
873
  }
822
874
  if (args.chain.length > 3)
@@ -832,7 +884,7 @@ export default function (pi: ExtensionAPI) {
832
884
  fg("muted", ` [${scope}]`);
833
885
  for (const t of args.tasks.slice(0, 3)) {
834
886
  const preview = t.task.length > 40 ? `${t.task.slice(0, 40)}...` : t.task;
835
- text += `\n ${fg("accent", t.agent)}${fg("dim", ` ${preview}`)}`;
887
+ text += `\n ${fg(resolveAgentColor(t.agent), t.agent)}${fg("dim", ` ${preview}`)}`;
836
888
  }
837
889
  if (args.tasks.length > 3)
838
890
  text += `\n ${fg("muted", `... +${args.tasks.length - 3} more`)}`;
@@ -848,7 +900,7 @@ export default function (pi: ExtensionAPI) {
848
900
  : "...";
849
901
  let text =
850
902
  fg("toolTitle", theme.bold("subagent ")) +
851
- fg("accent", agentName) +
903
+ fg(resolveAgentColor(agentName), agentName) +
852
904
  fg("muted", ` [${scope}]`);
853
905
  text += `\n ${fg("dim", preview)}`;
854
906
  return new Text(text, 0, 0);
@@ -866,7 +918,8 @@ export default function (pi: ExtensionAPI) {
866
918
 
867
919
  // --- Single ---
868
920
  if (details.mode === "single" && details.results.length === 1) {
869
- return renderSingleResult(details.results[0], expanded, theme);
921
+ const r = details.results[0];
922
+ return renderSingleResult(r, expanded, theme, resolveAgentColor(r.agent));
870
923
  }
871
924
 
872
925
  // --- Chain ---
@@ -895,7 +948,7 @@ export default function (pi: ExtensionAPI) {
895
948
  container.addChild(
896
949
  new Text(
897
950
  fg("muted", `─── Step ${r.exitCode !== -1 ? "" : "?"}: `) +
898
- fg("accent", r.agent) +
951
+ fg(resolveAgentColor(r.agent), r.agent) +
899
952
  ` ${stepIcon}`,
900
953
  0,
901
954
  0,
@@ -930,7 +983,8 @@ export default function (pi: ExtensionAPI) {
930
983
  fg("accent", `${successCount}/${details.results.length} steps`);
931
984
  for (const r of details.results) {
932
985
  const stepIcon = isFailedResult(r) ? fg("error", "✗") : fg("success", "✓");
933
- text += `\n ${stepIcon} ${fg("accent", r.agent)}`;
986
+ const color = resolveAgentColor(r.agent);
987
+ text += `\n ${stepIcon} ${fg(color, r.agent)}`;
934
988
  }
935
989
  const totalUsage = formatUsageStats(aggregateUsage(details.results));
936
990
  if (totalUsage) text += `\n${fg("dim", totalUsage)}`;
@@ -973,7 +1027,7 @@ export default function (pi: ExtensionAPI) {
973
1027
  : fg("success", "✓");
974
1028
  container.addChild(
975
1029
  new Text(
976
- fg("muted", "─── ") + fg("accent", r.agent) + ` ${taskIcon}`,
1030
+ fg("muted", "─── ") + fg(resolveAgentColor(r.agent), r.agent) + ` ${taskIcon}`,
977
1031
  0,
978
1032
  0,
979
1033
  ),
@@ -1013,7 +1067,7 @@ export default function (pi: ExtensionAPI) {
1013
1067
  : isFailedResult(r)
1014
1068
  ? fg("error", "✗")
1015
1069
  : fg("success", "✓");
1016
- text += `\n ${taskIcon} ${fg("accent", r.agent)}`;
1070
+ text += `\n ${taskIcon} ${fg(resolveAgentColor(r.agent), r.agent)}`;
1017
1071
  }
1018
1072
  if (!isRunning) {
1019
1073
  const totalUsage = formatUsageStats(aggregateUsage(details.results));
@@ -188,6 +188,7 @@ export function renderSingleResult(
188
188
  result: SubAgentResult,
189
189
  expanded: boolean,
190
190
  theme: { fg: (c: any, t: string) => string; bold: (t: string) => string },
191
+ agentColor?: string,
191
192
  ): Container | Text {
192
193
  const isError = isFailedResult(result);
193
194
  const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
@@ -197,7 +198,7 @@ export function renderSingleResult(
197
198
  if (expanded) {
198
199
  const mdTheme = getMarkdownTheme();
199
200
  const container = new Container();
200
- let header = `${icon} ${theme.fg("toolTitle", theme.bold(result.agent))}`;
201
+ let header = `${icon} ${theme.fg(agentColor ?? "toolTitle", theme.bold(result.agent))}`;
201
202
  if (isError && result.stopReason) {
202
203
  const reasonColor = result.stopReason === "timeout" ? "warning" : "error";
203
204
  header += ` ${theme.fg(reasonColor, `[${result.stopReason}]`)}`;
@@ -239,7 +240,7 @@ export function renderSingleResult(
239
240
  }
240
241
 
241
242
  // Collapsed
242
- let text = `${icon} ${theme.fg("toolTitle", theme.bold(result.agent))}`;
243
+ let text = `${icon} ${theme.fg(agentColor ?? "toolTitle", theme.bold(result.agent))}`;
243
244
  if (isError && result.stopReason) {
244
245
  const reasonColor = result.stopReason === "timeout" ? "warning" : "error";
245
246
  text += ` ${theme.fg(reasonColor, `[${result.stopReason}]`)}`;
@@ -123,7 +123,8 @@ export class ThreadViewer {
123
123
  else if (this.thread.mode === "chain-step") modeLabel = t.fg("muted", " [chain]");
124
124
 
125
125
  // Header
126
- let header = `${icon} ${t.fg("toolTitle", t.bold(this.thread.agentName))}${modeLabel}`;
126
+ const agentColor = this.thread.color ?? "accent";
127
+ let header = `${icon} ${t.fg(agentColor, t.bold(this.thread.agentName))}${modeLabel}`;
127
128
  if (status === "running") header += ` ${t.fg("warning", "(running...)")}`;
128
129
  else if (status === "aborted") header += ` ${t.fg("error", "[aborted]")}`;
129
130
  if (result && isErr && result.stopReason && result.stopReason !== "error" && result.stopReason !== "aborted") {
@@ -23,6 +23,7 @@ export interface SubagentThread {
23
23
  status: ThreadStatus;
24
24
  result?: SubAgentResult;
25
25
  toolCallId?: string;
26
+ color?: string;
26
27
  createdAt: number;
27
28
  updatedAt: number;
28
29
  }
@@ -53,6 +54,7 @@ export class ThreadStore {
53
54
  task: string;
54
55
  mode: ThreadMode;
55
56
  toolCallId?: string;
57
+ color?: string;
56
58
  }): SubagentThread {
57
59
  const id = cryptoGenId();
58
60
  const now = Date.now();
@@ -63,6 +65,7 @@ export class ThreadStore {
63
65
  mode: params.mode,
64
66
  status: "running",
65
67
  toolCallId: params.toolCallId,
68
+ color: params.color,
66
69
  createdAt: now,
67
70
  updatedAt: now,
68
71
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-subagent",
3
- "version": "0.6.1",
3
+ "version": "0.7.1",
4
4
  "description": "In-process subagents for Pi with isolated SDK sessions, parallel and chained delegation, and inspectable threads.",
5
5
  "type": "module",
6
6
  "license": "MIT",