@leing2021/super-pi 0.15.0 → 0.17.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/README.md CHANGED
@@ -99,14 +99,14 @@ Next time `02-plan` or `04-review` runs, a grep-first search strategy automatica
99
99
  | `08-status` | Scan artifacts, report progress | `workflow_state`, `session_history` |
100
100
  | `09-help` | Usage guide | — |
101
101
 
102
- ### 13 Tools (underlying capabilities)
102
+ ### 15 Tools (underlying capabilities)
103
103
 
104
104
  | Tool | What it does |
105
105
  |------|-------------|
106
106
  | `task_splitter` | Union-Find algorithm analyzes file dependencies, auto-groups parallel-safe units |
107
107
  | `session_checkpoint` | JSON-persisted checkpoints with save/load/fail/retry operations |
108
108
  | `plan_diff` | Incremental plans: compare detects diffs, patch applies changes |
109
- | `parallel_subagent` | `Promise.allSettled`-style parallel subagent execution |
109
+ | `parallel_subagent` | `Promise.allSettled`-style parallel subagent execution with context slimming |
110
110
  | `review_router` | Auto-assign reviewer personas from diff metadata |
111
111
  | `pattern_extractor` | Extract and categorize patterns from artifacts |
112
112
  | `brainstorm_dialog` | Multi-round dialog state machine (start → refine × N → summarize) |
@@ -115,13 +115,15 @@ Next time `02-plan` or `04-review` runs, a grep-first search strategy automatica
115
115
  | `worktree_manager` | Full git worktree lifecycle management |
116
116
  | `artifact_helper` | Artifact path resolution and directory creation |
117
117
  | `ask_user_question` | Structured user prompts (choices / free input) |
118
- | `subagent` | Serial subagent chain |
118
+ | `subagent` | Serial subagent chain with depth guard and context control |
119
+ | `subagent-depth-guard` | Env-based recursion depth tracking (prevents runaway nesting) |
120
+ | `async-mutex` | Serializes `process.env` mutation for concurrency-safe child process spawning |
119
121
 
120
122
  ---
121
123
 
122
124
  ## Code Scale
123
125
 
124
- ~2000 lines of TypeScript implementing 13 tools, 22 Markdown reference files driving 9 skills' behavioral strategies, 95 tests covering all tool logic.
126
+ ~2500 lines of TypeScript implementing 15 tools, 22 Markdown reference files driving 9 skills' behavioral strategies, 162 tests covering all tool logic.
125
127
 
126
128
  Not a heavy framework. Each tool has a single responsibility, each skill works independently, and together they form a complete workflow.
127
129
 
@@ -236,6 +238,22 @@ Not a fork. Not a wrapper. The methodologies are extracted and rebuilt with Pi's
236
238
 
237
239
  ## Changelog
238
240
 
241
+ ### 0.17.0 — Subagent safety
242
+ - Recursion depth guard (`PI_SUBAGENT_DEPTH` / `PI_SUBAGENT_MAX_DEPTH`) prevents runaway nesting
243
+ - Async mutex for `process.env` concurrency safety during parallel subagent execution
244
+ - Context slimming: `inheritSkills` parameter, parallel workers default to slim context (`--no-skills`)
245
+ - Shared `createSubagentRunner` factory (deduped runner closures)
246
+ - 162 tests passing
247
+
248
+ ### 0.16.0 — Context optimization
249
+ - Read output filter: structural compression for large code files, lock files, markdown
250
+ - Compaction optimizer: focused summary instructions for session compaction
251
+ - Bash output filter improvements
252
+
253
+ ### 0.15.0 — Output filtering
254
+ - Bash output filter: smart truncation by command type (install, test, build)
255
+ - Read output filter: preserves structure while cutting verbosity
256
+
239
257
  ### 0.14.0 — Structured solution retrieval
240
258
  - YAML frontmatter tagging + grep-first two-level search
241
259
  - 95 tests passing
@@ -14,6 +14,53 @@ import { createPlanDiffTool } from "./tools/plan-diff"
14
14
  import { createSessionHistoryTool } from "./tools/session-history"
15
15
  import { createPatternExtractorTool } from "./tools/pattern-extractor"
16
16
  import { filterBashOutput } from "./tools/bash-output-filter"
17
+ import { filterReadOutput } from "./tools/read-output-filter"
18
+ import { COMPACTION_FOCUS_INSTRUCTIONS } from "./tools/compaction-optimizer"
19
+ import { AsyncMutex } from "./tools/async-mutex"
20
+ import type { SubagentExecOptions, SubagentRunner } from "./tools/subagent"
21
+
22
+ const _subagentEnvMutex = new AsyncMutex()
23
+
24
+ /**
25
+ * Create a subagent runner that handles env injection with mutex protection
26
+ * for concurrency safety when multiple parallel subagents share the process.
27
+ */
28
+ function createSubagentRunner(
29
+ pi: ExtensionAPI,
30
+ signal?: AbortSignal,
31
+ ): SubagentRunner {
32
+ return async (prompt: string, options?: SubagentExecOptions) => {
33
+ const args = ["--no-session", ...(options?.extraFlags ?? []), "-p", prompt]
34
+ const release = await _subagentEnvMutex.acquire()
35
+ const savedEnv: Record<string, string | undefined> = {}
36
+ const extraEnv = options?.extraEnv ?? {}
37
+ for (const [key, value] of Object.entries(extraEnv)) {
38
+ savedEnv[key] = process.env[key]
39
+ process.env[key] = value
40
+ }
41
+ try {
42
+ const execResult = await pi.exec("pi", args, {
43
+ signal,
44
+ timeout: 10 * 60 * 1000,
45
+ })
46
+
47
+ if (execResult.code !== 0) {
48
+ throw new Error(execResult.stderr || execResult.stdout || `Subagent failed for prompt: ${prompt}`)
49
+ }
50
+
51
+ return (execResult.stdout || "").trim()
52
+ } finally {
53
+ for (const [key, oldValue] of Object.entries(savedEnv)) {
54
+ if (oldValue === undefined) {
55
+ delete process.env[key]
56
+ } else {
57
+ process.env[key] = oldValue
58
+ }
59
+ }
60
+ release()
61
+ }
62
+ }
63
+ }
17
64
 
