@rohaquinlop/pi-subagents 0.4.0 → 0.5.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/agents/worker.md CHANGED
@@ -16,6 +16,7 @@ Guidelines:
16
16
  - Read files before editing to understand existing code
17
17
  - Make targeted edits, not wholesale rewrites
18
18
  - Use safe_bash for running commands (tests, builds, installs, etc.)
19
+ - `safe_bash` has a 5-minute default timeout (300s). For long-running commands (builds, tests, installs), pass a larger `timeout`, e.g. `timeout: 600` for 10 minutes.
19
20
  - If something fails, diagnose and fix it
20
21
  - Report what you did and what changed when done
21
22
 
package/index.ts CHANGED
@@ -17,6 +17,11 @@ import "./tools/safe-bash";
17
17
  import type { AgentConfig, AgentUsage, PipelineStepResult, PipelineResult, LoopIterationResult, LoopResult } from "./lib/types";
18
18
  import { discoverAgents, mergeAgents, substitutePlaceholders, formatConnectorContext } from "./lib/helpers";
19
19
  import { zeroUsage, accumulateUsage, validateAgents, MAX_LOOP_CONTEXT, parseJudgeVerdict } from "./lib/pipeline-helpers";
20
+ import { buildSubagentErrorContent, buildPipelineErrorContent, buildLoopErrorContent } from "./lib/error-helpers";
21
+ import { detectCycle } from "./lib/loop-detector";
22
+ import { extractCycleSignature, LOOP_PRIOR_ITERATIONS_HEADER } from "./lib/cycle-signature";
23
+ import { validateAgentGraphAcyclicity } from "./lib/agent-graph";
24
+ import { checkDepth } from "./lib/depth-limit";
20
25
 
