@bacnh85/pi-subagent 0.7.0 → 0.8.0

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
@@ -48,6 +48,7 @@ interface AgentCache {
48
48
  scope: AgentScope;
49
49
  agents: AgentConfig[];
50
50
  projectAgentsDir: string | null;
51
+ diagnostics: AgentDiscoveryDiagnostic[];
51
52
  /** File-level signature per directory (name:mtime:size for each .md file) */
52
53
  dirSignatures: Map<string, string>;
53
54
  }
@@ -104,7 +105,22 @@ function loadAgentsFromDir(
104
105
  continue;
105
106
  }
106
107
 
107
- const { frontmatter, body } = parseFrontmatter<Record<string, unknown>>(content);
108
+
109
+
110
+ let frontmatter: Record<string, unknown>;
111
+ let body: string;
112
+ try {
113
+ const parsed = parseFrontmatter<Record<string, unknown>>(content);
114
+ frontmatter = parsed.frontmatter;
115
+ body = parsed.body;
116
+ } catch (err) {
117
+ diagnostics.push({
118
+ filePath,
119
+ issue: `Failed to parse YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`,
120
+ severity: "error",
121
+ });
122
+ continue;
123
+ }
108
124
 
109
125
  if (typeof frontmatter.name !== "string" || typeof frontmatter.description !== "string") {
110
126
  if (typeof frontmatter.name !== "string" && typeof frontmatter.description !== "string") {
@@ -169,7 +185,7 @@ function loadAgentsFromDir(
169
185
  if (!validSandboxes.includes(frontmatter.sandbox)) {
170
186
  diagnostics.push({
171
187
  filePath,
172
- issue: `Invalid sandbox mode "${frontmatter.sandbox}". Valid values: ${validSandboxes.join(", ")}. Using default.`,
188
+ issue: `Invalid sandbox mode "${frontmatter.sandbox}". Valid values: ${validSandboxes.join(", ")}. Ignoring.`,
173
189
  severity: "warn",
174
190
  });
175
191
  }
@@ -225,8 +241,12 @@ function dirSignature(dir: string): string {
225
241
  .filter((e) => e.name.endsWith(".md") && (e.isFile() || e.isSymbolicLink()))
226
242
  .map((e) => {
227
243
  const file = path.join(dir, e.name);
228
- const st = fs.statSync(file);
229
- return `${e.name}:${st.mtimeMs}:${st.size}`;
244
+ try {
245
+ const st = fs.statSync(file);
246
+ return `${e.name}:${st.mtimeMs}:${st.size}`;
247
+ } catch {
248
+ return `${e.name}:broken`;
249
+ }
230
250
  })
231
251
  .sort();
232
252
  return `exists:${entries.join("|")}`;
@@ -277,7 +297,7 @@ export function discoverAgents(
277
297
  }
278
298
  }
279
299
  if (!stale) {
280
- return { agents: _cache.agents, projectAgentsDir: _cache.projectAgentsDir, diagnostics: [] };
300
+ return { agents: _cache.agents, projectAgentsDir: _cache.projectAgentsDir, diagnostics: _cache.diagnostics };
281
301
  }
282
302
  // Cache is stale — rebuild below
283
303
  _cache = null;
@@ -316,6 +336,7 @@ export function discoverAgents(
316
336
  scope,
317
337
  agents,
318
338
  projectAgentsDir,
339
+ diagnostics,
319
340
  dirSignatures,
320
341
  };
321
342
 
@@ -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,
@@ -159,7 +161,7 @@ export default function (pi: ExtensionAPI) {
159
161
  // Inject available agent catalog into system prompt for semantic auto-delegation
160
162
  pi.on("before_agent_start", async (event) => {
161
163
  const ctx = currentCtx;
162
- const discovery = discoverAgents(event.cwd ?? ctx?.cwd ?? process.cwd(), "both", bundledAgentsDir);
164
+ const discovery = discoverAgents(ctx?.cwd ?? process.cwd(), "both", bundledAgentsDir);
163
165
  const catalog = discovery.agents
164
166
  .map((a) => {
165
167
  const modelInfo = a.model ? ` (model: ${a.model})` : " (inherits parent)";
@@ -192,7 +194,7 @@ export default function (pi: ExtensionAPI) {
192
194
  request.respond({ id: request.id, ok: false, error: `Unknown agent: ${request.agent}` });
193
195
  return;
194
196
  }
195
- const thread = threadStore.createThread({ agentName: agent.name, task: request.task, mode: "single", color: agent.color });
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 });
196
198
  void runNamedAgent({
197
199
  agent: request.readOnly ? { ...agent, tools: ["read", "grep", "find", "ls"] } : agent,
198
200
  task: request.task,
@@ -207,11 +209,15 @@ export default function (pi: ExtensionAPI) {
207
209
  status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
208
210
  result,
209
211
  });
210
- if (isFailedResult(result)) request.respond({ id: request.id, ok: false, error: getResultOutput(result) });
211
- else request.respond({ id: request.id, ok: true, result });
212
+ try {
213
+ if (isFailedResult(result)) request.respond({ id: request.id, ok: false, error: getResultOutput(result) });
214
+ else request.respond({ id: request.id, ok: true, result });
215
+ } catch { /* respond channel closed */ }
212
216
  }, (error) => {
213
217
  threadStore.updateThread(thread.id, { status: "failed" });
214
- request.respond({ id: request.id, ok: false, error: error instanceof Error ? error.message : String(error) });
218
+ try {
219
+ request.respond({ id: request.id, ok: false, error: error instanceof Error ? error.message : String(error) });
220
+ } catch { /* respond channel closed */ }
215
221
  });
216
222
  });
217
223
 
@@ -228,9 +234,12 @@ export default function (pi: ExtensionAPI) {
228
234
  const list = formatAgentList(fresh.agents, 20);
229
235
  const extra = list.remaining > 0 ? `\n ... +${list.remaining} more` : "";
230
236
  const dirs = fresh.projectAgentsDir ? `project: ${fresh.projectAgentsDir}` : "no project agents dir";
237
+ const diagText = fresh.diagnostics.length > 0
238
+ ? "\n\nWarnings:\n" + fresh.diagnostics.map(d => ` - [${d.severity}] ${d.filePath}: ${d.issue}`).join("\n")
239
+ : "";
231
240
  pi.sendMessage({
232
241
  customType: "pi-subagent",
233
- content: `Agent definitions reloaded.\n\nAvailable agents (${fresh.agents.length}):\n ${list.text}${extra}\n\nDirectories searched:\n user: ${path.join(getAgentDir(), "agents")}\n ${dirs}\n bundled: ${bundledAgentsDir}`,
242
+ content: `Agent definitions reloaded.\n\nAvailable agents (${fresh.agents.length}):\n ${list.text}${extra}${diagText}\n\nDirectories searched:\n user: ${path.join(getAgentDir(), "agents")}\n ${dirs}\n bundled: ${bundledAgentsDir}`,
234
243
  display: true,
235
244
  });
236
245
  ctx.ui.notify("Agent definitions reloaded", "info");
@@ -242,9 +251,12 @@ export default function (pi: ExtensionAPI) {
242
251
  const list = formatAgentList(discovery.agents, 20);
243
252
  const extra = list.remaining > 0 ? `\n ... +${list.remaining} more` : "";
244
253
  const dirs = discovery.projectAgentsDir ? `\n project: ${discovery.projectAgentsDir}` : "";
254
+ const diagText = discovery.diagnostics.length > 0
255
+ ? "\n\nWarnings:\n" + discovery.diagnostics.map(d => ` - [${d.severity}] ${d.filePath}: ${d.issue}`).join("\n")
256
+ : "";
245
257
  pi.sendMessage({
246
258
  customType: "pi-subagent",
247
- content: `Available agents (${discovery.agents.length}):\n ${list.text}${extra}\n\nScopes searched:\n user: ${path.join(getAgentDir(), "agents")}${dirs}\n bundled: ${bundledAgentsDir}\n\nUse /subagent <name> for agent details, /subagent reload to refresh.`,
259
+ content: `Available agents (${discovery.agents.length}):\n ${list.text}${extra}${diagText}\n\nScopes searched:\n user: ${path.join(getAgentDir(), "agents")}${dirs}\n bundled: ${bundledAgentsDir}\n\nUse /subagent <name> for agent details, /subagent reload to refresh.`,
248
260
  display: true,
249
261
  });
250
262
  return;
@@ -281,14 +293,45 @@ export default function (pi: ExtensionAPI) {
281
293
  const list = formatAgentList(discovery.agents, 20);
282
294
  const extra = list.remaining > 0 ? `\n ... +${list.remaining} more` : "";
283
295
  const dirs = discovery.projectAgentsDir ? `\n project: ${discovery.projectAgentsDir}` : "";
296
+ const diagText = discovery.diagnostics.length > 0
297
+ ? "\n\nWarnings:\n" + discovery.diagnostics.map(d => ` - [${d.severity}] ${d.filePath}: ${d.issue}`).join("\n")
298
+ : "";
284
299
  pi.sendMessage({
285
300
  customType: "pi-subagent",
286
- content: `Available agents (${discovery.agents.length}):\n ${list.text}${extra}\n\nScopes searched:\n user: ${path.join(getAgentDir(), "agents")}${dirs}\n bundled: ${bundledAgentsDir}\n\nUse /subagent <name> for agent details, /subagent reload to refresh.`,
301
+ content: `Available agents (${discovery.agents.length}):\n ${list.text}${extra}${diagText}\n\nScopes searched:\n user: ${path.join(getAgentDir(), "agents")}${dirs}\n bundled: ${bundledAgentsDir}\n\nUse /subagent <name> for agent details, /subagent reload to refresh.`,
287
302
  display: true,
288
303
  });
289
304
  },
290
305
  });
291
306
 
307
+ /** Map AgentColor (from agent frontmatter) to ThemeColor (for pi TUI). */
308
+ const AGENT_TO_THEME_COLOR: Record<AgentColor, ThemeColor> = {
309
+ red: "error",
310
+ blue: "accent",
311
+ green: "success",
312
+ yellow: "warning",
313
+ purple: "syntaxType",
314
+ orange: "syntaxString",
315
+ pink: "customMessageLabel",
316
+ cyan: "syntaxVariable",
317
+ };
318
+
319
+ /** Resolve agent-defined color to a valid ThemeColor for thread creation. */
320
+ const agentToThemeColor = (agentName: string): ThemeColor | undefined => {
321
+ const ctx = currentCtx;
322
+ if (!ctx) return undefined;
323
+ const agent = discoverAgents(ctx.cwd, "both", bundledAgentsDir).agents.find(a => a.name === agentName);
324
+ return agent?.color ? AGENT_TO_THEME_COLOR[agent.color] : undefined;
325
+ };
326
+
327
+ /** Look up agent color by name for TUI rendering. */
328
+ const resolveAgentColor = (name: string): ThemeColor => {
329
+ const ctx = currentCtx;
330
+ if (!ctx) return "accent";
331
+ const found = discoverAgents(ctx.cwd, "both", bundledAgentsDir).agents.find(a => a.name === name);
332
+ return found?.color ? AGENT_TO_THEME_COLOR[found.color] : "accent";
333
+ };
334
+
292
335
  pi.registerTool({
293
336
  name: "subagent",
294
337
  label: "Subagent",
@@ -438,10 +481,16 @@ export default function (pi: ExtensionAPI) {
438
481
  }
439
482
 
440
483
  // Helper: validate and normalise tools for an agent.
441
- function resolveChildTools(agentTools: string[] | undefined, readOnly?: boolean): string[] {
484
+ function resolveChildTools(agentTools: string[] | undefined, sandbox?: string, readOnly?: boolean): string[] {
442
485
  const defaultTools = ["read", "bash", "edit", "write", "grep", "find", "ls"];
443
- const rawTools = agentTools ?? defaultTools;
444
- const result = validateAgentTools({ tools: rawTools, readOnly });
486
+ let rawTools = agentTools ?? defaultTools;
487
+ // sandbox overrides tools: silently strip mutation tools, not an error
488
+ if (sandbox === "read-only") {
489
+ rawTools = rawTools.filter(t => READ_ONLY_TOOLS.includes(t));
490
+ if (rawTools.length === 0) rawTools = [...READ_ONLY_TOOLS];
491
+ }
492
+ const effectiveReadOnly = readOnly || sandbox === "read-only";
493
+ const result = validateAgentTools({ tools: rawTools, readOnly: effectiveReadOnly });
445
494
  if (result.errors.length > 0) {
446
495
  throw new Error(`Tool validation errors: ${result.errors.join("; ")}`);
447
496
  }
@@ -476,6 +525,8 @@ export default function (pi: ExtensionAPI) {
476
525
  agent: agentName,
477
526
  task,
478
527
  exitCode: 1,
528
+ status: "error",
529
+ stopReason: "error",
479
530
  messages: [],
480
531
  stderr: `Unknown agent: "${agentName}". Available: ${available}.`,
481
532
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
@@ -491,6 +542,8 @@ export default function (pi: ExtensionAPI) {
491
542
  agent: agentName,
492
543
  task,
493
544
  exitCode: 1,
545
+ status: "error",
546
+ stopReason: "error",
494
547
  messages: [],
495
548
  stderr: `Model not found for agent "${agentName}". Tried: ${tried}. Parent model: ${parentInfo}. Check agent definition and pi model configuration.`,
496
549
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
@@ -505,7 +558,7 @@ export default function (pi: ExtensionAPI) {
505
558
  try {
506
559
  // Inject parent's API key so --api-key and other runtime overrides work
507
560
  await injectApiKey(resolved.model);
508
- tools = resolveChildTools(agent.tools, isReadOnly);
561
+ tools = resolveChildTools(agent.tools, agent.sandbox, isReadOnly);
509
562
  effectiveTimeoutMs = resolveChildTimeout(timeoutMs, params.timeout);
510
563
  safeCwd = resolveChildCwd(cwd);
511
564
  } catch (err: unknown) {
@@ -514,6 +567,8 @@ export default function (pi: ExtensionAPI) {
514
567
  agent: agentName,
515
568
  task,
516
569
  exitCode: 1,
570
+ status: "error",
571
+ stopReason: "error",
517
572
  messages: [],
518
573
  stderr: `Validation error: ${errorMsg}`,
519
574
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
@@ -547,14 +602,14 @@ export default function (pi: ExtensionAPI) {
547
602
 
548
603
  for (let i = 0; i < params.chain.length; i++) {
549
604
  const step = params.chain[i];
550
- const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput);
605
+ const taskWithContext = step.task.replace(/\{previous\}/g, () => previousOutput);
551
606
 
552
607
  const thread = threadStore.createThread({
553
608
  agentName: step.agent,
554
609
  task: taskWithContext,
555
610
  mode: "chain-step",
556
611
  toolCallId: _toolCallId,
557
- color: agents.find(a => a.name === step.agent)?.color,
612
+ color: agentToThemeColor(step.agent),
558
613
  });
559
614
  const result = await runOne(
560
615
  step.agent, taskWithContext, step.cwd,
@@ -646,7 +701,7 @@ export default function (pi: ExtensionAPI) {
646
701
  task: t.task,
647
702
  mode: "parallel-task",
648
703
  toolCallId: _toolCallId,
649
- color: agents.find(a => a.name === t.agent)?.color,
704
+ color: agentToThemeColor(t.agent),
650
705
  }),
651
706
  );
652
707
 
@@ -689,6 +744,7 @@ export default function (pi: ExtensionAPI) {
689
744
  agent: t.agent,
690
745
  task: t.task,
691
746
  exitCode: 1,
747
+ status: "error",
692
748
  messages: [],
693
749
  stderr: "",
694
750
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
@@ -761,7 +817,7 @@ export default function (pi: ExtensionAPI) {
761
817
  task: params.task,
762
818
  mode: "single",
763
819
  toolCallId: _toolCallId,
764
- color: agents.find(a => a.name === params.agent)?.color,
820
+ color: agentToThemeColor(params.agent),
765
821
  });
766
822
  const result = await runOne(
767
823
  params.agent, params.task, params.cwd,
@@ -814,14 +870,6 @@ export default function (pi: ExtensionAPI) {
814
870
  // TUI rendering
815
871
  // ------------------------------------------------------------------
816
872
 
817
- /** Look up agent color by name for TUI rendering. */
818
- const resolveAgentColor = (name: string): string => {
819
- const ctx = currentCtx;
820
- if (!ctx) return "accent";
821
- const found = discoverAgents(ctx.cwd, "both", bundledAgentsDir).agents.find(a => a.name === name);
822
- return found?.color ?? "accent";
823
- };
824
-
825
873
  renderCall(args, theme, _context) {
826
874
  const scope: AgentScope = args.agentScope ?? "user";
827
875
  const fg = theme.fg.bind(theme);
@@ -890,7 +938,8 @@ export default function (pi: ExtensionAPI) {
890
938
 
891
939
  // --- Single ---
892
940
  if (details.mode === "single" && details.results.length === 1) {
893
- return renderSingleResult(details.results[0], expanded, theme);
941
+ const r = details.results[0];
942
+ return renderSingleResult(r, expanded, theme, resolveAgentColor(r.agent));
894
943
  }
895
944
 
896
945
  // --- Chain ---
@@ -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}]`)}`;
@@ -148,6 +148,8 @@ export async function runSubAgent(options: {
148
148
  result.stopReason = isTimeout ? "timeout" : "aborted";
149
149
  result.errorMessage = combinedSignal.reason instanceof Error ? combinedSignal.reason.message : "Sub-agent aborted before start";
150
150
  result.status = classifyStopReason(result.stopReason, !isTimeout, isTimeout);
151
+ cleanupCombined?.();
152
+ if (timeoutId) clearTimeout(timeoutId);
151
153
  return result;
152
154
  }
153
155
 
@@ -167,6 +169,7 @@ export async function runSubAgent(options: {
167
169
  let cleanupEventAbort: (() => void) | undefined;
168
170
  let abortedBySignal = false;
169
171
  let timedOut = false;
172
+ let eventUnsubscribe: (() => void) | undefined;
170
173
 
171
174
  try {
172
175
  // Wire combined abort signal to session
@@ -191,7 +194,8 @@ export async function runSubAgent(options: {
191
194
  fn();
192
195
  };
193
196
 
194
- const unsubscribe = session.subscribe((event) => {
197
+ let unsubscribe: (() => void) | undefined;
198
+ unsubscribe = session.subscribe((event) => {
195
199
  try {
196
200
  switch (event.type) {
197
201
  case "message_end": {
@@ -223,7 +227,7 @@ export async function runSubAgent(options: {
223
227
  result.messages = event.messages as unknown as Message[];
224
228
  }
225
229
  finish(() => {
226
- unsubscribe();
230
+ unsubscribe?.();
227
231
  resolve();
228
232
  });
229
233
  break;
@@ -231,18 +235,19 @@ export async function runSubAgent(options: {
231
235
  }
232
236
  } catch (err) {
233
237
  finish(() => {
234
- unsubscribe();
238
+ unsubscribe?.();
235
239
  reject(err);
236
240
  });
237
241
  }
238
242
  });
243
+ eventUnsubscribe = unsubscribe;
239
244
 
240
245
  // Resolve on abort so the eventPromise doesn't hang
241
246
  const onAbortResolve = () => {
242
247
  finish(() => {
243
248
  result.exitCode = 1;
244
249
  if (!result.errorMessage) result.errorMessage = "Sub-agent aborted";
245
- unsubscribe();
250
+ unsubscribe?.();
246
251
  resolve();
247
252
  });
248
253
  };
@@ -279,6 +284,7 @@ export async function runSubAgent(options: {
279
284
  cleanupAbort?.();
280
285
  cleanupEventAbort?.();
281
286
  cleanupCombined();
287
+ eventUnsubscribe?.();
282
288
  if (timeoutId) clearTimeout(timeoutId);
283
289
  try {
284
290
  session.dispose();
@@ -7,6 +7,7 @@ import {
7
7
  normalizeTimeout,
8
8
  resolveSafeCwd,
9
9
  MAX_INSTRUCTIONS_LENGTH,
10
+ READ_ONLY_TOOLS,
10
11
  } from "./security.ts";
11
12
 
12
13
  export const SUBAGENT_REQUEST_EVENT = "pi-subagent:run";
@@ -53,8 +54,13 @@ export async function runNamedAgent(options: {
53
54
  const effectiveTimeoutMs = normalizeTimeout({ requested: options.timeout }).timeoutMs;
54
55
 
55
56
  // Security: validate tools against allowlist.
56
- const rawTools = options.agent.tools ?? ["read", "bash", "edit", "write", "grep", "find", "ls"];
57
- const toolValidation = validateAgentTools({ tools: rawTools });
57
+ let rawTools = options.agent.tools ?? ["read", "bash", "edit", "write", "grep", "find", "ls"];
58
+ // Enforce read-only sandbox: strip mutating and execution tools
59
+ if (options.agent.sandbox === "read-only") {
60
+ rawTools = rawTools.filter(t => READ_ONLY_TOOLS.includes(t));
61
+ if (rawTools.length === 0) rawTools = [...READ_ONLY_TOOLS];
62
+ }
63
+ const toolValidation = validateAgentTools({ tools: rawTools, readOnly: options.agent.sandbox === "read-only" });
58
64
  if (toolValidation.errors.length > 0) {
59
65
  throw new Error(`Tool validation errors for agent "${options.agent.name}": ${toolValidation.errors.join("; ")}`);
60
66
  }
@@ -226,6 +226,7 @@ export class ThreadViewer {
226
226
  ? Math.max(0, total - (maxVisible - 1))
227
227
  : 0;
228
228
  const offset = Math.max(0, Math.min(this.scrollOffset, maxOffset));
229
+ this.scrollOffset = offset;
229
230
 
230
231
  // Reserve space for scroll indicators
231
232
  const aboveShown = offset > 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-subagent",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
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",