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.
- package/extensions/commands.ts +6 -12
- package/extensions/context-imports.ts +2 -10
- package/extensions/env-settings.ts +1 -5
- package/extensions/git-checkpoint.ts +4 -12
- package/extensions/goal.ts +2 -2
- package/extensions/hooks/config.ts +6 -21
- package/extensions/hooks/decisions.ts +3 -2
- package/extensions/hooks/index.ts +4 -16
- package/extensions/hooks/matcher.ts +2 -1
- package/extensions/hooks/runners.ts +4 -3
- package/extensions/internal/command-file.ts +7 -241
- package/extensions/internal/command-spans.ts +246 -0
- package/extensions/internal/managed-settings.ts +3 -5
- package/extensions/internal/plugins.ts +2 -2
- package/extensions/internal/settings-chain.ts +19 -0
- package/extensions/internal/values.ts +38 -0
- package/extensions/mcp/index.ts +6 -5
- package/extensions/mcp/listing.ts +2 -1
- package/extensions/mcp/oauth-flow.ts +2 -1
- package/extensions/mcp/policy.ts +9 -2
- package/extensions/memory.ts +10 -15
- package/extensions/output-styles.ts +4 -17
- package/extensions/plan-mode/index.ts +9 -9
- package/extensions/plan-mode/utils.ts +31 -0
- package/extensions/session-title.ts +2 -12
- package/extensions/skills.ts +6 -18
- package/extensions/status-line.ts +2 -8
- package/extensions/subagent/README.md +12 -2
- package/extensions/subagent/agents.ts +2 -2
- package/extensions/subagent/background.ts +2 -1
- package/extensions/subagent/child.ts +197 -0
- package/extensions/subagent/concurrency.ts +23 -0
- package/extensions/subagent/index.ts +36 -1426
- package/extensions/subagent/modes.ts +405 -0
- package/extensions/subagent/params.ts +56 -0
- package/extensions/subagent/registry-text.ts +105 -0
- package/extensions/subagent/render-result.ts +306 -0
- package/extensions/subagent/run.ts +375 -0
- package/extensions/subagent/types.ts +41 -0
- package/extensions/subagent/worktree.ts +2 -1
- 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
|
|
38
|
+
return contentText(entry.message.content, ' ').trim()
|
|
49
39
|
}
|
|
50
40
|
}
|
|
51
41
|
return ''
|
package/extensions/skills.ts
CHANGED
|
@@ -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
|
|
140
|
-
|
|
141
|
-
|
|
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: ${
|
|
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
|
|
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
|
|
22
|
-
├── agents.ts # Agent discovery
|
|
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 (${
|
|
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: ${
|
|
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
|
+
}
|