21
26
  interface ToolEvent {
22
27
  tool: string;
@@ -55,6 +60,7 @@ interface AgentProgress {
55
60
  durationMs: number;
56
61
  lastMessage: string;
57
62
  error?: string;
63
+ warning?: string;
58
64
  }
59
65
 
60
66
  interface AgentResult {
@@ -78,6 +84,9 @@ interface Details {
78
84
 
79
85
  interface ExtensionConfig {
80
86
  maxConcurrency?: number;
87
+ subagentTimeoutMs?: number; // wall-clock, default 600000 (10 min). 0 = disabled.
88
+ subagentIdleTimeoutMs?: number; // no-stdout watchdog, default 300000 (5 min). 0 = disabled.
89
+ maxSubagentDepth?: number; // max nesting depth, default 8. Hard backstop against recursion loops.
81
90
  }
82
91
 
83
92
  const EXT_DIR = path.dirname(new URL(import.meta.url).pathname);
@@ -85,6 +94,10 @@ const AGENTS_DIR = path.join(EXT_DIR, "agents");
85
94
  const TOOLS_DIR = path.join(EXT_DIR, "tools");
86
95
  const CONFIG_PATH = path.join(EXT_DIR, "config.json");
87
96
  const DEFAULT_MAX_CONCURRENCY = 4;
97
+ const DEFAULT_SUBAGENT_TIMEOUT_MS = 600_000; // 10 minutes
98
+ const DEFAULT_SUBAGENT_IDLE_TIMEOUT_MS = 300_000; // 5 minutes
99
+
100
+ let extensionConfig: ExtensionConfig = {};
88
101
 
89
102
  function loadConfig(): ExtensionConfig {
90
103
  try {
@@ -370,6 +383,15 @@ async function buildPiArgs(
370
383
  cwd: string,
371
384
  ): Promise<{ args: string[]; tempDir: string; childEnv: NodeJS.ProcessEnv }> {
372
385
  const piBin = resolvePiBinary();
386
+
387
+ // Depth check (before resource allocation to avoid temp-dir leaks on throw)
388
+ const currentDepth = parseInt(process.env.PI_SUBAGENT_DEPTH || "0", 10);
389
+ const maxDepth = extensionConfig.maxSubagentDepth ?? 8;
390
+ const depthResult = checkDepth(currentDepth, maxDepth);
391
+ if (!depthResult.allowed) {
392
+ throw new Error(depthResult.error!);
393
+ }
394
+
373
395
  const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-sub-"));
374
396
 
375
397
  // Write system prompt to temp file
@@ -451,6 +473,8 @@ async function buildPiArgs(
451
473
  childEnv.PI_SUBAGENT_ALLOWED = agent.subagentAgents.join(",");
452
474
  }
453
475
 
476
+ childEnv.PI_SUBAGENT_DEPTH = String(depthResult.newDepth);
477
+
454
478
  return { args: [piBin.command, ...args], tempDir, childEnv };
455
479
  }
456
480
 
@@ -479,6 +503,11 @@ function flatten(s: string): string {
479
503
  // doesn't need to read inline anyway.
480
504
  const MAX_ARG_PREVIEW = 4000;
481
505
 
506
+ // Hard cap on recentTools entries to prevent unbounded memory growth in
507
+ // long-running subagents. Generous for expanded-view history; matches the
508
+ // callHistory trim pattern.
509
+ const MAX_RECENT_TOOLS = 50;
510
+
482
511
  function extractToolArgsPreview(args: Record<string, unknown>): string {
483
512
  const cap = (s: string) => (s.length > MAX_ARG_PREVIEW ? s.slice(0, MAX_ARG_PREVIEW) + "…" : s);
484
513
  if (args.command) return cap(flatten(String(args.command)));
@@ -502,7 +531,7 @@ async function runSubagent(
502
531
  task: string,
503
532
  cwd: string,
504
533
  signal: AbortSignal | undefined,
505
- onUpdate?: (progress: AgentProgress, usage: AgentResult["usage"]) => void,
534
+ onUpdate?: (progress: AgentProgress, usage: AgentResult["usage"], finalExitCode?: number) => void,
506
535
  ): Promise<AgentResult> {
507
536
  const { args, tempDir, childEnv } = await buildPiArgs(agent, task, cwd);
508
537
  const command = args[0];
@@ -544,14 +573,80 @@ async function runSubagent(
544
573
 
545
574
  let buf = "";
546
575
  let stderrBuf = "";
576
+ let resolved = false;
577
+ let wallTimer: ReturnType<typeof setTimeout> | undefined;
578
+ let idleTimer: ReturnType<typeof setTimeout> | undefined;
579
+ let inFlightToolCount = 0;
580
+ let childClosed = false;
581
+ let sigkillTimer: ReturnType<typeof setTimeout> | undefined;
582
+ let callHistory: string[] = []; // sliding window of tool-call signatures for cycle detection
583
+ // NOTE: Cycle detection is PER-CHILD — each runSubagent() call has its own
584
+ // fresh callHistory. Cross-step loops in pipeline (A→B→A→B) and cross-iteration
585
+ // re-delegation in loop are NOT caught by the tool-call detector. They are
586
+ // bounded instead by: pipeline's 2-5 step cap, loop's 2-5 iteration cap, the
587
+ // depth cap (default 8), and wall-clock (10 min) + idle (5 min) timeouts.
588
+ // This is a deliberate tradeoff — per-child detection avoids false positives
589
+ // from parallel siblings sharing history.
590
+
591
+ const safeResolve = (code: number) => {
592
+ if (resolved) return;
593
+ resolved = true;
594
+ clearTimeout(wallTimer);
595
+ clearTimeout(idleTimer);
596
+ clearTimeout(sigkillTimer);
597
+ if (signal) signal.removeEventListener("abort", abortKill);
598
+ resolve(code);
599
+ };
600
+
601
+ // Wall-clock timeout
602
+ const timeoutMs = extensionConfig.subagentTimeoutMs ?? DEFAULT_SUBAGENT_TIMEOUT_MS;
603
+ if (timeoutMs > 0) {
604
+ wallTimer = setTimeout(() => {
605
+ if (!progress.error) progress.error = `Subagent timed out after ${Math.round(timeoutMs / 1000)}s`;
606
+ proc.kill("SIGTERM");
607
+ clearTimeout(sigkillTimer);
608
+ sigkillTimer = setTimeout(() => {
609
+ if (!childClosed) proc.kill("SIGKILL");
610
+ }, 5000);
611
+ }, timeoutMs);
612
+ }
613
+
614
+ // Idle (no-stdout) watchdog
615
+ const idleMs = extensionConfig.subagentIdleTimeoutMs ?? DEFAULT_SUBAGENT_IDLE_TIMEOUT_MS;
616
+ const resetIdle = () => {
617
+ if (idleMs <= 0) return;
618
+ clearTimeout(idleTimer);
619
+ idleTimer = setTimeout(() => {
620
+ if (!progress.error) progress.error = `Subagent idle for ${Math.round(idleMs / 1000)}s — likely stuck`;
621
+ proc.kill("SIGTERM");
622
+ clearTimeout(sigkillTimer);
623
+ sigkillTimer = setTimeout(() => {
624
+ if (!childClosed) proc.kill("SIGKILL");
625
+ }, 5000);
626
+ }, idleMs);
627
+ };
628
+ resetIdle();
629
+
630
+ const pauseIdle = () => {
631
+ clearTimeout(idleTimer);
632
+ idleTimer = undefined;
633
+ };
634
+ const resumeIdle = () => {
635
+ if (inFlightToolCount === 0) resetIdle();
636
+ };
637
+
638
+ const MAX_STDERR_BYTES = 100_000;
547
639
 
548
640
  const processLine = (line: string) => {
641
+ if (inFlightToolCount === 0) resetIdle();
549
642
  if (!line.trim()) return;
550
643
  try {
551
644
  const evt = JSON.parse(line) as any;
552
645
  progress.durationMs = Date.now() - startTime;
553
646
 
554
647
  if (evt.type === "tool_execution_start") {
648
+ inFlightToolCount++;
649
+ pauseIdle();
555
650
  progress.toolCount++;
556
651
  progress.recentTools.push({
557
652
  tool: evt.toolName,
@@ -559,6 +654,37 @@ async function runSubagent(
559
654
  toolCallId: evt.toolCallId,
560
655
  status: "running",
561
656
  });
657
+ // Trim oldest completed entries, but never evict an in-flight tool —
658
+ // otherwise tool_execution_end's .find(toolCallId) would no-op and leave a
659
+ // permanently-"running" ghost.
660
+ while (progress.recentTools.length > MAX_RECENT_TOOLS) {
661
+ const idx = progress.recentTools.findIndex((t) => t.status !== "running");
662
+ if (idx === -1) break; // only running entries left — don't evict in-flight
663
+ progress.recentTools.splice(idx, 1);
664
+ }
665
+ // ── Cycle detection (parent-side, context-free) ──
666
+ // Signature = toolName + args preview. Two calls with different args
667
+ // (different file, or same file different content) → different sig.
668
+ const sig = extractCycleSignature(evt.toolName, (evt.args || {}) as Record<string, unknown>);
669
+ const cycleResult = detectCycle(callHistory, sig);
670
+ callHistory.push(sig);
671
+ if (callHistory.length > 24) callHistory = callHistory.slice(-24);
672
+
673
+ if (cycleResult.cycle) {
674
+ const toolNames = (cycleResult.pattern || []).map((s) => {
675
+ const colonIdx = s.indexOf(":");
676
+ return colonIdx >= 0 ? s.slice(0, colonIdx) : s;
677
+ });
678
+ const patternStr = toolNames.join("→");
679
+ if (!progress.error) {
680
+ progress.error = `Subagent stuck in a tool-call loop: repeating ${patternStr}`;
681
+ }
682
+ proc.kill("SIGTERM");
683
+ clearTimeout(sigkillTimer);
684
+ sigkillTimer = setTimeout(() => {
685
+ if (!childClosed) proc.kill("SIGKILL");
686
+ }, 5000);
687
+ }
562
688
  fireUpdate();
563
689
  }
564
690
 
@@ -593,6 +719,8 @@ async function runSubagent(
593
719
  hit.children = finalChildren as AgentResult[];
594
720
  }
595
721
  }
722
+ inFlightToolCount = Math.max(0, inFlightToolCount - 1);
723
+ resumeIdle();
596
724
  fireUpdate();
597
725
  }
598
726
 
@@ -659,26 +787,37 @@ async function runSubagent(
659
787
  });
660
788
 
661
789
  proc.stderr.on("data", (d: Buffer) => {
790
+ if (stderrBuf.length >= MAX_STDERR_BYTES) return;
662
791
  stderrBuf += d.toString();
792
+ if (stderrBuf.length >= MAX_STDERR_BYTES) {
793
+ stderrBuf = stderrBuf.slice(0, MAX_STDERR_BYTES) + "\n[stderr truncated]";
794
+ }
663
795
  });
664
796
 
665
797
  proc.on("close", (code) => {
798
+ childClosed = true;
666
799
  if (buf.trim()) processLine(buf);
667
800
  if (code !== 0 && stderrBuf.trim() && !progress.error) {
668
801
  progress.error = stderrBuf.trim();
802
+ } else if (code === 0 && stderrBuf.trim()) {
803
+ // Non-fatal: surface stderr (e.g. deprecation warnings) on a successful exit.
804
+ progress.warning = stderrBuf.trim().slice(0, 2000);
669
805
  }
670
- resolve(code ?? 1);
806
+ safeResolve(code ?? 1);
671
807
  });
672
808
 
673
- proc.on("error", () => resolve(1));
809
+ proc.on("error", () => safeResolve(1));
674
810
 
811
+ const abortKill = () => {
812
+ proc.kill("SIGTERM");
813
+ clearTimeout(sigkillTimer);
814
+ sigkillTimer = setTimeout(() => {
815
+ if (!childClosed) proc.kill("SIGKILL");
816
+ }, 3000);
817
+ };
675
818
  if (signal) {
676
- const kill = () => {
677
- proc.kill("SIGTERM");
678
- setTimeout(() => !proc.killed && proc.kill("SIGKILL"), 3000);
679
- };
680
- if (signal.aborted) kill();
681
- else signal.addEventListener("abort", kill, { once: true });
819
+ if (signal.aborted) abortKill();
820
+ else signal.addEventListener("abort", abortKill, { once: true });
682
821
  }
683
822
  });
684
823
 
@@ -690,6 +829,13 @@ async function runSubagent(
690
829
  result.exitCode = exitCode;
691
830
  progress.status = exitCode === 0 && !progress.error ? "completed" : "failed";
692
831
  progress.durationMs = Date.now() - startTime;
832
+
833
+ // Push the terminal status to the live renderer so the TUI doesn't keep
834
+ // showing "running" after the child has exited. Pass exitCode so callers
835
+ // that hold a live result object (the subagent tool) can sync its exitCode
836
+ // and render the correct ✓/✗ icon instead of the -1 placeholder.
837
+ onUpdate?.(progress, result.usage, exitCode);
838
+
693
839
  if (progress.error) result.output = result.output || `Error: ${progress.error}`;
694
840
 
695
841
  // Truncate output if very large
@@ -900,6 +1046,10 @@ function renderAgentProgress(
900
1046
  addLine(theme.fg("error", `Error: ${prog.error}`));
901
1047
  }
902
1048
 
1049
+ if (prog.warning) {
1050
+ addLine(theme.fg("warning", `Warning: ${prog.warning}`));
1051
+ }
1052
+
903
1053
  return c;
904
1054
  }
905
1055
 
@@ -959,10 +1109,13 @@ async function runPipeline(
959
1109
  totalUsage = accumulateUsage(totalUsage, result.usage);
960
1110
  previousOutput = result.output;
961
1111
 
962
- // Stop on error
1112
+ // Stop on error — surface the failing step's error as finalOutput (the pipeline
1113
+ // tool returns finalOutput as content, so the main LLM sees the actual failure,
1114
+ // not the previous step's success text).
963
1115
  if (result.exitCode !== 0 || result.progress.error) {
1116
+ const errorDetail = buildPipelineErrorContent(i, step.agent, result);
964
1117
  return {
965
- steps: results, finalOutput: previousOutput,
1118
+ steps: results, finalOutput: errorDetail,
966
1119
  stoppedAt: i, error: result.progress.error || `Agent ${step.agent} exited with code ${result.exitCode}`,
967
1120
  totalUsage, totalDurationMs: Date.now() - startTime,
968
1121
  };
@@ -1014,7 +1167,7 @@ async function runLoop(
1014
1167
  }
1015
1168
  }
1016
1169
  const contextBlock = keptOutputs.join("\n\n");
1017
- fullTask = `${task}\n\n## Prior iterations:\n${contextBlock}`;
1170
+ fullTask = `${task}\n\n${LOOP_PRIOR_ITERATIONS_HEADER}\n${contextBlock}`;
1018
1171
  }
1019
1172
 
1020
1173
  const iterStart = Date.now();
@@ -1062,8 +1215,9 @@ async function runLoop(
1062
1215
 
1063
1216
  if (result.exitCode !== 0 || result.progress.error) {
1064
1217
  stoppedBecause = "error";
1218
+ const errorDetail = buildLoopErrorContent(i, agentName, result);
1065
1219
  return {
1066
- iterations, finalOutput: result.output || "(error)",
1220
+ iterations, finalOutput: errorDetail,
1067
1221
  stoppedBecause, totalUsage, totalDurationMs: Date.now() - startTime,
1068
1222
  };
1069
1223
  }
@@ -1239,10 +1393,17 @@ function renderLoopResult(
1239
1393
  // ── Extension ─────────────────────────────────────────────────────────
1240
1394
 
1241
1395
  export default function (pi: ExtensionAPI) {
1242
- const config = loadConfig();
1243
- semaphore = new Semaphore(config.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY);
1396
+ extensionConfig = loadConfig();
1397
+ semaphore = new Semaphore(extensionConfig.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY);
1244
1398
  agents = loadAgents();
1245
1399
 
1400
+ // Validate agent graph acyclicity (warning only — depth cap backstops recursion)
1401
+ const cycleError = validateAgentGraphAcyclicity(agents);
1402
+ if (cycleError) {
1403
+ console.error(`[pi-subagents] WARNING: ${cycleError}`);
1404
+ console.error(`[pi-subagents] The depth cap (maxSubagentDepth=${extensionConfig.maxSubagentDepth ?? 8}) prevents infinite recursion, but this agent configuration should be fixed.`);
1405
+ }
1406
+
1246
1407
  // If spawned as a child by a parent subagent process, PI_SUBAGENT_ALLOWED
1247
1408
  // pins which agents we're allowed to expose. Filter the registry now, before
1248
1409
  // any tool description sees the agent list — the child LLM should not even
@@ -1262,6 +1423,7 @@ export default function (pi: ExtensionAPI) {
1262
1423
  "Use subagent to delegate *reasoning and decisions*: codebase exploration (scout), web research (researcher), or isolated code changes (worker)",
1263
1424
  "For multiple independent subagent tasks, emit multiple `subagent` tool calls in the same turn — they run in parallel automatically.",
1264
1425
  "Subagents have NO context from the current conversation — include ALL necessary context in the task description",
1426
+ "When a subagent returns an error, read it carefully. For transient failures (timeout, API/network), retry once with the same task plus 'Previous attempt failed with: {error}'. For structural failures (wrong approach, missing context), simplify the task or switch agents. If it persists after retry, report to the user with the specific error.",
1265
1427
  ],
1266
1428
  parameters: Type.Object({
1267
1429
  agent: Type.String({ description: "Name of the agent to invoke" }),
@@ -1296,9 +1458,10 @@ export default function (pi: ExtensionAPI) {
1296
1458
  };
1297
1459
 
1298
1460
  const result = await semaphore.run(() =>
1299
- runSubagent(agent, params.task!, params.cwd ?? cwd, signal, (progress, usage) => {
1461
+ runSubagent(agent, params.task!, params.cwd ?? cwd, signal, (progress, usage, finalExitCode) => {
1300
1462
  liveResult.progress = progress;
1301
1463
  liveResult.usage = { ...usage };
1464
+ if (finalExitCode !== undefined) liveResult.exitCode = finalExitCode;
1302
1465
  onUpdate?.({
1303
1466
  content: [{ type: "text", text: "(running...)" }],
1304
1467
  details: { results: [liveResult] },
@@ -1308,8 +1471,11 @@ export default function (pi: ExtensionAPI) {
1308
1471
 
1309
1472
  result.contextWindow = contextWindow;
1310
1473
  const isError = result.exitCode !== 0 || !!result.progress.error;
1474
+ const contentText = isError
1475
+ ? buildSubagentErrorContent(result)
1476
+ : (result.output || "(no output)");
1311
1477
  return {
1312
- content: [{ type: "text", text: result.output || "(no output)" }],
1478
+ content: [{ type: "text", text: contentText }],
1313
1479
  details: { results: [result] },
1314
1480
  ...(isError ? { isError: true } : {}),
1315
1481
  };
@@ -1403,7 +1569,8 @@ export default function (pi: ExtensionAPI) {
1403
1569
  promptGuidelines: [
1404
1570
  "Use pipeline when a task naturally decomposes into sequential agent roles (e.g. explore → plan → implement → review).",
1405
1571
  "Each step receives the previous step's output automatically via {previous} placeholder substitution.",
1406
- "Pipelines stop on first error. The finalOutput is the last successful step's output.",
1572
+ "Pipelines stop on first error. The finalOutput is the failing step's error detail.",
1573
+ "When a pipeline fails at a step, the error identifies which step and why. Retry the failing step with a simpler task, or re-scope the pipeline. Early-step (exploration) failures → retry the whole pipeline with a more focused scope.",
1407
1574
  ],
1408
1575
  parameters: Type.Object({
1409
1576
  steps: Type.Array(
@@ -1535,6 +1702,7 @@ export default function (pi: ExtensionAPI) {
1535
1702
  "Use loop for tasks that benefit from iterative refinement (e.g. drafting → reviewing → polishing).",
1536
1703
  "Configure a judge agent to stop early when quality is sufficient, avoiding wasted iterations.",
1537
1704
  "Each iteration receives all prior outputs as context, enabling progressive improvement.",
1705
+ "When a loop iteration fails, the error shows which iteration. Reduce max_iterations or simplify the task; if the judge consistently rejects, refine the criteria or switch judge agent.",
1538
1706
  ],
1539
1707
  parameters: Type.Object({
1540
1708
  agent: Type.String({ description: "Agent name to run in the loop" }),
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Agent graph validation — detects cycles in agent delegation graphs.
3
+ *
4
+ * Uses WHITE/GRAY/BLACK DFS coloring to detect back edges (GRAY neighbor = cycle).
5
+ * Returns null if acyclic, or a human-readable cycle string if cyclic.
6
+ */
7
+ import type { AgentConfig } from "./types";
8
+
9
+ type Color = "white" | "gray" | "black";
10
+
11
+ /**
12
+ * Validate that the agent delegation graph is acyclic.
13
+ *
14
+ * Builds a directed graph from agent.subagentAgents references and runs
15
+ * DFS cycle detection. Handles: self-loops, 2-node cycles, N-node cycles,
16
+ * disconnected components, and references to agents not in the graph
17
+ * (skipped — they're external/leaf nodes).
18
+ *
19
+ * @param agents Array of agent configurations
20
+ * @returns null if acyclic, or a human-readable cycle string like
21
+ * "Cycle detected in agent delegation graph: A → B → A"
22
+ */
23
+ export function validateAgentGraphAcyclicity(agents: AgentConfig[]): string | null {
24
+ // Build adjacency list from agent configs
25
+ const agentNames = new Set(agents.map((a) => a.name));
26
+ const graph = new Map<string, string[]>();
27
+
28
+ for (const agent of agents) {
29
+ const targets = agent.subagentAgents ?? [];
30
+ // Filter out references to non-existent agents (external/leaf nodes)
31
+ graph.set(agent.name, targets.filter((t) => agentNames.has(t)));
32
+ }
33
+
34
+ // WHITE/GRAY/BLACK DFS coloring
35
+ const color = new Map<string, Color>();
36
+ const parent = new Map<string, string | null>();
37
+
38
+ for (const name of agentNames) {
39
+ color.set(name, "white");
40
+ }
41
+
42
+ // DFS from each unvisited node (handles disconnected components)
43
+ for (const start of agentNames) {
44
+ if (color.get(start) !== "white") continue;
45
+
46
+ // Iterative DFS using explicit stack
47
+ // Stack entries: [node, iterator index into neighbors]
48
+ const stack: Array<{ node: string; neighborIdx: number }> = [];
49
+ stack.push({ node: start, neighborIdx: 0 });
50
+ color.set(start, "gray");
51
+ parent.set(start, null);
52
+
53
+ while (stack.length > 0) {
54
+ const frame = stack[stack.length - 1];
55
+ const neighbors = graph.get(frame.node) ?? [];
56
+
57
+ if (frame.neighborIdx < neighbors.length) {
58
+ const neighbor = neighbors[frame.neighborIdx];
59
+ frame.neighborIdx++;
60
+
61
+ if (color.get(neighbor) === "gray") {
62
+ // Back edge found — reconstruct cycle path
63
+ const cyclePath = reconstructCyclePath(stack, neighbor);
64
+ return `Cycle detected in agent delegation graph: ${cyclePath}`;
65
+ }
66
+
67
+ if (color.get(neighbor) === "white") {
68
+ color.set(neighbor, "gray");
69
+ parent.set(neighbor, frame.node);
70
+ stack.push({ node: neighbor, neighborIdx: 0 });
71
+ }
72
+ // If black, it's a cross/forward edge — skip
73
+ } else {
74
+ // All neighbors explored — backtrack
75
+ color.set(frame.node, "black");
76
+ stack.pop();
77
+ }
78
+ }
79
+ }
80
+
81
+ return null;
82
+ }
83
+
84
+ /**
85
+ * Reconstruct the cycle path from the DFS stack when a back edge is found.
86
+ * The back edge goes from the current top of stack to `target`.
87
+ * The cycle is: target → ... → top → target
88
+ */
89
+ function reconstructCyclePath(
90
+ stack: Array<{ node: string; neighborIdx: number }>,
91
+ target: string,
92
+ ): string {
93
+ // Find target in the stack
94
+ let targetIdx = -1;
95
+ for (let i = 0; i < stack.length; i++) {
96
+ if (stack[i].node === target) {
97
+ targetIdx = i;
98
+ break;
99
+ }
100
+ }
101
+
102
+ if (targetIdx === -1) {
103
+ // Self-loop case: target is the current node
104
+ return `${target} → ${target}`;
105
+ }
106
+
107
+ // Build path from target to current top, then back to target
108
+ const path: string[] = [];
109
+ for (let i = targetIdx; i < stack.length; i++) {
110
+ path.push(stack[i].node);
111
+ }
112
+ path.push(target);
113
+
114
+ return path.join(" → ");
115
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Cycle-detection signature builder for tool calls.
3
+ *
4
+ * Unlike `extractToolArgsPreview` (optimized for TUI display), this module
5
+ * retains distinguishing arguments so that different calls doing different
6
+ * work produce different signatures (no false positives) while identical
7
+ * re-invocations collapse (true positives preserved).
8
+ */
9
+ import * as crypto from "node:crypto";
10
+
11
+ /**
12
+ * Header injected by the loop tool when accumulating prior iteration context
13
+ * into a subagent task. Shared here so normalizeTaskForSignature stays in sync
14
+ * with the injection site in index.ts — changing one without the other would
15
+ * silently break loop-context stripping (false-negative loop detection).
16
+ */
17
+ export const LOOP_PRIOR_ITERATIONS_HEADER = "## Prior iterations:";
18
+
19
+ /**
20
+ * Deterministic 48-bit fingerprint (12 hex chars) via SHA-256.
21
+ * Used for cycle-detection signatures: same input → same fingerprint,
22
+ * different inputs → different fingerprints (collision-resistant enough
23
+ * for a 24-entry sliding window).
24
+ */
25
+ export function taskFingerprint(str: string): string {
26
+ return crypto.createHash("sha256").update(str, "utf8").digest("hex").slice(0, 12);
27
+ }
28
+
29
+ /**
30
+ * Conservatively normalize a subagent task string for cycle-signature hashing.
31
+ * Strips injected connector context (## Prior iterations:, ## Key findings,
32
+ * ## Implementation results, ## Research findings) which varies per invocation
33
+ * but is not the "real" task, then collapses whitespace and lowercases.
34
+ * Preserves the core task instruction.
35
+ *
36
+ * Limitation: only strips the 4 known headers. A user-defined agent with a
37
+ * connector header NOT in this set would leave its {previous} content in the
38
+ * task hash, causing two identical logical tasks with varying prior context to
39
+ * produce different hashes — the detector could miss a loop (false negative).
40
+ * This is a deliberate tradeoff (conservative normalization preserves real task
41
+ * instructions; aggressive stripping risks over-collapsing different tasks).
42
+ * Residual risk is bounded by loop/pipeline/depth/timeout caps.
43
+ */
44
+ export function normalizeTaskForSignature(task: string): string {
45
+ const stripped = task
46
+ .replace(new RegExp(LOOP_PRIOR_ITERATIONS_HEADER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "[\\s\\S]*$", "m"), "")
47
+ .replace(/## Key findings[\s\S]*$/m, "")
48
+ .replace(/## Implementation results[\s\S]*$/m, "")
49
+ .replace(/## Research findings[\s\S]*$/m, "");
50
+ return stripped.replace(/\s+/g, " ").trim().toLowerCase();
51
+ }
52
+
53
+ /** Collapse any whitespace run (incl. newlines) into a single space. */
54
+ function flatten(s: string): string {
55
+ return s.replace(/\s+/g, " ").trim();
56
+ }
57
+
58
+ const MAX_CYCLE_SIG_COMPONENT = 120;
59
+
60
+ /**
61
+ * Build a cycle-detection signature for a tool call. Unlike extractToolArgsPreview
62
+ * (optimized for TUI display), this retains distinguishing arguments so that
63
+ * different calls doing different work produce different signatures (no false
64
+ * positives) while identical re-invocations collapse (true positives preserved).
65
+ * Components are capped to bound memory in the 24-entry sliding window.
66
+ */
67
+ export function extractCycleSignature(toolName: string, args: Record<string, unknown>): string {
68
+ const cap = (s: string, max: number = MAX_CYCLE_SIG_COMPONENT) =>
69
+ s.length > max ? s.slice(0, max) : s;
70
+
71
+ switch (toolName) {
72
+ case "bash":
73
+ case "safe_bash": {
74
+ const cmd = cap(flatten(String(args.command || "")));
75
+ const cwd = args.cwd ? `:${cap(flatten(String(args.cwd)), 60)}` : "";
76
+ return `${toolName}:${cmd}${cwd}`;
77
+ }
78
+ case "read": {
79
+ // Include offset (and limit) so pagination of the same file is progress, not a loop.
80
+ const p = cap(flatten(String(args.path || "")));
81
+ const offset = args.offset ?? 0;
82
+ const limit = args.limit ?? "";
83
+ return `read:${p}@${offset}${limit !== "" ? "/" + limit : ""}`;
84
+ }
85
+ case "write": {
86
+ const p = cap(flatten(String(args.path || "")));
87
+ const contentHash = args.content
88
+ ? taskFingerprint(String(args.content).slice(0, 1000))
89
+ : "nocontent";
90
+ return `write:${p}:${contentHash}`;
91
+ }
92
+ case "edit": {
93
+ const p = cap(flatten(String(args.path || "")));
94
+ let body = "";
95
+ if (Array.isArray(args.edits) && args.edits.length > 0) {
96
+ body = (args.edits as Array<{ newText?: string; new_string?: string }>)
97
+ .map((e) => String(e?.newText || e?.new_string || ""))
98
+ .join("\n");
99
+ } else {
100
+ body = String(args.newText || args.new_string || args.content || "");
101
+ }
102
+ const contentHash = body ? taskFingerprint(body.slice(0, 500)) : "noedit";
103
+ return `edit:${p}:${contentHash}`;
104
+ }
105
+ case "grep": {
106
+ const pat = cap(flatten(String(args.pattern || "")));
107
+ const p = args.path ? `:${cap(flatten(String(args.path)), 60)}` : "";
108
+ const ex = args.exclude ? `!${cap(flatten(String(args.exclude)), 40)}` : "";
109
+ return `grep:${pat}${p}${ex}`;
110
+ }
111
+ case "find":
112
+ case "fffind": {
113
+ const pat = cap(flatten(String(args.pattern || "")));
114
+ const p = args.path ? `:${cap(flatten(String(args.path)), 60)}` : "";
115
+ return `${toolName}:${pat}${p}`;
116
+ }
117
+ case "ffgrep": {
118
+ const pat = cap(flatten(String(args.pattern || "")));
119
+ const p = args.path ? `:${cap(flatten(String(args.path)), 60)}` : "";
120
+ return `ffgrep:${pat}${p}`;
121
+ }
122
+ case "ls": {
123
+ return `ls:${cap(flatten(String(args.path || ".")))}`;
124
+ }
125
+ case "web_search": {
126
+ return `web_search:${cap(flatten(String(args.query || "")))}`;
127
+ }
128
+ case "web_fetch": {
129
+ return `web_fetch:${cap(flatten(String(args.url || "")))}`;
130
+ }
131
+ case "subagent": {
132
+ // CRITICAL: include task fingerprint so different delegations are distinguishable.
133
+ const agent = flatten(String(args.agent || ""));
134
+ const taskHash = args.task
135
+ ? taskFingerprint(normalizeTaskForSignature(String(args.task)))
136
+ : "notask";
137
+ return `subagent:${agent}:${taskHash}`;
138
+ }
139
+ case "gh_cli": {
140
+ return `gh_cli:${cap(flatten(String(args.command || "")))}`;
141
+ }
142
+ case "read_pdf": {
143
+ return `read_pdf:${cap(flatten(String(args.path || "")))}`;
144
+ }
145
+ case "clarification_ui": {
146
+ return `clarification_ui:${cap(flatten(JSON.stringify(args)))}`;
147
+ }
148
+ default: {
149
+ const s = JSON.stringify(args);
150
+ return `${toolName}:${cap(flatten(s))}`;
151
+ }
152
+ }
153
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Depth-limiting logic for subagent nesting.
3
+ *
4
+ * Pure helper functions for checking and propagating subagent depth.
5
+ * Extracted for testability — the actual depth enforcement happens in
6
+ * buildPiArgs() in index.ts.
7
+ */
8
+
9
+ export interface DepthCheckResult {
10
+ allowed: boolean;
11
+ newDepth: number;
12
+ error?: string;
13
+ }
14
+
15
+ /**
16
+ * Check whether a new subagent spawn is allowed given the current depth and max.
17
+ *
18
+ * @param currentDepth The current nesting depth (from PI_SUBAGENT_DEPTH env var)
19
+ * @param maxDepth The maximum allowed depth (from config or default of 8)
20
+ * @returns DepthCheckResult with allowed flag, newDepth, and optional error message
21
+ */
22
+ export function checkDepth(currentDepth: number, maxDepth: number): DepthCheckResult {
23
+ const newDepth = currentDepth + 1;
24
+ if (newDepth > maxDepth) {
25
+ return {
26
+ allowed: false,
27
+ newDepth,
28
+ error:
29
+ `Subagent depth limit exceeded (${newDepth} > ${maxDepth}). ` +
30
+ `This likely indicates a recursion loop in agent delegation. ` +
31
+ `Set maxSubagentDepth in config.json to increase the limit.`,
32
+ };
33
+ }
34
+ return { allowed: true, newDepth };
35
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Error-message helpers for subagent tool returns.
3
+ *
4
+ * pi-agent-core drops the `isError` flag on normal tool returns, so `content`
5
+ * is the only reliable channel to communicate failure context back to the main
6
+ * LLM. These helpers build clear, actionable messages so the LLM can retry,
7
+ * redirect, or report.
8
+ */
9
+
10
+ /** Minimal shape the helper needs from a subagent result. */
11
+ interface SubagentErrorResult {
12
+ agent: string;
13
+ exitCode: number;
14
+ output: string;
15
+ progress: {
16
+ error?: string;
17
+ lastMessage?: string;
18
+ };
19
+ }
20
+
21
+ /**
22
+ * Build a clear, actionable error message for the main LLM when a single
23
+ * subagent fails. The `content` channel is the only reliable one (pi-agent-core
24
+ * drops a returned `isError` flag on normal tool returns), so this must carry
25
+ * enough context for the LLM to retry, redirect, or report.
26
+ */
27
+ export function buildSubagentErrorContent(result: SubagentErrorResult): string {
28
+ const parts: string[] = [];
29
+ parts.push(`[Subagent error] Agent "${result.agent}" failed (exit code ${result.exitCode}).`);
30
+ if (result.progress.error) {
31
+ parts.push(`Error: ${result.progress.error}`);
32
+ }
33
+ if (result.progress.lastMessage) {
34
+ parts.push(`Last message: ${result.progress.lastMessage}`);
35
+ }
36
+ if (result.output && result.output !== "(no output)") {
37
+ parts.push(`Output:\n${result.output}`);
38
+ }
39
+ return parts.join("\n");
40
+ }
41
+
42
+ export function buildPipelineErrorContent(
43
+ stepIndex: number,
44
+ stepAgent: string,
45
+ result: { exitCode: number; output: string; progress: { error?: string } },
46
+ ): string {
47
+ return [
48
+ `Pipeline failed at step ${stepIndex + 1} (agent: ${stepAgent}).`,
49
+ `Exit code: ${result.exitCode}`,
50
+ result.progress.error ? `Error: ${result.progress.error}` : null,
51
+ result.output ? `Output:\n${result.output}` : "(no output)",
52
+ ].filter(Boolean).join("\n");
53
+ }
54
+
55
+ export function buildLoopErrorContent(
56
+ iteration: number,
57
+ agentName: string,
58
+ result: { exitCode: number; output: string; progress: { error?: string } },
59
+ ): string {
60
+ return [
61
+ `Loop failed at iteration ${iteration + 1} (agent: ${agentName}).`,
62
+ `Exit code: ${result.exitCode}`,
63
+ result.progress.error ? `Error: ${result.progress.error}` : null,
64
+ result.output ? `Output:\n${result.output}` : "(no output)",
65
+ ].filter(Boolean).join("\n");
66
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Cycle detector for tool-call streams. Watches a sliding window of
3
+ * tool-call signatures for a sub-sequence of length P repeated 3 times
4
+ * (P in [2,8]). Catches an agent stuck in a tool-call loop without
5
+ * imposing any count cap on legitimate work. Purely parent-side.
6
+ */
7
+ const WINDOW_SIZE = 24;
8
+ const MIN_PATTERN_LEN = 2;
9
+ const MAX_PATTERN_LEN = 8;
10
+ const REPETITIONS = 3;
11
+
12
+ export interface CycleResult {
13
+ cycle: boolean;
14
+ pattern?: string[]; // the repeating sub-sequence of signatures (length P)
15
+ }
16
+
17
+ /**
18
+ * Detect whether `newSig` completes a 3× cycle. Pure: does NOT mutate `history`.
19
+ * The caller pushes `newSig` onto its own history AFTER calling this.
20
+ */
21
+ export function detectCycle(history: string[], newSig: string): CycleResult {
22
+ const window = [...history, newSig];
23
+ const start = Math.max(0, window.length - WINDOW_SIZE);
24
+ const w = window.slice(start);
25
+ for (let p = MIN_PATTERN_LEN; p <= MAX_PATTERN_LEN; p++) {
26
+ const needed = p * REPETITIONS;
27
+ if (w.length < needed) continue;
28
+ const tail = w.slice(-needed);
29
+ const pattern = tail.slice(0, p);
30
+ let isCycle = true;
31
+ for (let i = 0; i < p; i++) {
32
+ if (pattern[i] !== tail[p + i] || pattern[i] !== tail[2 * p + i]) {
33
+ isCycle = false;
34
+ break;
35
+ }
36
+ }
37
+ if (isCycle) return { cycle: true, pattern };
38
+ }
39
+ return { cycle: false };
40
+ }
package/package.json CHANGED
@@ -1,42 +1,42 @@
1
1
  {
2
- "name": "@rohaquinlop/pi-subagents",
3
- "version": "0.4.0",
4
- "description": "Pi extension for delegating tasks to subagents — parallel execution, agent discovery, and TUI rendering",
5
- "keywords": [
6
- "pi-package",
7
- "subagent",
8
- "parallel",
9
- "coding-agent",
10
- "extension"
11
- ],
12
- "license": "MIT",
13
- "author": "rohaquinlop",
14
- "repository": {
15
- "type": "git",
16
- "url": "git+https://github.com/rohaquinlop/pi-subagents.git"
17
- },
18
- "files": [
19
- "index.ts",
20
- "agents/",
21
- "tools/",
22
- "lib/",
23
- "README.md"
24
- ],
25
- "pi": {
26
- "extensions": [
27
- "./index.ts"
28
- ]
29
- },
30
- "peerDependencies": {
31
- "@earendil-works/pi-coding-agent": "*",
32
- "@earendil-works/pi-tui": "*",
33
- "@sinclair/typebox": "*"
34
- },
35
- "scripts": {
36
- "test": "vitest run",
37
- "test:watch": "vitest"
38
- },
39
- "devDependencies": {
40
- "vitest": "^3.2.0"
41
- }
2
+ "name": "@rohaquinlop/pi-subagents",
3
+ "version": "0.5.1",
4
+ "description": "Pi extension for delegating tasks to subagents — parallel execution, agent discovery, and TUI rendering",
5
+ "keywords": [
6
+ "pi-package",
7
+ "subagent",
8
+ "parallel",
9
+ "coding-agent",
10
+ "extension"
11
+ ],
12
+ "license": "MIT",
13
+ "author": "rohaquinlop",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/rohaquinlop/pi-subagents.git"
17
+ },
18
+ "files": [
19
+ "index.ts",
20
+ "agents/",
21
+ "tools/",
22
+ "lib/",
23
+ "README.md"
24
+ ],
25
+ "pi": {
26
+ "extensions": [
27
+ "./index.ts"
28
+ ]
29
+ },
30
+ "peerDependencies": {
31
+ "@earendil-works/pi-coding-agent": "*",
32
+ "@earendil-works/pi-tui": "*",
33
+ "@sinclair/typebox": "*"
34
+ },
35
+ "scripts": {
36
+ "test": "vitest run",
37
+ "test:watch": "vitest"
38
+ },
39
+ "devDependencies": {
40
+ "vitest": "^3.2.0"
41
+ }
42
42
  }
@@ -6,6 +6,8 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
6
  import { createBashTool } from "@earendil-works/pi-coding-agent";
7
7
  import { Type } from "@sinclair/typebox";
8
8
 
9
+ const DEFAULT_SAFE_BASH_TIMEOUT_S = 300; // 5 minutes — kills stuck commands instead of relying on the 10-min wall-clock
10
+
9
11
  const DANGEROUS_PATTERNS = [
10
12
  /\brm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+)?(-[a-zA-Z]*r[a-zA-Z]*\s+)?(\/|~\/?\s|~\/?\b)/,
11
13
  /\brm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+)?(-[a-zA-Z]*f[a-zA-Z]*\s+)?(\/|~\/?\s|~\/?\b)/,
@@ -46,7 +48,7 @@ export default function (pi: ExtensionAPI) {
46
48
  parameters: Type.Object({
47
49
  command: Type.String({ description: "Bash command to execute" }),
48
50
  timeout: Type.Optional(
49
- Type.Number({ description: "Timeout in seconds (optional)" }),
51
+ Type.Number({ description: "Timeout in seconds (default: 300s/5min; pass a larger value for builds/tests/installs)" }),
50
52
  ),
51
53
  }),
52
54
  async execute(toolCallId, params, signal, onUpdate, ctx) {
@@ -54,7 +56,8 @@ export default function (pi: ExtensionAPI) {
54
56
  if (danger) {
55
57
  throw new Error(danger);
56
58
  }
57
- return bashTool.execute(toolCallId, params, signal, onUpdate);
59
+ const timeout = params.timeout ?? DEFAULT_SAFE_BASH_TIMEOUT_S;
60
+ return bashTool.execute(toolCallId, { ...params, timeout }, signal, onUpdate);
58
61
  },
59
62
  });
60
63
  }