@bacnh85/pi-subagent 0.7.1 → 0.8.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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.1 (2026-07-15)
4
+
5
+ ### Review handoff
6
+
7
+ - Reviewer findings now require reproduction or evidence, expected behavior, and acceptance criteria so implementation agents receive self-contained actionable issues.
8
+
3
9
  ## 0.6.0 (2026-07-12)
4
10
 
5
11
  ### Security (breaking changes)
package/README.md CHANGED
@@ -87,6 +87,7 @@ Every child execution receives a timeout:
87
87
  - **Maximum:** 60 minutes (`MAX_TIMEOUT_MS`)
88
88
  - Timeout values must be positive integers within the allowed range.
89
89
  - Timeout errors are distinguishable from parent cancellation.
90
+ - Progress heartbeats keep the parent transport active during quiet model work; explicit timeouts remain hard deadlines.
90
91
  - Parallel tasks and chain steps may have per-item timeouts.
91
92
 
92
93
  ### Output safety
@@ -158,6 +159,7 @@ The raw `stopReason` from the Pi SDK is preserved in the result.
158
159
  - **Parent cancellation:** Aborting the parent tool call cancels all children.
159
160
  - **Sibling cancellation:** In parallel mode with `abortOnFailure: true`, the first failed task cancels running siblings.
160
161
  - **Timeout vs. abort:** Timeout errors set `status: "timeout"` and `stopReason: "timeout"`; parent cancellation sets `status: "aborted"`.
162
+ - **Transport idle:** The parent tool receives periodic progress heartbeats while a child runs; these do not extend its timeout.
161
163
 
162
164
  ## Extension contract
163
165
 
@@ -9,7 +9,7 @@ sandbox: read-only
9
9
 
10
10
  You are an independent senior code reviewer. Inspect the requested Git scope with read-only tools.
11
11
 
12
- Focus only on actionable issues introduced by the reviewed change:
12
+ Focus only on actionable issues introduced by the reviewed change. Return each confirmed finding as one self-contained issue another agent can fix without redoing the review:
13
13
  1. Correctness and edge cases
14
14
  2. Security and data loss
15
15
  3. Regressions and API compatibility
@@ -27,8 +27,10 @@ Return JSON only:
27
27
  "file": "relative/path",
28
28
  "line": 1,
29
29
  "issue": "what is wrong and why it matters",
30
- "evidence": "specific inspected code evidence",
30
+ "evidence": "reproduction steps or specific inspected code evidence",
31
+ "expectedBehavior": "what should happen instead",
31
32
  "suggestedFix": "smallest safe fix",
33
+ "acceptanceCriteria": "observable pass conditions and exact verification checks",
32
34
  "blocking": true
33
35
  }
34
36
  ]
@@ -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
 
@@ -38,6 +38,7 @@ import {
38
38
  isFailedResult,
39
39
  mapWithConcurrencyLimit,
40
40
  runSubAgent,
41
+ startHeartbeat,
41
42
  } from "./runner.ts";
42
43
  import {
43
44
  normalizeTimeout,
@@ -209,11 +210,15 @@ export default function (pi: ExtensionAPI) {
209
210
  status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
210
211
  result,
211
212
  });
212
- if (isFailedResult(result)) request.respond({ id: request.id, ok: false, error: getResultOutput(result) });
213
- else request.respond({ id: request.id, ok: true, result });
213
+ try {
214
+ if (isFailedResult(result)) request.respond({ id: request.id, ok: false, error: getResultOutput(result) });
215
+ else request.respond({ id: request.id, ok: true, result });
216
+ } catch { /* respond channel closed */ }
214
217
  }, (error) => {
215
218
  threadStore.updateThread(thread.id, { status: "failed" });
216
- request.respond({ id: request.id, ok: false, error: error instanceof Error ? error.message : String(error) });
219
+ try {
220
+ request.respond({ id: request.id, ok: false, error: error instanceof Error ? error.message : String(error) });
221
+ } catch { /* respond channel closed */ }
217
222
  });
218
223
  });
219
224
 
@@ -230,9 +235,12 @@ export default function (pi: ExtensionAPI) {
230
235
  const list = formatAgentList(fresh.agents, 20);
231
236
  const extra = list.remaining > 0 ? `\n ... +${list.remaining} more` : "";
232
237
  const dirs = fresh.projectAgentsDir ? `project: ${fresh.projectAgentsDir}` : "no project agents dir";
238
+ const diagText = fresh.diagnostics.length > 0
239
+ ? "\n\nWarnings:\n" + fresh.diagnostics.map(d => ` - [${d.severity}] ${d.filePath}: ${d.issue}`).join("\n")
240
+ : "";
233
241
  pi.sendMessage({
234
242
  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}`,
243
+ 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
244
  display: true,
237
245
  });
238
246
  ctx.ui.notify("Agent definitions reloaded", "info");
@@ -244,9 +252,12 @@ export default function (pi: ExtensionAPI) {
244
252
  const list = formatAgentList(discovery.agents, 20);
245
253
  const extra = list.remaining > 0 ? `\n ... +${list.remaining} more` : "";
246
254
  const dirs = discovery.projectAgentsDir ? `\n project: ${discovery.projectAgentsDir}` : "";
255
+ const diagText = discovery.diagnostics.length > 0
256
+ ? "\n\nWarnings:\n" + discovery.diagnostics.map(d => ` - [${d.severity}] ${d.filePath}: ${d.issue}`).join("\n")
257
+ : "";
247
258
  pi.sendMessage({
248
259
  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.`,
260
+ 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
261
  display: true,
251
262
  });
