pi-code 1.0.56 → 1.0.57

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.
Files changed (41) hide show
  1. package/extensions/commands.ts +6 -12
  2. package/extensions/context-imports.ts +2 -10
  3. package/extensions/env-settings.ts +1 -5
  4. package/extensions/git-checkpoint.ts +4 -12
  5. package/extensions/goal.ts +2 -2
  6. package/extensions/hooks/config.ts +6 -21
  7. package/extensions/hooks/decisions.ts +3 -2
  8. package/extensions/hooks/index.ts +4 -16
  9. package/extensions/hooks/matcher.ts +2 -1
  10. package/extensions/hooks/runners.ts +4 -3
  11. package/extensions/internal/command-file.ts +7 -241
  12. package/extensions/internal/command-spans.ts +246 -0
  13. package/extensions/internal/managed-settings.ts +3 -5
  14. package/extensions/internal/plugins.ts +2 -2
  15. package/extensions/internal/settings-chain.ts +19 -0
  16. package/extensions/internal/values.ts +38 -0
  17. package/extensions/mcp/index.ts +6 -5
  18. package/extensions/mcp/listing.ts +2 -1
  19. package/extensions/mcp/oauth-flow.ts +2 -1
  20. package/extensions/mcp/policy.ts +9 -2
  21. package/extensions/memory.ts +10 -15
  22. package/extensions/output-styles.ts +4 -17
  23. package/extensions/plan-mode/index.ts +9 -9
  24. package/extensions/plan-mode/utils.ts +31 -0
  25. package/extensions/session-title.ts +2 -12
  26. package/extensions/skills.ts +6 -18
  27. package/extensions/status-line.ts +2 -8
  28. package/extensions/subagent/README.md +12 -2
  29. package/extensions/subagent/agents.ts +2 -2
  30. package/extensions/subagent/background.ts +2 -1
  31. package/extensions/subagent/child.ts +197 -0
  32. package/extensions/subagent/concurrency.ts +23 -0
  33. package/extensions/subagent/index.ts +36 -1426
  34. package/extensions/subagent/modes.ts +405 -0
  35. package/extensions/subagent/params.ts +56 -0
  36. package/extensions/subagent/registry-text.ts +105 -0
  37. package/extensions/subagent/render-result.ts +306 -0
  38. package/extensions/subagent/run.ts +375 -0
  39. package/extensions/subagent/types.ts +41 -0
  40. package/extensions/subagent/worktree.ts +2 -1
  41. package/package.json +1 -1
@@ -120,6 +120,37 @@ export interface TodoItem {
120
120
  completed: boolean
121
121
  }
122
122
 
