@bacnh85/pi-subagent 0.7.1 → 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.
@@ -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") {
@@ -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
 
@@ -209,11 +209,15 @@ export default function (pi: ExtensionAPI) {
209
209
  status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
210
210
  result,
211
211
  });
212
- if (isFailedResult(result)) request.respond({ id: request.id, ok: false, error: getResultOutput(result) });
213
- 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 */ }
214
216
  }, (error) => {
215
217
  threadStore.updateThread(thread.id, { status: "failed" });
216
- 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 */ }
217
221
  });
218
222
  });
219
223
 
@@ -230,9 +234,12 @@ export default function (pi: ExtensionAPI) {
230
234
  const list = formatAgentList(fresh.agents, 20);
231
235
  const extra = list.remaining > 0 ? `\n ... +${list.remaining} more` : "";
232
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
+ : "";
233
240
  pi.sendMessage({
234
241
  customType: "pi-subagent",
235
- 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}`,
236
243
  display: true,
237
244
  });
238
245
  ctx.ui.notify("Agent definitions reloaded", "info");
@@ -244,9 +251,12 @@ export default function (pi: ExtensionAPI) {
244
251
  const list = formatAgentList(discovery.agents, 20);
245
252
  const extra = list.remaining > 0 ? `\n ... +${list.remaining} more` : "";
246
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
+ : "";
247
257
  pi.sendMessage({
248
258
  customType: "pi-subagent",
249
- 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.`,
250
260
  display: true,
251
261
  });
252
262
  return;
@@ -283,9 +293,12 @@ export default function (pi: ExtensionAPI) {
283
293
  const list = formatAgentList(discovery.agents, 20);
284
294
  const extra = list.remaining > 0 ? `\n ... +${list.remaining} more` : "";
285
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
+ : "";
286
299
  pi.sendMessage({
287
300
  customType: "pi-subagent",
288
- 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.`,
289
302
  display: true,
290
303
  });
291
304
  },
@@ -512,6 +525,8 @@ export default function (pi: ExtensionAPI) {
512
525
  agent: agentName,
513
526
  task,
514
527
  exitCode: 1,
528
+ status: "error",
529
+ stopReason: "error",
515
530
  messages: [],
516
531
  stderr: `Unknown agent: "${agentName}". Available: ${available}.`,
517
532
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
@@ -527,6 +542,8 @@ export default function (pi: ExtensionAPI) {
527
542
  agent: agentName,
528
543
  task,
529
544
  exitCode: 1,
545
+ status: "error",
546
+ stopReason: "error",
530
547
  messages: [],
531
548
  stderr: `Model not found for agent "${agentName}". Tried: ${tried}. Parent model: ${parentInfo}. Check agent definition and pi model configuration.`,
532
549
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
@@ -550,6 +567,8 @@ export default function (pi: ExtensionAPI) {
550
567
  agent: agentName,
551
568
  task,
552
569
  exitCode: 1,
570
+ status: "error",
571
+ stopReason: "error",
553
572
  messages: [],
554
573
  stderr: `Validation error: ${errorMsg}`,
555
574
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
@@ -583,7 +602,7 @@ export default function (pi: ExtensionAPI) {
583
602
 
584
603
  for (let i = 0; i < params.chain.length; i++) {
585
604
  const step = params.chain[i];
586
- const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput);
605
+ const taskWithContext = step.task.replace(/\{previous\}/g, () => previousOutput);
587
606
 
588
607
  const thread = threadStore.createThread({
589
608
  agentName: step.agent,
@@ -725,6 +744,7 @@ export default function (pi: ExtensionAPI) {
725
744
  agent: t.agent,
726
745
  task: t.task,
727
746
  exitCode: 1,
747
+ status: "error",
728
748
  messages: [],
729
749
  stderr: "",
730
750
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
@@ -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.1",
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",