252
263
  return;
@@ -283,9 +294,12 @@ export default function (pi: ExtensionAPI) {
283
294
  const list = formatAgentList(discovery.agents, 20);
284
295
  const extra = list.remaining > 0 ? `\n ... +${list.remaining} more` : "";
285
296
  const dirs = discovery.projectAgentsDir ? `\n project: ${discovery.projectAgentsDir}` : "";
297
+ const diagText = discovery.diagnostics.length > 0
298
+ ? "\n\nWarnings:\n" + discovery.diagnostics.map(d => ` - [${d.severity}] ${d.filePath}: ${d.issue}`).join("\n")
299
+ : "";
286
300
  pi.sendMessage({
287
301
  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.`,
302
+ 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
303
  display: true,
290
304
  });
291
305
  },
@@ -502,6 +516,7 @@ export default function (pi: ExtensionAPI) {
502
516
  parentSignal?: AbortSignal,
503
517
  timeoutMs?: number,
504
518
  onProgress?: (partial: SubAgentResult) => void,
519
+ heartbeatDetails?: () => SubagentDetails,
505
520
  isReadOnly?: boolean,
506
521
  ): Promise<SubAgentResult> {
507
522
  const agent = agents.find((a) => a.name === agentName);
@@ -512,6 +527,8 @@ export default function (pi: ExtensionAPI) {
512
527
  agent: agentName,
513
528
  task,
514
529
  exitCode: 1,
530
+ status: "error",
531
+ stopReason: "error",
515
532
  messages: [],
516
533
  stderr: `Unknown agent: "${agentName}". Available: ${available}.`,
517
534
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
@@ -527,6 +544,8 @@ export default function (pi: ExtensionAPI) {
527
544
  agent: agentName,
528
545
  task,
529
546
  exitCode: 1,
547
+ status: "error",
548
+ stopReason: "error",
530
549
  messages: [],
531
550
  stderr: `Model not found for agent "${agentName}". Tried: ${tried}. Parent model: ${parentInfo}. Check agent definition and pi model configuration.`,
532
551
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
@@ -550,6 +569,8 @@ export default function (pi: ExtensionAPI) {
550
569
  agent: agentName,
551
570
  task,
552
571
  exitCode: 1,
572
+ status: "error",
573
+ stopReason: "error",
553
574
  messages: [],
554
575
  stderr: `Validation error: ${errorMsg}`,
555
576
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
@@ -557,23 +578,30 @@ export default function (pi: ExtensionAPI) {
557
578
  };
558
579
  }
559
580
 
560
- const result = await runSubAgent({
561
- cwd: safeCwd,
562
- systemPrompt: params.instructions
563
- ? `${agent.systemPrompt}\n\n## Task Contract\n${params.instructions.slice(0, MAX_INSTRUCTIONS_LENGTH)}`
564
- : agent.systemPrompt,
565
- task,
566
- tools,
567
- model: resolved.model,
568
- authStorage,
569
- modelRegistry,
570
- signal: parentSignal,
571
- timeoutMs: effectiveTimeoutMs,
572
- agentName,
573
- thinkingLevel: agent.thinking,
574
- onMessage: onProgress,
575
- });
576
- return result;
581
+ const stopHeartbeat = onUpdate ? startHeartbeat(() => onUpdate({
582
+ content: [{ type: "text", text: `Subagent ${agentName} is still running…` }],
583
+ details: heartbeatDetails?.() ?? makeDetails("single")([]),
584
+ })) : undefined;
585
+ try {
586
+ return await runSubAgent({
587
+ cwd: safeCwd,
588
+ systemPrompt: params.instructions
589
+ ? `${agent.systemPrompt}\n\n## Task Contract\n${params.instructions.slice(0, MAX_INSTRUCTIONS_LENGTH)}`
590
+ : agent.systemPrompt,
591
+ task,
592
+ tools,
593
+ model: resolved.model,
594
+ authStorage,
595
+ modelRegistry,
596
+ signal: parentSignal,
597
+ timeoutMs: effectiveTimeoutMs,
598
+ agentName,
599
+ thinkingLevel: agent.thinking,
600
+ onMessage: onProgress,
601
+ });
602
+ } finally {
603
+ stopHeartbeat?.();
604
+ }
577
605
  }
578
606
 
579
607
  // --- Chain mode ---