123
+ /** The plan state persisted in a session entry, once each field has been checked.
124
+ * A field the restore cannot recognize is simply absent, so the caller keeps its
125
+ * current value. */
126
+ export interface RestoredPlanState {
127
+ enabled?: boolean
128
+ todos?: TodoItem[]
129
+ executing?: boolean
130
+ savedTools?: string[]
131
+ }
132
+
133
+ const isTodoItem = (value: unknown): value is TodoItem => {
134
+ if (value === null || typeof value !== 'object') return false
135
+ const item = value as Record<string, unknown>
136
+ return typeof item.step === 'number' && typeof item.text === 'string' && typeof item.completed === 'boolean'
137
+ }
138
+
139
+ /** Read a persisted plan-mode entry, keeping only fields of the expected shape.
140
+ * The session file is data on disk, and `savedTools` feeds the active tool set: a
141
+ * string there would be spread character by character into the tool gating, and a
142
+ * non-array `todos` throws on the first restore that iterates it. */
143
+ export function restoredPlanState(data: unknown): RestoredPlanState {
144
+ if (data === null || typeof data !== 'object') return {}
145
+ const raw = data as Record<string, unknown>
146
+ const state: RestoredPlanState = {}
147
+ if (typeof raw.enabled === 'boolean') state.enabled = raw.enabled
148
+ if (typeof raw.executing === 'boolean') state.executing = raw.executing
149
+ if (Array.isArray(raw.todos) && raw.todos.every(isTodoItem)) state.todos = raw.todos
150
+ if (Array.isArray(raw.savedTools) && raw.savedTools.every((tool) => typeof tool === 'string')) state.savedTools = raw.savedTools
151
+ return state
152
+ }
153
+
123
154
  function cleanStepText(text: string): string {
124
155
  let cleaned = text
125
156
  .replace(/\*{1,2}([^*]+)\*{1,2}/g, '$1') // Remove bold/italic
@@ -21,6 +21,7 @@
21
21
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
22
22
 
23
23
  import { completeText } from './internal/model-complete.js'
24
+ import { contentText } from './internal/values.js'
24
25
 
25
26
  const TITLE_SYSTEM = 'You name a coding session from its first user message. Reply with a terse 3 to 6 word title in Title Case that captures the task. No quotes, no surrounding punctuation, no trailing period. Output the title only, nothing else.'
26
27
  /** A title is a few words; a tight cap keeps the extra call cheap and stops a runaway reply. */
@@ -29,23 +30,12 @@ const TITLE_MAX_TOKENS = 24
29
30
  * bounded prompt keeps the input cost of the extra call small. */
30
31
  const MAX_PROMPT_CHARS = 1000
31
32
 
32
- /** Join the text of a message's content, mirroring git-checkpoint's extraction: content is
33
- * either a plain string or an array of parts, of which only text parts carry a title's worth. */
34
- function extractText(content: unknown): string {
35
- if (typeof content === 'string') return content
36
- if (!Array.isArray(content)) return ''
37
- return content
38
- .filter((part) => part?.type === 'text' && typeof part.text === 'string')
39
- .map((part) => part.text)
40
- .join(' ')
41
- }
42
-
43
33
  /** Text of the first user message in the branch, or empty when the run carried no user text
44
34
  * (for example a slash-command-only turn), in which case there is nothing to title from. */
45
35
  export function firstUserText(ctx: ExtensionContext): string {
46
36
  for (const entry of ctx.sessionManager.getBranch()) {
47
37
  if (entry?.type === 'message' && entry.message.role === 'user') {
48
- return extractText(entry.message.content).trim()
38
+ return contentText(entry.message.content, ' ').trim()
49
39
  }
50
40
  }
51
41
  return ''
@@ -23,7 +23,6 @@ import * as fs from 'node:fs'
23
23
  import * as os from 'node:os'
24
24
  import * as path from 'node:path'
25
25
  import { type ExtensionAPI, type ExtensionContext, parseFrontmatter } from '@earendil-works/pi-coding-agent'
26
-
27
26
  import { expandCommand, shellExecutionDisabled } from './commands.js'
28
27
  import { runAgent } from './internal/agent-run.js'
29
28
  import { parseCommandFile } from './internal/command-file.js'
@@ -32,16 +31,9 @@ import { managedSettingsFile } from './internal/managed-settings.js'
32
31
  import { installedPlugins, pluginComponentPath } from './internal/plugins.js'
33
32
  import { isProjectApprovedSilently } from './internal/project-approval.js'
34
33
  import { ancestorDirs } from './internal/project-root.js'
35
- import { claudeSettingsChain } from './internal/settings-chain.js'
34
+ import { claudeSettingsChain, readSettingsChain } from './internal/settings-chain.js'
36
35
  import { SKILL_HOOKS_CHANNEL } from './internal/skill-hooks.js'
37
-
38
- function isDirectory(target: string): boolean {
39
- try {
40
- return fs.statSync(target).isDirectory()
41
- } catch {
42
- return false
43
- }
44
- }
36
+ import { errorMessage, isDirectory, isRecord } from './internal/values.js'
45
37
 
46
38
  /** Existing `.claude/skills` directories, user first then project. The project
47
39
  * directory is included only for approved projects: pi's loader surfaces every skill's
@@ -136,13 +128,9 @@ export default function skillsExtension(pi: ExtensionAPI) {
136
128
  * (a pi-loader surface, noted in docs). */
137
129
  function skillOverrideFor(name: string, cwd: string, trusted: boolean): string | undefined {
138
130
  let value: string | undefined
139
- for (const file of claudeSettingsChain(cwd, os.homedir(), trusted)) {
140
- try {
141
- const overrides = JSON.parse(fs.readFileSync(file, 'utf-8')).skillOverrides
142
- if (overrides !== null && typeof overrides === 'object' && typeof overrides[name] === 'string') value = overrides[name]
143
- } catch {
144
- // missing or invalid file: skip
145
- }
131
+ for (const settings of readSettingsChain(claudeSettingsChain(cwd, os.homedir(), trusted))) {
132
+ const overrides = settings.skillOverrides
133
+ if (isRecord(overrides) && typeof overrides[name] === 'string') value = overrides[name]
146
134
  }
147
135
  return value
148
136
  }
@@ -165,7 +153,7 @@ async function runForkedSkill(name: string, filePath: string, expanded: string,
165
153
  const output = await runAgent({ prompt: expanded, fullTools: true, ...(agentName ? { agent: agentName } : {}) })
166
154
  return { action: 'transform', text: `<skill name="${name}" location="${filePath}">\nThe skill ran in a forked subagent (no conversation history shared). Its result:\n\n${output}\n</skill>` }
167
155
  } catch (error) {
168
- return { action: 'transform', text: `<skill name="${name}">\nThe forked subagent run failed: ${error instanceof Error ? error.message : String(error)}\n</skill>` }
156
+ return { action: 'transform', text: `<skill name="${name}">\nThe forked subagent run failed: ${errorMessage(error)}\n</skill>` }
169
157
  }
170
158
  }
171
159
 
@@ -36,6 +36,7 @@ import { claudeEffortLevel } from './internal/effort.js'
36
36
  import { readManagedSettings } from './internal/managed-settings.js'
37
37
  import { isPlanModeState, PLAN_MODE_CHANNEL } from './internal/plan-mode-state.js'
38
38
  import { isProjectApprovedSilently } from './internal/project-approval.js'
39
+ import { readSettingsChain } from './internal/settings-chain.js'
39
40
  import { watchSettingsFiles } from './internal/settings-watch.js'
40
41
  import { readActiveStyleName, settingsFiles } from './output-styles.js'
41
42
 
@@ -178,14 +179,7 @@ export function readStatusLineConfig(files: string[], managed: Record<string, un
178
179
  if (managedConfig) return managedConfig
179
180
  if (managed.allowManagedHooksOnly === true) return undefined
180
181
  let found: StatusLineConfig | undefined
181
- for (const file of files) {
182
- try {
183
- const settings = JSON.parse(fs.readFileSync(file, 'utf-8'))
184
- found = parseStatusLineEntry(settings.statusLine) ?? found
185
- } catch {
186
- // missing or invalid file: skip
187
- }
188
- }
182
+ for (const settings of readSettingsChain(files)) found = parseStatusLineEntry(settings.statusLine) ?? found
189
183
  return found
190
184
  }
191
185
 
@@ -18,9 +18,19 @@ Delegate tasks to specialized subagents with isolated context windows.
18
18
  ```
19
19
  subagent/
20
20
  ├── README.md # This file
21
- ├── index.ts # The extension (entry point)
22
- ├── agents.ts # Agent discovery logic
21
+ ├── index.ts # The extension: tool schema, dispatch, session hooks
22
+ ├── agents.ts # Agent discovery and frontmatter
23
+ ├── child.ts # How a child is configured before it is spawned
24
+ ├── run.ts # Spawning one child and parsing its event stream
25
+ ├── modes.ts # Single, parallel, chain, background, and the project-agent gate
23
26
  ├── background.ts # Background run registry and spawning
27
+ ├── worktree.ts # isolation: worktree setup and teardown
28
+ ├── params.ts # The tool schema and the types derived from it
29
+ ├── types.ts # Result shapes shared by the tool and its renderers
30
+ ├── concurrency.ts # Parallel-run caps and the bounded worker pool
31
+ ├── registry-text.ts # Text for /tasks, /agents and completion notices
32
+ ├── render.ts # Transcript formatting shared with the parent
33
+ ├── render-result.ts # How a call and its results are drawn
24
34
  ├── agents/ # Bundled builtin agents, always available (lowest precedence)
25
35
  │ ├── explore.md # Explore: fast read-only codebase exploration
26
36
  │ ├── plan.md # Plan: read-only implementation planning
@@ -6,7 +6,6 @@ import * as fs from 'node:fs'
6
6
  import * as os from 'node:os'
7
7
  import * as path from 'node:path'
8
8
  import { getAgentDir, parseFrontmatter, stripFrontmatter } from '@earendil-works/pi-coding-agent'
9
-
10
9
  // The same mapping a command's `allowed-tools` gets: an agent's `tools:` is the same
11
10
  // Claude field, and `--tools` is an exact-name allowlist, so a name pi has no tool for
12
11
  // is not merely ignored, it narrows the child's registry.
@@ -14,6 +13,7 @@ import { parseToolGrants } from '../internal/command-file.js'
14
13
  import { claudeConfigDir } from '../internal/config-dir.js'
15
14
  import { installedPlugins, pluginComponentPath } from '../internal/plugins.js'
16
15
  import { ancestorDirs, findNearestDir } from '../internal/project-root.js'
16
+ import { errorMessage } from '../internal/values.js'
17
17
 
18
18
  /**
19
19
  * `tools:` may be a comma-separated string (the Claude Code format) or a YAML block
@@ -180,7 +180,7 @@ function parseAgentFile(content: string, source: AgentSource, filePath: string,
180
180
  } catch (error) {
181
181
  // Malformed YAML must not abort discovery for the whole directory, but a silent drop
182
182
  // reads as "that agent does not exist", so it is named like the other rejections here.
183
- console.warn(`pi-code-subagent: ignoring agent ${filePath}: its frontmatter could not be parsed (${error instanceof Error ? error.message : String(error)})`)
183
+ console.warn(`pi-code-subagent: ignoring agent ${filePath}: its frontmatter could not be parsed (${errorMessage(error)})`)
184
184
  return null
185
185
  }
186
186
  const { frontmatter, body } = parsed
@@ -11,6 +11,7 @@ import { randomUUID } from 'node:crypto'
11
11
  import * as fs from 'node:fs'
12
12
  import * as os from 'node:os'
13
13
  import * as path from 'node:path'
14
+ import { errorMessage } from '../internal/values.js'
14
15
 
15
16
  export interface BackgroundRun {
16
17
  id: string
@@ -245,7 +246,7 @@ function withRebuiltPrompt(spawnSpec: BackgroundSpawn, agent: string): { args: s
245
246
  // prompt text, which would replace the agent persona with a temp path. The child then
246
247
  // runs as a plain assistant instead of the agent asked for, and nothing in its output
247
248
  // says so, hence the notice.
248
- console.warn(`pi-code-subagent: resuming ${agent} without its agent prompt: ${error instanceof Error ? error.message : String(error)}`)
249
+ console.warn(`pi-code-subagent: resuming ${agent} without its agent prompt: ${errorMessage(error)}`)
249
250
  return { args: spawnSpec.args.filter((_arg, i) => i !== flag && i !== flag + 1) }
250
251
  }
251
252
  }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * How a child agent is configured before it is spawned: its system prompt, its own
3
+ * memory store, the tools it resolves to, its CLI arguments and its hook environment.
4
+ *
5
+ * Separate from the runner so the shape of a child can be asserted without spawning
6
+ * one, and from the extension body so the factory keeps only schema and dispatch.
7
+ */
8
+
9
+ import * as fs from 'node:fs'
10
+ import * as os from 'node:os'
11
+ import * as path from 'node:path'
12
+
13
+ import type { AgentRunRequest } from '../internal/agent-run.js'
14
+ import { claudeConfigDir } from '../internal/config-dir.js'
15
+ import { repoRoot } from '../internal/project-root.js'
16
+ import { autoMemoryEnabled, capIndexForPrompt, INDEX_MAX_BYTES, INDEX_MAX_LINES, memorySettingsFiles, readMemorySettings } from '../memory.js'
17
+ import { type AgentConfig, type AgentMemoryScope, expandMcpToolPatterns, withPreloadedSkills } from './agents.js'
18
+ /** The system prompt for Claude's experimental `type: "agent"` hooks: the subagent
19
+ * inspects with read-only tools and returns the same JSON decision a command hook's
20
+ * stdout carries. A hook-supplied `systemPrompt` is appended after it. */
21
+ export const AGENT_HOOK_SYSTEM = [
22
+ 'You are a Claude Code agent hook verifying whether an action should proceed.',
23
+ 'Use the Read, Grep, and Glob tools to inspect files as needed before deciding.',
24
+ 'When done, respond with ONLY a JSON object and nothing else:',
25
+ '{"hookSpecificOutput":{"permissionDecision":"allow"|"deny"|"ask","permissionDecisionReason":"<short reason>"}}',
26
+ 'Use "allow" to let the action proceed, "deny" to block it, "ask" to require the user to confirm.',
27
+ ].join('\n')
28
+
29
+ /** A throwaway agent config for one agent-hook run: read-only inspection tools, the
30
+ * hook's model (a fast default when unset), and the decision-returning system prompt. */
31
+ /** The agent a context: fork skill runs as when it names none: full toolset, no
32
+ * extra system prompt (the child keeps pi's default), the skill content as the
33
+ * task. */
34
+ export function forkAgent(request: Pick<AgentRunRequest, 'model' | 'systemPrompt'>): AgentConfig {
35
+ return {
36
+ name: 'fork',
37
+ description: 'forked skill run',
38
+ systemPrompt: request.systemPrompt ?? '',
39
+ ...(request.model ? { model: request.model } : {}),
40
+ source: 'builtin',
41
+ filePath: '',
42
+ }
43
+ }
44
+
45
+ export function buildHookAgent(request: Pick<AgentRunRequest, 'model' | 'systemPrompt'>): AgentConfig {
46
+ return {
47
+ name: 'agent-hook',
48
+ description: 'Verifies a hook condition using read-only inspection tools.',
49
+ tools: ['read', 'grep', 'find'],
50
+ model: request.model,
51
+ systemPrompt: request.systemPrompt ? `${AGENT_HOOK_SYSTEM}\n\n${request.systemPrompt}` : AGENT_HOOK_SYSTEM,
52
+ source: 'builtin',
53
+ filePath: '',
54
+ }
55
+ }
56
+
57
+ /** The file-management tools a memory-enabled child needs for its store. */
58
+ const MEMORY_TOOLS = ['read', 'write', 'edit']
59
+
60
+ /** Where an agent's own persistent memory lives, per its `memory:` scope (Claude:
61
+ * user -> ~/.claude/agent-memory/<name>, project -> <root>/.claude/agent-memory/<name>,
62
+ * local -> <root>/.claude/agent-memory-local/<name>). The name comes from frontmatter
63
+ * a repository can control, so it is sanitized before becoming a path segment. */
64
+ export function agentMemoryDir(scope: AgentMemoryScope, name: string, cwd: string, home: string): string {
65
+ const sanitized = name.replace(/[^\w.-]+/g, '_')
66
+ // A name of only dots ('.', '..') survives the character filter but still traverses.
67
+ const segment = /^\.+$/.test(sanitized) ? '_' : sanitized
68
+ if (scope === 'user') return path.join(claudeConfigDir(home), 'agent-memory', segment)
69
+ const root = repoRoot(cwd) ?? cwd
70
+ return path.join(root, '.claude', scope === 'project' ? 'agent-memory' : 'agent-memory-local', segment)
71
+ }
72
+
73
+ /** The prompt section giving a memory-enabled child its own persistent store: the
74
+ * directory, read/write/curation instructions, and its MEMORY.md capped like the
75
+ * parent's index load (first 200 lines or 25KB, whichever comes first). */
76
+ export function agentMemorySection(dir: string, memoryMd: string): string {
77
+ const indexPath = path.join(dir, 'MEMORY.md')
78
+ const capped = capIndexForPrompt(memoryMd)
79
+ const current = capped.trim() ? `Current ${indexPath}:\n\n${capped}` : `${indexPath} does not exist yet; create it once you have something worth keeping.`
80
+ return [
81
+ '## Agent memory',
82
+ '',
83
+ `You have a persistent memory directory at ${dir} that survives across sessions.`,
84
+ 'Use the read, write, and edit tools to record durable insights, project patterns, and lessons learned there, and consult them when relevant.',
85
+ `Only the first ${INDEX_MAX_LINES} lines or ${INDEX_MAX_BYTES} bytes of ${indexPath} are loaded at startup, so keep it a concise, curated index and move details into separate files in the directory.`,
86
+ '',
87
+ current,
88
+ ].join('\n')
89
+ }
90
+
91
+ /** The memory section for one run, or undefined when the agent declares no memory,
92
+ * auto memory is off, or a repo-scoped store is not approved. Subagent memory is part
93
+ * of auto memory, so the same settings chain and env kill switch gate it. */
94
+ export function agentMemoryPromptSection(agent: Pick<AgentConfig, 'memory' | 'name'>, cwd: string, projectApproved: boolean): string | undefined {
95
+ if (!agent.memory) return undefined
96
+ // project and local stores live under the repository's .claude, a repo-controlled
97
+ // path; like rules, they are only read once the project is approved.
98
+ if (agent.memory !== 'user' && !projectApproved) return undefined
99
+ const settings = readMemorySettings(memorySettingsFiles(cwd, os.homedir(), projectApproved))
100
+ if (!autoMemoryEnabled(settings.autoMemoryEnabled, process.env)) return undefined
101
+ const dir = agentMemoryDir(agent.memory, agent.name, cwd, os.homedir())
102
+ let memoryMd = ''
103
+ try {
104
+ memoryMd = fs.readFileSync(path.join(dir, 'MEMORY.md'), 'utf-8')
105
+ } catch {
106
+ // no store yet: the section still tells the child where to create one
107
+ }
108
+ return agentMemorySection(dir, memoryMd)
109
+ }
110
+
111
+ /** Widen a restricted agent's allowlist so it can manage its memory files. An
112
+ * unrestricted agent (no allowlist) already has every tool. */
113
+ export function withMemoryTools(tools: string[] | undefined): string[] | undefined {
114
+ if (!tools || tools.length === 0) return tools
115
+ return [...tools, ...MEMORY_TOOLS.filter((tool) => !tools.includes(tool))]
116
+ }
117
+
118
+ /** The child's --append-system-prompt body: the skills-preloaded prompt plus the
119
+ * agent memory section, without a stray separator when either part is empty. */
120
+ export function childPromptBody(agent: AgentConfig, skillRoots: string[], memorySection: string | undefined): string {
121
+ const prompt = withPreloadedSkills(agent.systemPrompt, agent.skills, skillRoots)
122
+ if (!memorySection) return prompt
123
+ return [prompt, memorySection].filter((part) => part.trim()).join('\n\n')
124
+ }
125
+
126
+ /** The parent's MCP tool aliases, published by the mcp extension on the shared bus;
127
+ * the module-level seam matches setMcpToolCaller's. Children read the same MCP config
128
+ * files, so the parent's roster is the translation table for server-level patterns. */
129
+ let knownMcpAliases: ReadonlyArray<{ pi: string; claude: string }> = []
130
+
131
+ export function setKnownMcpAliases(aliases: ReadonlyArray<{ pi: string; claude: string }>): void {
132
+ knownMcpAliases = aliases
133
+ }
134
+
135
+ /** pi's built-in ToolName union (core/tools/index.d.ts; the package's export map
136
+ * does not expose allToolNames, so this mirrors it) plus the tools pi-code's own
137
+ * extensions register in a child. Claude's capitalized spellings fold onto these. */
138
+ const CHILD_TOOL_NAMES = new Set(['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls', 'web_fetch', 'web_search', 'list_mcp_resources', 'read_mcp_resource', 'todo', 'question', 'memory', 'slash_command', 'plan_mode_complete'])
139
+
140
+ /** Claude: when no entry in a `tools` list resolves to a tool, the subagent fails
141
+ * to launch with an error naming the entries, instead of running tool-less. */
142
+ export function unresolvedToolsError(agent: AgentConfig): string | undefined {
143
+ if (!agent.tools || agent.tools.length === 0) return undefined
144
+ const fold = (name: string): string => name.toLowerCase().replaceAll('-', '_')
145
+ const known = new Set(knownMcpAliases.map((alias) => fold(alias.pi)))
146
+ const resolves = expandMcpToolPatterns(agent.tools, knownMcpAliases).some((entry) => CHILD_TOOL_NAMES.has(fold(entry)) || known.has(fold(entry)))
147
+ if (resolves) return undefined
148
+ return `Agent "${agent.name}" would launch with zero tools: no entry in [${agent.tools.join(', ')}] resolves to a tool.`
149
+ }
150
+
151
+ /** CLI args shared by foreground and background children, from the agent's config. */
152
+ export function agentInvocationArgs(agent: AgentConfig, aliasModel?: string): string[] {
153
+ const args: string[] = ['--mode', 'json', '-p', '--no-session']
154
+ // A concrete model wins; otherwise a Claude tier alias resolved against the models
155
+ // this user can actually run; then CLAUDE_CODE_SUBAGENT_MODEL, per Claude's model
156
+ // order (invocation model, frontmatter model, this variable, the session model).
157
+ // pi reads a thinking level from the model pattern's :suffix when a model is
158
+ // pinned, and from --thinking otherwise.
159
+ // Claude exempts the two built-ins from the environment variable: "Setting
160
+ // CLAUDE_CODE_SUBAGENT_MODEL by itself doesn't change the model the built-in Explore and
161
+ // Plan subagents run on." A model they name themselves, or one the invocation names,
162
+ // still applies.
163
+ const exemptFromEnvModel = agent.source === 'builtin' && (agent.name === 'Explore' || agent.name === 'Plan')
164
+ const model = agent.model ?? aliasModel ?? (exemptFromEnvModel ? undefined : process.env.CLAUDE_CODE_SUBAGENT_MODEL)
165
+ if (model) args.push('--model', agent.effort ? `${model}:${agent.effort}` : model)
166
+ else if (agent.effort) args.push('--thinking', agent.effort)
167
+ // Claude's mcp__<server> / mcp__* patterns expand against the parent's MCP roster;
168
+ // without this a server-level deny removed nothing (fail open) and a server-level
169
+ // grant granted nothing.
170
+ if (agent.tools && agent.tools.length > 0) args.push('--tools', expandMcpToolPatterns(agent.tools, knownMcpAliases).join(','))
171
+ if (agent.disallowedTools && agent.disallowedTools.length > 0) args.push('--exclude-tools', expandMcpToolPatterns(agent.disallowedTools, knownMcpAliases).join(','))
172
+ // Claude: "Explore and Plan are the only subagents that omit CLAUDE.md" (and no
173
+ // field or setting changes which agents skip them), to keep research fast.
174
+ if (agent.source === 'builtin' && (agent.name === 'Explore' || agent.name === 'Plan')) args.push('--no-context-files')
175
+ return args
176
+ }
177
+
178
+ /** Claude's agent-frontmatter hooks ride to the child as env; the child's hooks
179
+ * extension merges them for the run only (they die with the process, matching
180
+ * "only while that subagent is running"). Stop converts to SubagentStop, the
181
+ * event the child fires when it completes, as Claude documents. */
182
+ export function agentHooksEnv(agent: AgentConfig, agentId: string): Record<string, string> {
183
+ if (!agent.hooks) return {}
184
+ const hooks: Record<string, unknown> = { ...agent.hooks }
185
+ const stop = hooks.Stop
186
+ delete hooks.Stop
187
+ if (Array.isArray(stop)) hooks.SubagentStop = [...(Array.isArray(hooks.SubagentStop) ? (hooks.SubagentStop as unknown[]) : []), ...stop]
188
+ return { PI_CODE_AGENT_HOOKS: JSON.stringify({ agent: agent.name, id: agentId, hooks }) }
189
+ }
190
+
191
+ /** The task argument with any SubagentStart hook context ahead of it, per Claude:
192
+ * "added to the subagent's context at the start of its conversation, before its
193
+ * first prompt". */
194
+ export function taskWithStartContext(task: string, contexts: string[]): string {
195
+ const context = contexts.filter(Boolean).join('\n')
196
+ return context ? `${context}\n\nTask: ${task}` : `Task: ${task}`
197
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Bounded fan-out for parallel subagent runs: the caps the tool advertises and the
3
+ * worker pool that honours them.
4
+ */
5
+
6
+ export const MAX_PARALLEL_TASKS = 8
7
+ export const MAX_CONCURRENCY = 4
8
+
9
+ export async function mapWithConcurrencyLimit<TIn, TOut>(items: TIn[], concurrency: number, fn: (item: TIn, index: number) => Promise<TOut>): Promise<TOut[]> {
10
+ if (items.length === 0) return []
11
+ const limit = Math.max(1, Math.min(concurrency, items.length))
12
+ const results: TOut[] = new Array(items.length)
13
+ let nextIndex = 0
14
+ const workers = new Array(limit).fill(null).map(async () => {
15
+ while (true) {
16
+ const current = nextIndex++
17
+ if (current >= items.length) return
18
+ results[current] = await fn(items[current], current)
19
+ }
20
+ })
21
+ await Promise.all(workers)
22
+ return results
23
+ }