18
65
  const artifactHelperParams = Type.Object({
19
66
  repoRoot: Type.String({ description: "Repository root where workflow artifacts should be created" }),
@@ -46,6 +93,7 @@ const subagentParams = Type.Object({
46
93
  agent: Type.Optional(Type.String({ description: "Single subagent skill name" })),
47
94
  task: Type.Optional(Type.String({ description: "Single subagent task" })),
48
95
  chain: Type.Optional(Type.Array(subagentTaskSchema, { description: "Serial subagent chain with optional {previous} placeholder" })),
96
+ inheritSkills: Type.Optional(Type.Boolean({ description: "Whether the subagent should inherit skills. Default: true" })),
49
97
  })
50
98
 
51
99
  const workflowStateParams = Type.Object({
@@ -77,6 +125,7 @@ const parallelSubagentTaskSchema = Type.Object({
77
125
 
78
126
  const parallelSubagentParams = Type.Object({
79
127
  tasks: Type.Array(parallelSubagentTaskSchema, { description: "Array of independent tasks to run concurrently" }),
128
+ inheritSkills: Type.Optional(Type.Boolean({ description: "Whether subagents should inherit skills. Default: false" })),
80
129
  })
81
130
 
82
131
  const sessionCheckpointParams = Type.Object({
@@ -261,19 +310,9 @@ export default function ceCoreExtension(pi: ExtensionAPI) {
261
310
  agent: params.agent,
262
311
  task: params.task,
263
312
  chain: params.chain,
313
+ inheritSkills: params.inheritSkills,
264
314
  },
265
- async (prompt: string) => {
266
- const execResult = await pi.exec("pi", ["--no-session", "-p", prompt], {
267
- signal,
268
- timeout: 10 * 60 * 1000,
269
- })
270
-
271
- if (execResult.code !== 0) {
272
- throw new Error(execResult.stderr || execResult.stdout || `Subagent failed for prompt: ${prompt}`)
273
- }
274
-
275
- return (execResult.stdout || "").trim()
276
- },
315
+ createSubagentRunner(pi, signal),
277
316
  )
278
317
 
279
318
  return {
@@ -361,19 +400,11 @@ export default function ceCoreExtension(pi: ExtensionAPI) {
361
400
  parameters: parallelSubagentParams,
362
401
  async execute(_toolCallId, params, signal) {
363
402
  const result = await parallelSubagent.execute(
364
- { tasks: params.tasks },
365
- async (prompt: string) => {
366
- const execResult = await pi.exec("pi", ["--no-session", "-p", prompt], {
367
- signal,
368
- timeout: 10 * 60 * 1000,
369
- })
370
-
371
- if (execResult.code !== 0) {
372
- throw new Error(execResult.stderr || execResult.stdout || `Subagent failed for prompt: ${prompt}`)
373
- }
374
-
375
- return (execResult.stdout || "").trim()
403
+ {
404
+ tasks: params.tasks,
405
+ inheritSkills: params.inheritSkills,
376
406
  },
407
+ createSubagentRunner(pi, signal),
377
408
  )
378
409
 
379
410
  return {
@@ -543,12 +574,58 @@ export default function ceCoreExtension(pi: ExtensionAPI) {
543
574
  },
544
575
  }
545
576
  })
577
+
578
+ // Read output smart filter — reduces context waste from large file reads
579
+ pi.on("tool_result", async (event, _ctx) => {
580
+ if (event.toolName !== "read") return undefined
581
+
582
+ // Extract path from input
583
+ const path = (event.input as any)?.path ?? ""
584
+ if (!path) return undefined
585
+
586
+ // Extract text content from tool result
587
+ const textBlocks = (event.content as Array<any>)?.filter((b: any) => b.type === "text") ?? []
588
+ if (textBlocks.length === 0) return undefined
589
+
590
+ const output = textBlocks.map((b: any) => b.text).join("")
591
+ const isImage = (event.content as Array<any>)?.some((b: any) => b.type === "image") ?? false
592
+
593
+ const result = filterReadOutput({
594
+ path,
595
+ output,
596
+ isError: event.isError ?? false,
597
+ isImage,
598
+ })
599
+
600
+ if (!result.filtered) return undefined
601
+
602
+ return {
603
+ content: [{ type: "text", text: result.output }],
604
+ details: {
605
+ ...event.details,
606
+ readFilter: {
607
+ strategy: result.strategy,
608
+ originalBytes: result.originalBytes,
609
+ filteredBytes: result.filteredBytes,
610
+ },
611
+ },
612
+ }
613
+ })
614
+
615
+ // Compaction prompt optimizer — makes summaries more focused and useful
616
+ pi.on("session_before_compact", async (_event, _ctx) => {
617
+ return {
618
+ customInstructions: COMPACTION_FOCUS_INSTRUCTIONS,
619
+ }
620
+ })
546
621
  }
547
622
 
548
623
 
549
624
  export { createArtifactHelperTool } from "./tools/artifact-helper"
550
625
  export { createAskUserQuestionTool } from "./tools/ask-user-question"
551
626
  export { createSubagentTool } from "./tools/subagent"
627
+ export { checkSubagentDepth, getChildDepthEnv, DEFAULT_MAX_SUBAGENT_DEPTH } from "./tools/subagent-depth-guard"
628
+ export { AsyncMutex } from "./tools/async-mutex"
552
629
  export { createWorkflowStateTool } from "./tools/workflow-state"
553
630
  export { createWorktreeManagerTool } from "./tools/worktree-manager"
554
631
  export { createReviewRouterTool } from "./tools/review-router"
@@ -567,3 +644,5 @@ export {
567
644
  } from "./utils/artifact-paths"
568
645
  export { normalizeSlug } from "./utils/name-utils"
569
646
  export { filterBashOutput } from "./tools/bash-output-filter"
647
+ export { filterReadOutput } from "./tools/read-output-filter"
648
+ export { COMPACTION_FOCUS_INSTRUCTIONS, TURN_PREFIX_FOCUS_INSTRUCTIONS } from "./tools/compaction-optimizer"
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Async mutex for serializing critical sections that mutate shared state
3
+ * (e.g. process.env) across concurrent async operations.
4
+ */
5
+ export class AsyncMutex {
6
+ private queue: (() => void)[] = []
7
+ private locked = false
8
+
9
+ async acquire(): Promise<() => void> {
10
+ return new Promise<() => void>((resolve) => {
11
+ const tryAcquire = () => {
12
+ if (!this.locked) {
13
+ this.locked = true
14
+ resolve(() => this.release())
15
+ } else {
16
+ this.queue.push(tryAcquire)
17
+ }
18
+ }
19
+ tryAcquire()
20
+ })
21
+ }
22
+
23
+ private release(): void {
24
+ this.locked = false
25
+ const next = this.queue.shift()
26
+ if (next) next()
27
+ }
28
+ }
@@ -0,0 +1,38 @@
1
+ // ============================================================================
2
+ // Compaction Prompt Optimizer
3
+ // ============================================================================
4
+ //
5
+ // Hooks into `session_before_compact` to inject custom instructions that make
6
+ // compaction summaries more focused and useful for coding agent context.
7
+ //
8
+ // This is a "prompt-only" optimization — it doesn't replace pi's compaction
9
+ // flow, just adds focus instructions to the summarization prompt.
10
+
11
+ /**
12
+ * Custom instructions appended to compaction summarization prompts.
13
+ *
14
+ * Goals:
15
+ * 1. Preserve exact technical identifiers (paths, names, error messages)
16
+ * 2. Be terse on reasoning process, verbose on concrete state changes
17
+ * 3. Summarize file reads by purpose rather than including code snippets
18
+ * 4. Keep Critical Context section detailed for continuation
19
+ */
20
+ export const COMPACTION_FOCUS_INSTRUCTIONS = `Additional focus for this summary:
21
+
22
+ 1. Preserve EXACT file paths, function names, class names, variable names, and error messages — never paraphrase these
23
+ 2. For each code change, note: file path, function/class, what changed, and why
24
+ 3. Summarize file reads by their purpose (e.g., "read auth.ts to understand JWT middleware flow") rather than including code snippets
25
+ 4. Be concise on the agent's reasoning process; be verbose on concrete state changes and decisions
26
+ 5. Keep the "Critical Context" section detailed — this is what the agent needs to continue working
27
+ 6. If any tests were run, summarize results by: file, pass/fail count, and specific failure messages
28
+ 7. Note any blocked items and their exact error state`
29
+
30
+ /**
31
+ * Additional instructions for turn-prefix summaries (split turns).
32
+ * These are more concise since the turn suffix is retained.
33
+ */
34
+ export const TURN_PREFIX_FOCUS_INSTRUCTIONS = `Focus the turn-prefix summary on:
35
+ - What the user originally asked for
36
+ - Key decisions made in the prefix
37
+ - Exact file paths and identifiers needed to understand the retained suffix
38
+ Skip reasoning details — only keep actionable context.`
@@ -1,3 +1,17 @@
1
+ /**
2
+ * Parallel subagent tool — runs multiple skill-based subagent tasks concurrently.
3
+ *
4
+ * Features:
5
+ * - Recursion depth guard (PI_SUBAGENT_DEPTH / PI_SUBAGENT_MAX_DEPTH)
6
+ * - Optional context slimming via --no-skills flag
7
+ */
8
+
9
+ import {
10
+ checkSubagentDepth,
11
+ getChildDepthEnv,
12
+ } from "./subagent-depth-guard"
13
+ import type { SubagentExecOptions, SubagentRunner } from "./subagent"
14
+
1
15
  export interface ParallelSubagentTask {
2
16
  agent: string
3
17
  task: string
@@ -5,6 +19,8 @@ export interface ParallelSubagentTask {
5
19
 
6
20
  export interface ParallelSubagentInput {
7
21
  tasks: ParallelSubagentTask[]
22
+ /** Whether subagents should inherit skills. Default: false for parallel */
23
+ inheritSkills?: boolean
8
24
  }
9
25
 
10
26
  export interface ParallelResultItem {
@@ -18,8 +34,6 @@ export interface ParallelSubagentResult {
18
34
  outputs: ParallelResultItem[]
19
35
  }
20
36
 
21
- export type SubagentRunner = (prompt: string) => Promise<string>
22
-
23
37
  function buildPrompt(agent: string, task: string): string {
24
38
  return `/skill:${agent} ${task}`
25
39
  }
@@ -28,12 +42,22 @@ export function createParallelSubagentTool() {
28
42
  return {
29
43
  name: "parallel_subagent",
30
44
  async execute(input: ParallelSubagentInput, runner: SubagentRunner): Promise<ParallelSubagentResult> {
45
+ // Recursion depth guard
46
+ const depthCheck = checkSubagentDepth()
47
+ if (!depthCheck.allowed) {
48
+ throw new Error(depthCheck.reason!)
49
+ }
50
+
31
51
  if (!input.tasks || input.tasks.length === 0) {
32
52
  throw new Error("parallel_subagent requires at least one task")
33
53
  }
34
54
 
55
+ // Default: parallel workers get a slim context (no inherited skills)
56
+ const inheritSkills = input.inheritSkills ?? false
57
+ const execOptions = buildParallelExecOptions(inheritSkills)
58
+
35
59
  const promises = input.tasks.map(({ agent, task }) =>
36
- runner(buildPrompt(agent, task)),
60
+ runner(buildPrompt(agent, task), execOptions),
37
61
  )
38
62
 
39
63
  const settled = await Promise.allSettled(promises)
@@ -52,3 +76,25 @@ export function createParallelSubagentTool() {
52
76
  },
53
77
  }
54
78
  }
79
+
80
+ /**
81
+ * Build exec options for parallel subagent workers.
82
+ * Defaults to slim context (no inherited skills).
83
+ */
84
+ function buildParallelExecOptions(inheritSkills: boolean): SubagentExecOptions {
85
+ const flags: string[] = []
86
+ const env: Record<string, string> = {}
87
+
88
+ // Context slimming
89
+ if (!inheritSkills) {
90
+ flags.push("--no-skills")
91
+ }
92
+
93
+ // Recursion depth
94
+ Object.assign(env, getChildDepthEnv())
95
+
96
+ return {
97
+ extraFlags: flags.length > 0 ? flags : undefined,
98
+ extraEnv: env,
99
+ }
100
+ }
@@ -0,0 +1,506 @@
1
+ // ============================================================================
2
+ // Read Output Filter
3
+ // ============================================================================
4
+ //
5
+ // Filters `read` tool output at the source (via tool_result hook) to reduce
6
+ // context waste from large file reads. Symmetric with bash-output-filter.ts.
7
+ //
8
+ // Strategies:
9
+ // 1. Lock / generated files → extreme compression (summary only)
10
+ // 2. package.json → keep scripts + dep summary, drop full version lists
11
+ // 3. Large code files → keep signatures/structure, collapse bodies
12
+ // 4. Large markdown → keep headings + first paragraph per section
13
+ // 5. Generic → head/tail truncation for very large files
14
+
15
+ // ============================================================================
16
+ // Types
17
+ // ============================================================================
18
+
19
+ export interface ReadOutputFilterInput {
20
+ /** File path that was read */
21
+ path: string
22
+ /** The raw output text */
23
+ output: string
24
+ /** Whether the read resulted in an error */
25
+ isError?: boolean
26
+ /** Whether the file is an image (skip filtering) */
27
+ isImage?: boolean
28
+ }
29
+
30
+ export interface ReadOutputFilterResult {
31
+ /** Filtered output text */
32
+ output: string
33
+ /** Whether filtering was applied */
34
+ filtered: boolean
35
+ /** Original size in bytes */
36
+ originalBytes: number
37
+ /** Filtered size in bytes */
38
+ filteredBytes: number
39
+ /** The filter strategy that was applied */
40
+ strategy: string
41
+ }
42
+
43
+ // ============================================================================
44
+ // File Classification
45
+ // ============================================================================
46
+
47
+ type FileCategory =
48
+ | "lock-file"
49
+ | "generated-file"
50
+ | "minified-file"
51
+ | "package-json"
52
+ | "code"
53
+ | "markdown"
54
+ | "config"
55
+ | "unknown"
56
+
57
+ interface FilePattern {
58
+ test: (path: string) => boolean
59
+ category: FileCategory
60
+ }
61
+
62
+ const FILE_PATTERNS: FilePattern[] = [
63
+ // Lock files — extreme compression
64
+ { test: (p) => /(^|\/)(package-lock\.json|yarn\.lock|bun\.lock|pnpm-lock\.yaml|Gemfile\.lock|Podfile\.lock|composer\.lock|Cargo\.lock)$/.test(p), category: "lock-file" },
65
+ // Minified / bundle files
66
+ { test: (p) => /\.(min\.js|min\.css|bundle\.js|chunk\.js|vendor\.js)$/i.test(p), category: "minified-file" },
67
+ // Generated files
68
+ { test: (p) => /\.(generated\.\w+|auto\.\w+|pb\.ts)$/i.test(p) || /(?:^|\/)(generated|auto-generated|__generated__)\//i.test(p), category: "generated-file" },
69
+ // package.json — smart filter
70
+ { test: (p) => /(^|\/)package\.json$/.test(p), category: "package-json" },
71
+ // Markdown
72
+ { test: (p) => /\.(md|mdx)$/i.test(p), category: "markdown" },
73
+ // Config files (JSON/YAML/TOML without lock)
74
+ { test: (p) => /\.(jsonc?|ya?ml|toml|ini|conf|rc)$/i.test(p) && !/lock/i.test(p), category: "config" },
75
+ // Code files
76
+ { test: (p) => /\.(ts|tsx|js|jsx|py|rs|go|java|kt|rb|c|cpp|h|hpp|cs|swift|zig|scala|clj)$/i.test(p), category: "code" },
77
+ // Code files (alternate extensions)
78
+ { test: (p) => /\.(vue|svelte|astro|sol|dart|lua|r|pl|pm|sh|bash|zsh|fish|ps1)$/i.test(p), category: "code" },
79
+ ]
80
+
81
+ function classifyFile(path: string): FileCategory {
82
+ const normalized = path.replace(/\\/g, "/")
83
+ for (const pattern of FILE_PATTERNS) {
84
+ if (pattern.test(normalized)) return pattern.category
85
+ }
86
+ return "unknown"
87
+ }
88
+
89
+ // ============================================================================
90
+ // Constants
91
+ // ============================================================================
92
+
93
+ /** Minimum output size (bytes) to trigger any filtering */
94
+ const MIN_FILTER_THRESHOLD = 2048 // 2KB
95
+
96
+ /** Maximum size for structural code compression trigger */
97
+ const CODE_COMPRESS_THRESHOLD = 8192 // 8KB
98
+
99
+ /** Maximum filtered output size (bytes) */
100
+ const MAX_FILTERED_SIZE = 30720 // 30KB
101
+
102
+ // ============================================================================
103
+ // Filter: Lock Files
104
+ // ============================================================================
105
+
106
+ function filterLockFile(output: string, path: string): string {
107
+ const bytes = Buffer.byteLength(output, "utf-8")
108
+ const lines = output.split("\n").length
109
+
110
+ // Try to extract name and lockfileVersion from JSON lock files
111
+ let summary = `Lock file: ${path.split("/").pop()}`
112
+
113
+ try {
114
+ const data = JSON.parse(output)
115
+ if (data.name) summary += `\nProject: ${data.name}`
116
+ if (data.lockfileVersion) summary += `\nLockfile version: ${data.lockfileVersion}`
117
+ if (data.packages) {
118
+ const pkgCount = Object.keys(data.packages).length
119
+ summary += `\nPackages: ${pkgCount}`
120
+ }
121
+ } catch {
122
+ // Not JSON (yarn.lock, etc.) — count lines
123
+ summary += `\nLines: ${lines}`
124
+ }
125
+
126
+ summary += `\nOriginal size: ${formatBytes(bytes)}`
127
+ return summary
128
+ }
129
+
130
+ // ============================================================================
131
+ // Filter: Minified / Generated Files
132
+ // ============================================================================
133
+
134
+ function filterMinifiedFile(output: string, path: string): string {
135
+ const bytes = Buffer.byteLength(output, "utf-8")
136
+ const ext = path.split(".").pop() ?? ""
137
+ return [
138
+ `Minified ${ext} file: ${path.split("/").pop()}`,
139
+ `Size: ${formatBytes(bytes)} (${output.split("\n").length} lines)`,
140
+ `First 500 chars:`,
141
+ output.slice(0, 500),
142
+ "",
143
+ `[... ${formatBytes(bytes - 500)} omitted]`,
144
+ ].join("\n")
145
+ }
146
+
147
+ function filterGeneratedFile(output: string, path: string): string {
148
+ const bytes = Buffer.byteLength(output, "utf-8")
149
+ const lines = output.split("\n")
150
+
151
+ // Keep first 30 lines + last 10 lines + count
152
+ if (lines.length <= 50) return output
153
+
154
+ const head = lines.slice(0, 30)
155
+ const tail = lines.slice(-10)
156
+ const omitted = lines.length - 40
157
+
158
+ return [
159
+ ...head,
160
+ "",
161
+ `[... ${omitted} lines omitted (generated file, ${formatBytes(bytes)})]`,
162
+ "",
163
+ ...tail,
164
+ ].join("\n")
165
+ }
166
+
167
+ // ============================================================================
168
+ // Filter: package.json
169
+ // ============================================================================
170
+
171
+ function filterPackageJson(output: string): string {
172
+ try {
173
+ const pkg = JSON.parse(output)
174
+ const sections: string[] = []
175
+
176
+ sections.push(`Name: ${pkg.name ?? "unknown"}`)
177
+ if (pkg.version) sections.push(`Version: ${pkg.version}`)
178
+ if (pkg.description) sections.push(`Description: ${pkg.description}`)
179
+
180
+ if (pkg.scripts && Object.keys(pkg.scripts).length > 0) {
181
+ sections.push("")
182
+ sections.push("Scripts:")
183
+ for (const [name, cmd] of Object.entries(pkg.scripts)) {
184
+ sections.push(` ${name}: ${cmd}`)
185
+ }
186
+ }
187
+
188
+ if (pkg.dependencies) {
189
+ const depCount = Object.keys(pkg.dependencies).length
190
+ sections.push("")
191
+ sections.push(`Dependencies (${depCount}):`)
192
+ if (depCount <= 15) {
193
+ for (const [name, ver] of Object.entries(pkg.dependencies)) {
194
+ sections.push(` ${name}: ${ver}`)
195
+ }
196
+ } else {
197
+ // Show first 10 + summary
198
+ const entries = Object.entries(pkg.dependencies)
199
+ for (const [name, ver] of entries.slice(0, 10)) {
200
+ sections.push(` ${name}: ${ver}`)
201
+ }
202
+ sections.push(` ... and ${depCount - 10} more`)
203
+ }
204
+ }
205
+
206
+ if (pkg.devDependencies) {
207
+ const devCount = Object.keys(pkg.devDependencies).length
208
+ sections.push("")
209
+ sections.push(`DevDependencies (${devCount}):`)
210
+ if (devCount <= 10) {
211
+ for (const [name, ver] of Object.entries(pkg.devDependencies)) {
212
+ sections.push(` ${name}: ${ver}`)
213
+ }
214
+ } else {
215
+ const entries = Object.entries(pkg.devDependencies)
216
+ for (const [name, ver] of entries.slice(0, 5)) {
217
+ sections.push(` ${name}: ${ver}`)
218
+ }
219
+ sections.push(` ... and ${devCount - 5} more`)
220
+ }
221
+ }
222
+
223
+ return sections.join("\n")
224
+ } catch {
225
+ // Not valid JSON — generic truncation
226
+ return truncateGeneric(output)
227
+ }
228
+ }
229
+
230
+ // ============================================================================
231
+ // Filter: Large Code Files — Structural Compression
232
+ // ============================================================================
233
+
234
+ /**
235
+ * Structural compression for code files.
236
+ * Keeps: imports, exports, type definitions, function/class signatures, comments with TODO/FIXME/HACK
237
+ * Collapses: function bodies > N lines, consecutive blank lines
238
+ */
239
+ function filterCodeFile(output: string): string {
240
+ const lines = output.split("\n")
241
+ const kept: string[] = []
242
+ let bodyDepth = 0
243
+ let bodyLines = 0
244
+ let skippedInBody = 0
245
+ const MAX_BODY_LINES = 8 // keep first N lines of a body
246
+
247
+ for (let i = 0; i < lines.length; i++) {
248
+ const line = lines[i]
249
+ const trimmed = line.trim()
250
+
251
+ // Track brace depth for body detection
252
+ const opens = (line.match(/\{/g) || []).length
253
+ const closes = (line.match(/\}/g) || []).length
254
+
255
+ // Always keep: empty lines (structure), imports, exports, type/interface/class declarations
256
+ // comments with TODO/FIXME/HACK, decorators
257
+ if (
258
+ trimmed === "" ||
259
+ /^(import |export |from )/.test(trimmed) ||
260
+ /^(export )?(default |abstract |async |const |let |var |function |class |interface |type |enum )/.test(trimmed) ||
261
+ /^(\/\/|#|\/\*|\*)/.test(trimmed) ||
262
+ /\b(TODO|FIXME|HACK|XXX|WARN|BUG|NOTE)\b/i.test(trimmed) ||
263
+ /^@/.test(trimmed) || // decorators
264
+ /^}\s*$/.test(trimmed) // closing brace
265
+ ) {
266
+ // If we were skipping body lines, add omission marker
267
+ if (skippedInBody > 0) {
268
+ kept.push(` [... ${skippedInBody} lines omitted]`)
269
+ skippedInBody = 0
270
+ }
271
+ kept.push(line)
272
+ bodyDepth += opens - closes
273
+ bodyLines = 0
274
+ continue
275
+ }
276
+
277
+ // Inside a body (after opening brace)
278
+ bodyDepth += opens - closes
279
+
280
+ if (bodyDepth > 1 || (bodyDepth === 1 && bodyLines < MAX_BODY_LINES)) {
281
+ kept.push(line)
282
+ bodyLines++
283
+ } else if (bodyDepth === 1 && bodyLines >= MAX_BODY_LINES) {
284
+ // Skip body lines but track count
285
+ skippedInBody++
286
+ bodyLines++
287
+ } else {
288
+ // Top-level or unknown — keep it
289
+ kept.push(line)
290
+ }
291
+ }
292
+
293
+ // Final omission marker
294
+ if (skippedInBody > 0) {
295
+ kept.push(` [... ${skippedInBody} lines omitted]`)
296
+ }
297
+
298
+ return deduplicateEmptyLines(kept.join("\n"))
299
+ }
300
+
301
+ // ============================================================================
302
+ // Filter: Markdown
303
+ // ============================================================================
304
+
305
+ function filterMarkdown(output: string): string {
306
+ const lines = output.split("\n")
307
+ const kept: string[] = []
308
+ let inParagraph = false
309
+ let paragraphLineCount = 0
310
+ let inCodeBlock = false
311
+
312
+ for (const line of lines) {
313
+ const trimmed = line.trim()
314
+
315
+ // Track code block state
316
+ if (trimmed.startsWith("```")) {
317
+ inCodeBlock = !inCodeBlock
318
+ kept.push(line)
319
+ continue
320
+ }
321
+
322
+ // Keep all lines inside code blocks
323
+ if (inCodeBlock) {
324
+ kept.push(line)
325
+ continue
326
+ }
327
+
328
+ // Always keep headings
329
+ if (/^#{1,6}\s/.test(trimmed)) {
330
+ kept.push(line)
331
+ inParagraph = false
332
+ paragraphLineCount = 0
333
+ continue
334
+ }
335
+
336
+ // Empty lines
337
+ if (trimmed === "") {
338
+ kept.push(line)
339
+ inParagraph = false
340
+ paragraphLineCount = 0
341
+ continue
342
+ }
343
+
344
+ // Keep first paragraph line after heading, skip the rest
345
+ if (!inParagraph) {
346
+ kept.push(line)
347
+ inParagraph = true
348
+ paragraphLineCount = 1
349
+ } else {
350
+ paragraphLineCount++
351
+ // Skip subsequent paragraph lines
352
+ if (paragraphLineCount === 2) {
353
+ kept.push(` [... additional paragraph content omitted]`)
354
+ }
355
+ }
356
+ }
357
+
358
+ return deduplicateEmptyLines(kept.join("\n"))
359
+ }
360
+
361
+ // ============================================================================
362
+ // Filter: Generic
363
+ // ============================================================================
364
+
365
+ function truncateGeneric(output: string): string {
366
+ const lines = output.split("\n")
367
+ const bytes = Buffer.byteLength(output, "utf-8")
368
+
369
+ if (lines.length <= 100) return output
370
+
371
+ const head = lines.slice(0, 50)
372
+ const tail = lines.slice(-10)
373
+ const omitted = lines.length - 60
374
+
375
+ return [
376
+ ...head,
377
+ "",
378
+ `[... ${omitted} lines omitted (${formatBytes(bytes)})]`,
379
+ "",
380
+ ...tail,
381
+ ].join("\n")
382
+ }
383
+
384
+ // ============================================================================
385
+ // Helpers
386
+ // ============================================================================
387
+
388
+ function deduplicateEmptyLines(text: string): string {
389
+ return text.replace(/\n{3,}/g, "\n\n")
390
+ }
391
+
392
+ function formatBytes(bytes: number): string {
393
+ if (bytes < 1024) return `${bytes}B`
394
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
395
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
396
+ }
397
+
398
+ function appendFilterNotice(
399
+ output: string,
400
+ originalBytes: number,
401
+ filteredBytes: number,
402
+ strategy: string,
403
+ ): string {
404
+ const saved = formatBytes(originalBytes - filteredBytes)
405
+ return `${output}\n\n[Read output filtered: ${formatBytes(originalBytes)} → ${formatBytes(filteredBytes)} (${saved} saved, strategy: ${strategy})]`
406
+ }
407
+
408
+ // ============================================================================
409
+ // Main Filter Function
410
+ // ============================================================================
411
+
412
+ export function filterReadOutput(input: ReadOutputFilterInput): ReadOutputFilterResult {
413
+ const { path, output, isError, isImage } = input
414
+ const originalBytes = Buffer.byteLength(output, "utf-8")
415
+
416
+ // Don't filter images
417
+ if (isImage) {
418
+ return { output, filtered: false, originalBytes, filteredBytes: originalBytes, strategy: "none (image)" }
419
+ }
420
+
421
+ // Don't filter error outputs
422
+ if (isError) {
423
+ return { output, filtered: false, originalBytes, filteredBytes: originalBytes, strategy: "none (error)" }
424
+ }
425
+
426
+ // Don't filter small outputs
427
+ if (originalBytes < MIN_FILTER_THRESHOLD) {
428
+ return { output, filtered: false, originalBytes, filteredBytes: originalBytes, strategy: "none (below threshold)" }
429
+ }
430
+
431
+ const category = classifyFile(path)
432
+
433
+ let filtered: string
434
+ let strategyName: string
435
+
436
+ switch (category) {
437
+ case "lock-file":
438
+ filtered = filterLockFile(output, path)
439
+ strategyName = "lock-file (summary only)"
440
+ break
441
+
442
+ case "minified-file":
443
+ filtered = filterMinifiedFile(output, path)
444
+ strategyName = "minified (head only)"
445
+ break
446
+
447
+ case "generated-file":
448
+ filtered = filterGeneratedFile(output, path)
449
+ strategyName = "generated (head + tail)"
450
+ break
451
+
452
+ case "package-json":
453
+ filtered = filterPackageJson(output)
454
+ strategyName = "package.json (scripts + dep summary)"
455
+ break
456
+
457
+ case "code":
458
+ if (originalBytes >= CODE_COMPRESS_THRESHOLD) {
459
+ filtered = filterCodeFile(output)
460
+ strategyName = "code (structural compression)"
461
+ } else {
462
+ return { output, filtered: false, originalBytes, filteredBytes: originalBytes, strategy: "none (code file below threshold)" }
463
+ }
464
+ break
465
+
466
+ case "markdown":
467
+ filtered = filterMarkdown(output)
468
+ strategyName = "markdown (headings + first paragraph)"
469
+ break
470
+
471
+ case "config":
472
+ // Config files: only filter if very large
473
+ if (originalBytes > MAX_FILTERED_SIZE) {
474
+ filtered = truncateGeneric(output)
475
+ strategyName = "config (generic truncation)"
476
+ } else {
477
+ return { output, filtered: false, originalBytes, filteredBytes: originalBytes, strategy: "none (config, within limit)" }
478
+ }
479
+ break
480
+
481
+ default:
482
+ // Unknown files: only filter if very large
483
+ if (originalBytes > MAX_FILTERED_SIZE) {
484
+ filtered = truncateGeneric(output)
485
+ strategyName = "generic (large unknown file)"
486
+ } else {
487
+ return { output, filtered: false, originalBytes, filteredBytes: originalBytes, strategy: "none (unknown, within limit)" }
488
+ }
489
+ break
490
+ }
491
+
492
+ const filteredBytes = Buffer.byteLength(filtered, "utf-8")
493
+
494
+ // If filtering didn't help much (< 20% reduction), keep original
495
+ if (filteredBytes > originalBytes * 0.8) {
496
+ return { output, filtered: false, originalBytes, filteredBytes, strategy: `none (${category}: filtering insufficient, <20% reduction)` }
497
+ }
498
+
499
+ return {
500
+ output: appendFilterNotice(filtered, originalBytes, filteredBytes, strategyName),
501
+ filtered: true,
502
+ originalBytes,
503
+ filteredBytes,
504
+ strategy: strategyName,
505
+ }
506
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Subagent recursion depth guard.
3
+ *
4
+ * Prevents uncontrolled recursive subagent spawning by tracking depth
5
+ * via environment variables. Inspired by pi-subagents' depth control
6
+ * but implemented as a minimal, standalone module.
7
+ *
8
+ * Environment variables:
9
+ * PI_SUBAGENT_DEPTH — current nesting depth (default: 0)
10
+ * PI_SUBAGENT_MAX_DEPTH — maximum allowed depth (default: 2)
11
+ */
12
+
13
+ /** Default maximum subagent nesting depth */
14
+ export const DEFAULT_MAX_SUBAGENT_DEPTH = 2
15
+
16
+ /**
17
+ * Read the current subagent depth from environment.
18
+ * Returns 0 if unset or invalid.
19
+ */
20
+ export function getCurrentDepth(): number {
21
+ const raw = process.env.PI_SUBAGENT_DEPTH
22
+ if (!raw) return 0
23
+ const depth = parseInt(raw, 10)
24
+ return Number.isNaN(depth) ? 0 : depth
25
+ }
26
+
27
+ /**
28
+ * Read the maximum allowed depth from environment.
29
+ * Falls back to DEFAULT_MAX_SUBAGENT_DEPTH if unset or invalid.
30
+ */
31
+ export function getMaxDepth(): number {
32
+ const raw = process.env.PI_SUBAGENT_MAX_DEPTH
33
+ if (!raw) return DEFAULT_MAX_SUBAGENT_DEPTH
34
+ const max = parseInt(raw, 10)
35
+ return Number.isNaN(max) || max < 0 ? DEFAULT_MAX_SUBAGENT_DEPTH : max
36
+ }
37
+
38
+ /**
39
+ * Check whether subagent execution is allowed at the current depth.
40
+ * Returns an object with:
41
+ * allowed — whether execution can proceed
42
+ * depth — the current depth
43
+ * max — the maximum depth
44
+ * reason — human-readable explanation if blocked
45
+ */
46
+ export function checkSubagentDepth(): {
47
+ allowed: boolean
48
+ depth: number
49
+ max: number
50
+ reason?: string
51
+ } {
52
+ const depth = getCurrentDepth()
53
+ const max = getMaxDepth()
54
+
55
+ if (depth >= max) {
56
+ return {
57
+ allowed: false,
58
+ depth,
59
+ max,
60
+ reason: `Subagent recursion depth limit reached (depth=${depth}, max=${max}). ` +
61
+ `Set PI_SUBAGENT_MAX_DEPTH to increase the limit.`,
62
+ }
63
+ }
64
+
65
+ return { allowed: true, depth, max }
66
+ }
67
+
68
+ /**
69
+ * Build environment variables to pass to a child subagent process.
70
+ * Increments the depth counter so the child can guard its own spawns.
71
+ *
72
+ * @param overrides — optional overrides for max depth per-agent
73
+ */
74
+ export function getChildDepthEnv(overrides?: {
75
+ maxDepth?: number
76
+ }): Record<string, string> {
77
+ const currentDepth = getCurrentDepth()
78
+ const childDepth = currentDepth + 1
79
+ let maxDepth = overrides?.maxDepth ?? getMaxDepth()
80
+ if (maxDepth < 0) maxDepth = DEFAULT_MAX_SUBAGENT_DEPTH
81
+
82
+ return {
83
+ PI_SUBAGENT_DEPTH: String(childDepth),
84
+ PI_SUBAGENT_MAX_DEPTH: String(maxDepth),
85
+ }
86
+ }
@@ -1,3 +1,16 @@
1
+ /**
2
+ * Subagent tool — runs a single skill-based subagent or a serial chain.
3
+ *
4
+ * Features:
5
+ * - Recursion depth guard (PI_SUBAGENT_DEPTH / PI_SUBAGENT_MAX_DEPTH)
6
+ * - Optional context slimming via --no-skills flag
7
+ */
8
+
9
+ import {
10
+ checkSubagentDepth,
11
+ getChildDepthEnv,
12
+ } from "./subagent-depth-guard"
13
+
1
14
  export interface SubagentTask {
2
15
  agent: string
3
16
  task: string
@@ -7,6 +20,8 @@ export interface SubagentInput {
7
20
  agent?: string
8
21
  task?: string
9
22
  chain?: SubagentTask[]
23
+ /** Whether the subagent should inherit skills. Default: true */
24
+ inheritSkills?: boolean
10
25
  }
11
26
 
12
27
  export interface SubagentResult {
@@ -14,12 +29,28 @@ export interface SubagentResult {
14
29
  outputs: string[]
15
30
  }
16
31
 
17
- export type SubagentRunner = (prompt: string) => Promise<string>
32
+ export interface SubagentExecOptions {
33
+ /** Extra CLI flags to pass to the child pi process */
34
+ extraFlags?: string[]
35
+ /** Environment variables to inject into the child process */
36
+ extraEnv?: Record<string, string>
37
+ }
38
+
39
+ export type SubagentRunner = (
40
+ prompt: string,
41
+ options?: SubagentExecOptions,
42
+ ) => Promise<string>
18
43
 
19
44
  export function createSubagentTool() {
20
45
  return {
21
46
  name: "subagent",
22
47
  async execute(input: SubagentInput, runner: SubagentRunner): Promise<SubagentResult> {
48
+ // Recursion depth guard
49
+ const depthCheck = checkSubagentDepth()
50
+ if (!depthCheck.allowed) {
51
+ throw new Error(depthCheck.reason!)
52
+ }
53
+
23
54
  const hasSingle = Boolean(input.agent && input.task)
24
55
  const hasChain = Boolean(input.chain && input.chain.length > 0)
25
56
 
@@ -27,9 +58,11 @@ export function createSubagentTool() {
27
58
  throw new Error("Provide exactly one mode: single or chain")
28
59
  }
29
60
 
61
+ const execOptions = buildExecOptions(input.inheritSkills)
62
+
30
63
  if (hasSingle) {
31
64
  const prompt = buildPrompt(input.agent!, input.task!)
32
- const output = await runner(prompt)
65
+ const output = await runner(prompt, execOptions)
33
66
  return {
34
67
  mode: "single",
35
68
  outputs: [output],
@@ -41,7 +74,7 @@ export function createSubagentTool() {
41
74
 
42
75
  for (const task of input.chain ?? []) {
43
76
  const prompt = buildPrompt(task.agent, task.task.replace(/\{previous\}/g, previous))
44
- const output = await runner(prompt)
77
+ const output = await runner(prompt, execOptions)
45
78
  outputs.push(output)
46
79
  previous = output
47
80
  }
@@ -54,6 +87,28 @@ export function createSubagentTool() {
54
87
  }
55
88
  }
56
89
 
90
+ /**
91
+ * Build exec options based on subagent configuration.
92
+ * Controls context inheritance and recursion depth.
93
+ */
94
+ function buildExecOptions(inheritSkills?: boolean): SubagentExecOptions {
95
+ const flags: string[] = []
96
+ const env: Record<string, string> = {}
97
+
98
+ // Context slimming: skip skills inheritance when explicitly disabled
99
+ if (inheritSkills === false) {
100
+ flags.push("--no-skills")
101
+ }
102
+
103
+ // Recursion depth: pass incremented depth to child process
104
+ Object.assign(env, getChildDepthEnv())
105
+
106
+ return {
107
+ extraFlags: flags.length > 0 ? flags : undefined,
108
+ extraEnv: env,
109
+ }
110
+ }
111
+
57
112
  function buildPrompt(agent: string, task: string): string {
58
113
  return `/skill:${agent} ${task}`
59
114
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leing2021/super-pi",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Pi-native Compound Engineering package for iterative development workflows",