@@ -583,7 +611,7 @@ export default function (pi: ExtensionAPI) {
583
611
 
584
612
  for (let i = 0; i < params.chain.length; i++) {
585
613
  const step = params.chain[i];
586
- const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput);
614
+ const taskWithContext = step.task.replace(/\{previous\}/g, () => previousOutput);
587
615
 
588
616
  const thread = threadStore.createThread({
589
617
  agentName: step.agent,
@@ -596,6 +624,7 @@ export default function (pi: ExtensionAPI) {
596
624
  step.agent, taskWithContext, step.cwd,
597
625
  signal, step.timeout ?? params.timeout,
598
626
  (partial) => threadStore.updateThread(thread.id, { result: partial }),
627
+ () => makeDetails("chain")(results),
599
628
  );
600
629
  threadStore.updateThread(thread.id, {
601
630
  status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
@@ -725,6 +754,7 @@ export default function (pi: ExtensionAPI) {
725
754
  agent: t.agent,
726
755
  task: t.task,
727
756
  exitCode: 1,
757
+ status: "error",
728
758
  messages: [],
729
759
  stderr: "",
730
760
  usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
@@ -747,7 +777,8 @@ export default function (pi: ExtensionAPI) {
747
777
  const result = await runOne(
748
778
  t.agent, t.task, t.cwd,
749
779
  parallelController.signal, t.timeout ?? params.timeout,
750
- (partial) => threadStore.updateThread(parallelThreads[index].id, { result: partial }),
780
+ (partial) => threadStore.updateThread(parallelThreads[index].id, { result: partial }),
781
+ () => makeDetails("parallel")([...allResults]),
751
782
  );
752
783
  allResults[index] = result;
753
784
  threadStore.updateThread(parallelThreads[index].id, {
@@ -803,6 +834,7 @@ export default function (pi: ExtensionAPI) {
803
834
  params.agent, params.task, params.cwd,
804
835
  signal, params.timeout,
805
836
  (partial) => threadStore.updateThread(thread.id, { result: partial }),
837
+ () => makeDetails("single")([]),
806
838
  );
807
839
  threadStore.updateThread(thread.id, {
808
840
  status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
@@ -63,6 +63,12 @@ export interface SubAgentResult {
63
63
  // Public API
64
64
  // ---------------------------------------------------------------------------
65
65
 
66
+ export function startHeartbeat(onHeartbeat: () => void, intervalMs = 30_000): () => void {
67
+ const timer = setInterval(onHeartbeat, intervalMs);
68
+ timer.unref?.();
69
+ return () => clearInterval(timer);
70
+ }
71
+
66
72
  export async function runSubAgent(options: {
67
73
  cwd: string;
68
74
  systemPrompt: string;
@@ -148,6 +154,8 @@ export async function runSubAgent(options: {
148
154
  result.stopReason = isTimeout ? "timeout" : "aborted";
149
155
  result.errorMessage = combinedSignal.reason instanceof Error ? combinedSignal.reason.message : "Sub-agent aborted before start";
150
156
  result.status = classifyStopReason(result.stopReason, !isTimeout, isTimeout);
157
+ cleanupCombined?.();
158
+ if (timeoutId) clearTimeout(timeoutId);
151
159
  return result;
152
160
  }
153
161
 
@@ -167,6 +175,7 @@ export async function runSubAgent(options: {
167
175
  let cleanupEventAbort: (() => void) | undefined;
168
176
  let abortedBySignal = false;
169
177
  let timedOut = false;
178
+ let eventUnsubscribe: (() => void) | undefined;
170
179
 
171
180
  try {
172
181
  // Wire combined abort signal to session
@@ -191,7 +200,8 @@ export async function runSubAgent(options: {
191
200
  fn();
192
201
  };
193
202
 
194
- const unsubscribe = session.subscribe((event) => {
203
+ let unsubscribe: (() => void) | undefined;
204
+ unsubscribe = session.subscribe((event) => {
195
205
  try {
196
206
  switch (event.type) {
197
207
  case "message_end": {
@@ -223,7 +233,7 @@ export async function runSubAgent(options: {
223
233
  result.messages = event.messages as unknown as Message[];
224
234
  }
225
235
  finish(() => {
226
- unsubscribe();
236
+ unsubscribe?.();
227
237
  resolve();
228
238
  });
229
239
  break;
@@ -231,18 +241,19 @@ export async function runSubAgent(options: {
231
241
  }
232
242
  } catch (err) {
233
243
  finish(() => {
234
- unsubscribe();
244
+ unsubscribe?.();
235
245
  reject(err);
236
246
  });
237
247
  }
238
248
  });
249
+ eventUnsubscribe = unsubscribe;
239
250
 
240
251
  // Resolve on abort so the eventPromise doesn't hang
241
252
  const onAbortResolve = () => {
242
253
  finish(() => {
243
254
  result.exitCode = 1;
244
255
  if (!result.errorMessage) result.errorMessage = "Sub-agent aborted";
245
- unsubscribe();
256
+ unsubscribe?.();
246
257
  resolve();
247
258
  });
248
259
  };
@@ -279,6 +290,7 @@ export async function runSubAgent(options: {
279
290
  cleanupAbort?.();
280
291
  cleanupEventAbort?.();
281
292
  cleanupCombined();
293
+ eventUnsubscribe?.();
282
294
  if (timeoutId) clearTimeout(timeoutId);
283
295
  try {
284
296
  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.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",