pi-code 1.0.32 → 1.0.34
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/context-imports.ts +24 -21
- package/extensions/hooks/config.ts +19 -0
- package/extensions/hooks/index.ts +47 -8
- package/extensions/internal/builtin-styles/concise.md +13 -0
- package/extensions/internal/path-rules.ts +5 -0
- package/extensions/internal/subagent-hooks.ts +26 -0
- package/extensions/memory.ts +43 -11
- package/extensions/output-styles.ts +25 -4
- package/extensions/subagent/agents.ts +33 -9
- package/extensions/subagent/background.ts +7 -3
- package/extensions/subagent/index.ts +81 -31
- package/package.json +1 -1
|
@@ -81,6 +81,21 @@ import { fenceMarker, stripBlockComments } from './internal/strip-comments.js'
|
|
|
81
81
|
|
|
82
82
|
/** Claude documents "a maximum depth of four hops" for recursive imports. */
|
|
83
83
|
const MAX_IMPORT_DEPTH = 4
|
|
84
|
+
|
|
85
|
+
/** Claude loads a context file (CLAUDE.md and friends) of up to 4 MiB in full and
|
|
86
|
+
* skips a larger one. */
|
|
87
|
+
const CONTEXT_FILE_MAX_BYTES = 4 * 1024 * 1024
|
|
88
|
+
|
|
89
|
+
/** One context file's content, or undefined when it is absent, unreadable, or over
|
|
90
|
+
* the 4 MiB limit Claude documents. */
|
|
91
|
+
function readContextFile(filePath: string): string | undefined {
|
|
92
|
+
try {
|
|
93
|
+
if (fs.statSync(filePath).size > CONTEXT_FILE_MAX_BYTES) return undefined
|
|
94
|
+
return fs.readFileSync(filePath, 'utf-8')
|
|
95
|
+
} catch {
|
|
96
|
+
return undefined
|
|
97
|
+
}
|
|
98
|
+
}
|
|
84
99
|
export const MAX_IMPORT_FILES = 50
|
|
85
100
|
export const MAX_IMPORT_BYTES = 256 * 1024
|
|
86
101
|
|
|
@@ -292,11 +307,8 @@ export function additionalDirContextFiles(dir: string, includeLocal: boolean): A
|
|
|
292
307
|
if (includeLocal) candidates.push(path.join(dir, 'CLAUDE.local.md'))
|
|
293
308
|
const files: Array<{ path: string; content: string }> = []
|
|
294
309
|
for (const candidate of candidates) {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
} catch {
|
|
298
|
-
// absent or unreadable: treat as not there
|
|
299
|
-
}
|
|
310
|
+
const content = readContextFile(candidate)
|
|
311
|
+
if (content !== undefined) files.push({ path: candidate, content })
|
|
300
312
|
}
|
|
301
313
|
return files
|
|
302
314
|
}
|
|
@@ -717,12 +729,9 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
717
729
|
|
|
718
730
|
// ~/.claude/CLAUDE.md, Claude's user-scope memory. The user's own file, so no
|
|
719
731
|
// project approval is required; a missing file simply leaves it unset.
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
} catch {
|
|
724
|
-
// no user CLAUDE.md
|
|
725
|
-
}
|
|
732
|
+
const userClaudeMd = path.join(claudeConfigDir(os.homedir()), 'CLAUDE.md')
|
|
733
|
+
const userContent = readContextFile(userClaudeMd)
|
|
734
|
+
if (userContent !== undefined) userContext = { path: userClaudeMd, content: userContent }
|
|
726
735
|
|
|
727
736
|
// CLAUDE.local.md is Claude Code's personal sidecar of CLAUDE.md; pi's own loader
|
|
728
737
|
// skips it. A cloned repo can ship one, so it is gated like other project config.
|
|
@@ -735,18 +744,12 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
|
|
|
735
744
|
const dotClaudeMd = findNearestFile(ctx.cwd, path.join('.claude', 'CLAUDE.md'))
|
|
736
745
|
if ((candidates.length > 0 || dotClaudeMd !== null) && (await isProjectApproved(ctx))) {
|
|
737
746
|
for (const candidate of candidates) {
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
} catch {
|
|
741
|
-
// unreadable: treat as absent
|
|
742
|
-
}
|
|
747
|
+
const content = readContextFile(candidate)
|
|
748
|
+
if (content !== undefined) localContexts.push({ path: candidate, content })
|
|
743
749
|
}
|
|
744
750
|
if (dotClaudeMd !== null) {
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
} catch {
|
|
748
|
-
// unreadable: treat as absent
|
|
749
|
-
}
|
|
751
|
+
const content = readContextFile(dotClaudeMd)
|
|
752
|
+
if (content !== undefined) projectDotClaude = { path: dotClaudeMd, content }
|
|
750
753
|
}
|
|
751
754
|
}
|
|
752
755
|
// Read after the local-context flow so an approval it just recorded is honored.
|
|
@@ -115,6 +115,25 @@ export function mergeSkillHooks(config: HooksConfig, skillName: string, hooks: u
|
|
|
115
115
|
mergeHooksJson(config, JSON.stringify({ hooks }), `${skillName} (skill)`, sources, `skill:${skillName}`)
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
/** Claude's agent-frontmatter hooks, inside the subagent child: the parent passes
|
|
119
|
+
* them via PI_CODE_AGENT_HOOKS (Stop already converted to SubagentStop), and they
|
|
120
|
+
* run only while this child runs because they die with the process. Returns the
|
|
121
|
+
* agent identity for the child's SubagentStop firing, or undefined outside a
|
|
122
|
+
* subagent or with no hooks passed. */
|
|
123
|
+
export function mergeAgentEnvHooks(config: HooksConfig, sources?: Map<HookMatcher, string>): { agent: string; id?: string } | undefined {
|
|
124
|
+
if (process.env.PI_CODE_SUBAGENT !== '1') return undefined
|
|
125
|
+
const raw = process.env.PI_CODE_AGENT_HOOKS
|
|
126
|
+
if (!raw) return undefined
|
|
127
|
+
try {
|
|
128
|
+
const parsed: unknown = JSON.parse(raw)
|
|
129
|
+
if (!isRecord(parsed) || typeof parsed.agent !== 'string' || !isRecord(parsed.hooks)) return undefined
|
|
130
|
+
mergeHooksJson(config, JSON.stringify({ hooks: parsed.hooks }), `${parsed.agent} (agent)`, sources, `agent:${parsed.agent}`)
|
|
131
|
+
return { agent: parsed.agent, ...(typeof parsed.id === 'string' ? { id: parsed.id } : {}) }
|
|
132
|
+
} catch {
|
|
133
|
+
return undefined
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
118
137
|
/** Claude's `allowedHttpHookUrls` setting: URL patterns http hooks may target, with
|
|
119
138
|
* `*` as a wildcard. Per Claude's documentation: undefined (no source sets the key)
|
|
120
139
|
* means no restrictions, an empty array blocks every http hook, and arrays merge
|
|
@@ -64,9 +64,13 @@
|
|
|
64
64
|
* (asyncRewake keeps its own), and hooks still running at session end are killed,
|
|
65
65
|
* as Claude does at teardown.
|
|
66
66
|
*
|
|
67
|
-
* SubagentStart
|
|
68
|
-
*
|
|
69
|
-
*
|
|
67
|
+
* SubagentStart runs through the pre-spawn seam (internal/subagent-hooks) so its
|
|
68
|
+
* additionalContext reaches the child before its first prompt; it cannot block a
|
|
69
|
+
* spawn, as Claude documents. SubagentStop rides the subagent extension's bus stop
|
|
70
|
+
* event (notify-style: the child has already exited, so exit-2 block semantics
|
|
71
|
+
* cannot be honored) and carries last_assistant_message. Inside a subagent child,
|
|
72
|
+
* agent-frontmatter hooks arrive via PI_CODE_AGENT_HOOKS (Stop pre-converted to
|
|
73
|
+
* SubagentStop, fired at the child's own agent end) and die with the process.
|
|
70
74
|
*
|
|
71
75
|
* Hook commands run via `sh -c` with the event JSON on stdin. A PreToolUse
|
|
72
76
|
* hook blocks the tool by exiting 2 (stderr becomes the reason) or by printing
|
|
@@ -99,8 +103,9 @@ import { isProjectApproved } from '../internal/project-approval.js'
|
|
|
99
103
|
import { repoRoot } from '../internal/project-root.js'
|
|
100
104
|
import { isSkillHooksEvent, SKILL_HOOKS_CHANNEL } from '../internal/skill-hooks.js'
|
|
101
105
|
import { isSubagentPhaseEvent, SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
|
|
106
|
+
import { setSubagentStartHookRunner } from '../internal/subagent-hooks.js'
|
|
102
107
|
import { claudeToolInput, claudeToolName, claudeToolResponse, piToolOutput } from './claude-tools.js'
|
|
103
|
-
import { formatHooksSummary, type HookCommand, type HookMatcher, type HooksConfig, hookFiles, isBackgroundHook, loadHooks, loadManagedHooks, loadPluginHooks, mergeSkillHooks, readAllowedHttpHookUrls, readDisableAllHooks, readSettingsDisableAllHooks } from './config.js'
|
|
108
|
+
import { formatHooksSummary, type HookCommand, type HookMatcher, type HooksConfig, hookFiles, isBackgroundHook, loadHooks, loadManagedHooks, loadPluginHooks, mergeAgentEnvHooks, mergeSkillHooks, readAllowedHttpHookUrls, readDisableAllHooks, readSettingsDisableAllHooks } from './config.js'
|
|
104
109
|
import { blockedToolCall, jsonBlockVerdict, postToolFeedback, promptContext, runPreToolUse, runUserPromptSubmit, surfaceSystemMessages, tryParseJson } from './decisions.js'
|
|
105
110
|
import { allCommands, matchingCommands, passesIfFilter } from './matcher.js'
|
|
106
111
|
import { type HookRunner, type HookRunResult, runAgentHook, runHookCommand, runHttpHook, runMcpToolHook, runPromptHook, sessionEndTimeoutMs, timeoutMs } from './runners.js'
|
|
@@ -199,6 +204,9 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
199
204
|
idlePromptTimer = undefined
|
|
200
205
|
}
|
|
201
206
|
let sessionCtx: ExtensionContext | undefined
|
|
207
|
+
/** Set inside a subagent child that carries agent-frontmatter hooks: the child's
|
|
208
|
+
* own agent end fires their SubagentStop, per Claude's Stop conversion. */
|
|
209
|
+
let agentIdentity: { agent: string; id?: string } | undefined
|
|
202
210
|
/** Claude's disableAllHooks escape hatch was set somewhere in the honored chain. */
|
|
203
211
|
let hooksDisabled = false
|
|
204
212
|
/** Which settings file each resolved entry came from, for the /hooks viewer. */
|
|
@@ -337,14 +345,16 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
337
345
|
// Subagent lifecycle arrives over the bus without a pi context; the session context
|
|
338
346
|
// captured at session_start supplies the common payload fields.
|
|
339
347
|
pi.events.on(SUBAGENT_CHANNEL, async (data) => {
|
|
340
|
-
|
|
348
|
+
// SubagentStart runs through the pre-spawn seam below (so its context can
|
|
349
|
+
// reach the child before its first prompt); the bus start event would
|
|
350
|
+
// double-run it, so only the stop phase is handled here.
|
|
351
|
+
if (!isSubagentPhaseEvent(data) || data.phase !== 'stop' || !sessionCtx) return
|
|
341
352
|
const ctx = sessionCtx
|
|
342
|
-
const eventName = data.phase === 'start' ? 'SubagentStart' : 'SubagentStop'
|
|
343
353
|
// Claude's SubagentStop carries the subagent's final text; agent_transcript_path
|
|
344
354
|
// stays absent (a --no-session child writes no transcript, see docs/hooks.md).
|
|
345
|
-
const payload = { hook_event_name:
|
|
355
|
+
const payload = { hook_event_name: 'SubagentStop', agent_type: data.agentType, agent_id: data.agentId, ...(data.lastAssistantMessage !== undefined ? { last_assistant_message: data.lastAssistantMessage } : {}) }
|
|
346
356
|
try {
|
|
347
|
-
const results = await runNotifyHooks(matchingCommands(config
|
|
357
|
+
const results = await runNotifyHooks(matchingCommands(config.SubagentStop, data.agentType), payload, boundRunner(ctx))
|
|
348
358
|
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
349
359
|
} catch {
|
|
350
360
|
// The bus outlives the session: an event landing between /new disposing this
|
|
@@ -353,6 +363,19 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
353
363
|
}
|
|
354
364
|
})
|
|
355
365
|
|
|
366
|
+
// Claude's SubagentStart hooks inject additionalContext into the subagent before
|
|
367
|
+
// its first prompt, so they must run before the spawn: the subagent extension
|
|
368
|
+
// calls this seam pre-spawn and prepends the returned context to the child's task.
|
|
369
|
+
setSubagentStartHookRunner(async (agentType, agentId) => {
|
|
370
|
+
if (!sessionCtx) return []
|
|
371
|
+
const ctx = sessionCtx
|
|
372
|
+
const commands = matchingCommands(config.SubagentStart, agentType)
|
|
373
|
+
if (commands.length === 0) return []
|
|
374
|
+
const results = await runNotifyHooks(commands, { hook_event_name: 'SubagentStart', agent_type: agentType, agent_id: agentId }, boundRunner(ctx))
|
|
375
|
+
surfaceSystemMessages(results, (message) => ctx.ui.notify(message, 'warning'))
|
|
376
|
+
return results.map((result) => promptContext(result.stdout)).filter(Boolean)
|
|
377
|
+
})
|
|
378
|
+
|
|
356
379
|
pi.on('session_start', async (event, ctx) => {
|
|
357
380
|
sessionCtx = ctx
|
|
358
381
|
// One extension instance serves every session. A mid-turn /new fires session_start on
|
|
@@ -389,6 +412,10 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
389
412
|
// Plugins are user-installed and enabled by user settings (see installedPlugins),
|
|
390
413
|
// so a checked-out repo cannot toggle which code-bearing plugin hooks run.
|
|
391
414
|
loadPluginHooks(config, installedPlugins(os.homedir()), hookSources)
|
|
415
|
+
// Inside a subagent child, the parent passes the agent's frontmatter hooks via
|
|
416
|
+
// env (Stop already converted to SubagentStop, per Claude); they run only for
|
|
417
|
+
// this child process.
|
|
418
|
+
agentIdentity = mergeAgentEnvHooks(config, hookSources)
|
|
392
419
|
// "reload" re-fires in-process with the same conversation and would double-run hooks;
|
|
393
420
|
// a fork is a genuine session begin, which Claude reports as source "fork".
|
|
394
421
|
if (event.reason === 'reload') return
|
|
@@ -560,6 +587,18 @@ export default function hooksExtension(pi: ExtensionAPI) {
|
|
|
560
587
|
idlePromptTimer.unref?.()
|
|
561
588
|
}
|
|
562
589
|
|
|
590
|
+
// In a subagent child, the agent-frontmatter Stop hooks were converted to
|
|
591
|
+
// SubagentStop and fire here, at the child's own end, notify-style; before the
|
|
592
|
+
// Stop early-returns, which do not apply to them.
|
|
593
|
+
if (agentIdentity) {
|
|
594
|
+
const subStop = matchingCommands(config.SubagentStop, agentIdentity.agent)
|
|
595
|
+
if (subStop.length > 0) {
|
|
596
|
+
const subText = lastAssistantText((event as { messages?: Array<{ role: string; content: unknown }> }).messages ?? [])
|
|
597
|
+
const subPayload = { hook_event_name: 'SubagentStop', agent_type: agentIdentity.agent, ...(agentIdentity.id ? { agent_id: agentIdentity.id } : {}), stop_hook_active: false, ...(subText ? { last_assistant_message: subText } : {}) }
|
|
598
|
+
await runNotifyHooks(subStop, subPayload, boundRunner(ctx)).catch(() => {})
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
563
602
|
// Stop has no matcher support (a stray matcher is ignored, as Claude documents)
|
|
564
603
|
// and an `if`-carrying hook never runs on a non-tool event.
|
|
565
604
|
const commands = allCommands(config.Stop).filter((command) => passesIfFilter(command, undefined))
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Concise
|
|
3
|
+
description: Leads with the result and keeps responses short by default, without cutting the engineering work
|
|
4
|
+
keep-coding-instructions: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
Lead with the result. Skip preamble, narration, and restating the request; answer first, support after.
|
|
8
|
+
|
|
9
|
+
Keep responses short by default while doing the engineering work as thoroughly as ever: brevity applies to the writing, never to the analysis, testing, or care taken.
|
|
10
|
+
|
|
11
|
+
When the user asks for an explanation or more detail, answer in full.
|
|
12
|
+
|
|
13
|
+
Always keep the complete content of error reports, security warnings, and confirmations for destructive actions; never shorten those.
|
|
@@ -198,9 +198,14 @@ export function globCompileStats(): { compiled: number; evaluated: number } {
|
|
|
198
198
|
/** Rule `paths:` globs compiled once for repeated matching, with claude-rules'
|
|
199
199
|
* pathMatchesGlobs semantics: `./` and leading `/` anchors are stripped, a trailing
|
|
200
200
|
* slash scopes to the directory's contents, and blank entries drop out. */
|
|
201
|
+
/** Claude's shared list budget: rule patterns past ~1000 compiled entries are
|
|
202
|
+
* ignored rather than compiled without bound. */
|
|
203
|
+
const LIST_PATTERN_BUDGET = 1000
|
|
204
|
+
|
|
201
205
|
export function compileGlobs(globs: string[]): CompiledGlob[] {
|
|
202
206
|
const compiled: CompiledGlob[] = []
|
|
203
207
|
for (const raw of globs) {
|
|
208
|
+
if (compiled.length >= LIST_PATTERN_BUDGET) break
|
|
204
209
|
let glob = raw.trim()
|
|
205
210
|
if (!glob) continue
|
|
206
211
|
if (glob.startsWith('./')) glob = glob.slice(2)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-spawn seam for Claude's SubagentStart hooks. The hooks extension registers
|
|
3
|
+
* the runner; the subagent extension calls it before spawning a child, so the
|
|
4
|
+
* hooks' additionalContext can be injected before the child's first prompt,
|
|
5
|
+
* which the after-the-fact bus event structurally cannot do. Same module-seam
|
|
6
|
+
* pattern as mcp-call.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type SubagentStartHookRunner = (agentType: string, agentId: string) => Promise<string[]>
|
|
10
|
+
|
|
11
|
+
let runner: SubagentStartHookRunner | undefined
|
|
12
|
+
|
|
13
|
+
export function setSubagentStartHookRunner(fn: SubagentStartHookRunner | undefined): void {
|
|
14
|
+
runner = fn
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Context strings SubagentStart hooks contribute; empty when no runner is
|
|
18
|
+
* registered or the runner fails (hooks must never block a spawn). */
|
|
19
|
+
export async function runSubagentStartHooks(agentType: string, agentId: string): Promise<string[]> {
|
|
20
|
+
if (!runner) return []
|
|
21
|
+
try {
|
|
22
|
+
return await runner(agentType, agentId)
|
|
23
|
+
} catch {
|
|
24
|
+
return []
|
|
25
|
+
}
|
|
26
|
+
}
|
package/extensions/memory.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { StringEnum } from '@earendil-works/pi-ai'
|
|
|
15
15
|
import { type ExtensionAPI, withFileMutationQueue } from '@earendil-works/pi-coding-agent'
|
|
16
16
|
import { Type } from 'typebox'
|
|
17
17
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
18
|
+
import { readManagedSettings } from './internal/managed-settings.js'
|
|
18
19
|
import { capForContext } from './internal/output-guard.js'
|
|
19
20
|
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
20
21
|
import { repoRoot } from './internal/project-root.js'
|
|
@@ -141,6 +142,18 @@ export function indexWouldOverflow(index: string, name: string, description: str
|
|
|
141
142
|
return next.split('\n').length > INDEX_MAX_LINES || Buffer.byteLength(next, 'utf-8') > INDEX_MAX_BYTES
|
|
142
143
|
}
|
|
143
144
|
|
|
145
|
+
/** Where the index stands against the read limits, measured on the loaded content
|
|
146
|
+
* (frontmatter and comments stripped): 'over' past either bound, 'near' within
|
|
147
|
+
* 10% of one, else 'ok'. Claude reminds near a limit and errors over it. */
|
|
148
|
+
export function indexReadState(index: string): 'ok' | 'near' | 'over' {
|
|
149
|
+
const loaded = stripNonLoaded(index)
|
|
150
|
+
const lines = loaded.split('\n').length
|
|
151
|
+
const bytes = Buffer.byteLength(loaded, 'utf-8')
|
|
152
|
+
if (lines > INDEX_MAX_LINES || bytes > INDEX_MAX_BYTES) return 'over'
|
|
153
|
+
if (lines > INDEX_MAX_LINES * 0.9 || bytes > INDEX_MAX_BYTES * 0.9) return 'near'
|
|
154
|
+
return 'ok'
|
|
155
|
+
}
|
|
156
|
+
|
|
144
157
|
type MemoryToolResult = { content: Array<{ type: 'text'; text: string }>; details: Record<string, never> }
|
|
145
158
|
|
|
146
159
|
/** Write a memory and its index line, or say why it cannot be written. The whole
|
|
@@ -156,18 +169,29 @@ async function saveMemory(dir: string, indexPath: string, name: string | undefin
|
|
|
156
169
|
}
|
|
157
170
|
return withFileMutationQueue(indexPath, async (): Promise<MemoryToolResult> => {
|
|
158
171
|
const index = readIndex(dir)
|
|
159
|
-
|
|
160
|
-
//
|
|
161
|
-
|
|
172
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
173
|
+
// A memory with frontmatter records its write time; one without is left as-is.
|
|
174
|
+
fs.writeFileSync(path.join(dir, `${name}.md`), stampModified(content, now))
|
|
175
|
+
const nextIndex = upsertIndexLine(index, name, description)
|
|
176
|
+
writeIndex(indexPath, nextIndex)
|
|
177
|
+
// Claude measures the index after the write: over a read limit the write still
|
|
178
|
+
// succeeds, but an error tells Claude to rewrite the index (everything past
|
|
179
|
+
// the limit is dropped on the next load); near a limit, a reminder to shorten.
|
|
180
|
+
const state = indexReadState(nextIndex)
|
|
181
|
+
if (state === 'over') {
|
|
162
182
|
return {
|
|
163
|
-
content: [
|
|
183
|
+
content: [
|
|
184
|
+
{
|
|
185
|
+
type: 'text',
|
|
186
|
+
text: `Saved memory ${name}, but the memory index is over its read limit (${INDEX_MAX_LINES} lines / ${INDEX_MAX_BYTES} bytes): rewrite MEMORY.md now. Keep one line per entry, move detail into topic files, and merge or drop stale entries; everything past the limit is dropped on the next load.`,
|
|
187
|
+
},
|
|
188
|
+
],
|
|
164
189
|
details: {},
|
|
165
190
|
}
|
|
166
191
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
writeIndex(indexPath, upsertIndexLine(index, name, description))
|
|
192
|
+
if (state === 'near') {
|
|
193
|
+
return { content: [{ type: 'text', text: `Saved memory ${name}. The memory index is near its read limit; shorten it: keep one line per entry, move detail into topic files, and merge or drop stale entries.` }], details: {} }
|
|
194
|
+
}
|
|
171
195
|
return { content: [{ type: 'text', text: `Saved memory ${name}.` }], details: {} }
|
|
172
196
|
})
|
|
173
197
|
}
|
|
@@ -294,8 +318,9 @@ export function memorySettingsFiles(cwd: string, home: string, approved: boolean
|
|
|
294
318
|
return claudeSettingsChain(cwd, home, approved)
|
|
295
319
|
}
|
|
296
320
|
|
|
297
|
-
/** Merge the two memory settings across the chain, later files winning per key
|
|
298
|
-
|
|
321
|
+
/** Merge the two memory settings across the chain, later files winning per key;
|
|
322
|
+
* managed policy settings win over every file, per Claude's settings precedence. */
|
|
323
|
+
export function readMemorySettings(files: string[], managed: Record<string, unknown> = readManagedSettings()): { autoMemoryEnabled?: unknown; autoMemoryDirectory?: unknown } {
|
|
299
324
|
const merged: { autoMemoryEnabled?: unknown; autoMemoryDirectory?: unknown } = {}
|
|
300
325
|
for (const file of files) {
|
|
301
326
|
try {
|
|
@@ -307,6 +332,8 @@ export function readMemorySettings(files: string[]): { autoMemoryEnabled?: unkno
|
|
|
307
332
|
// missing or invalid settings file: skip
|
|
308
333
|
}
|
|
309
334
|
}
|
|
335
|
+
if ('autoMemoryEnabled' in managed) merged.autoMemoryEnabled = managed.autoMemoryEnabled
|
|
336
|
+
if ('autoMemoryDirectory' in managed) merged.autoMemoryDirectory = managed.autoMemoryDirectory
|
|
310
337
|
return merged
|
|
311
338
|
}
|
|
312
339
|
|
|
@@ -405,7 +432,8 @@ export default function memoryExtension(pi: ExtensionAPI) {
|
|
|
405
432
|
pi.registerTool({
|
|
406
433
|
name: 'memory',
|
|
407
434
|
label: 'Memory',
|
|
408
|
-
description:
|
|
435
|
+
description:
|
|
436
|
+
'Persistent memory across sessions. Save durable facts, user preferences, corrections, and project decisions that are not derivable from the code. Give each saved memory `type` frontmatter from the documented vocabulary: user (who the user is), feedback (guidance on how to work), project (ongoing work and constraints), or reference (pointers to external resources). Actions: save (name + description + content), read (name), delete (name), list.',
|
|
409
437
|
parameters: MemoryParams,
|
|
410
438
|
async execute(_id, params) {
|
|
411
439
|
if (inSubagent()) {
|
|
@@ -492,6 +520,10 @@ export default function memoryExtension(pi: ExtensionAPI) {
|
|
|
492
520
|
` Index: ${path.join(store, INDEX_FILE)}`,
|
|
493
521
|
` User memory (CLAUDE.md): ${path.join(home, '.claude', 'CLAUDE.md')}`,
|
|
494
522
|
` Project memory (CLAUDE.md): ${path.join(ctx.cwd, 'CLAUDE.md')}`,
|
|
523
|
+
// Claude's /memory lists every documented location, including files that
|
|
524
|
+
// do not exist yet.
|
|
525
|
+
` Project memory (CLAUDE.local.md): ${path.join(ctx.cwd, 'CLAUDE.local.md')}`,
|
|
526
|
+
` Project memory (alternate): ${path.join(ctx.cwd, '.claude', 'CLAUDE.md')}`,
|
|
495
527
|
'Toggle with /memory on or /memory off.',
|
|
496
528
|
]
|
|
497
529
|
ctx.ui.notify(lines.join('\n'), 'info')
|
|
@@ -25,6 +25,7 @@ import * as path from 'node:path'
|
|
|
25
25
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
26
26
|
|
|
27
27
|
import { claudeConfigDir } from './internal/config-dir.js'
|
|
28
|
+
import { readManagedSettings } from './internal/managed-settings.js'
|
|
28
29
|
import { installedPlugins } from './internal/plugins.js'
|
|
29
30
|
import { isProjectApproved } from './internal/project-approval.js'
|
|
30
31
|
import { ancestorDirs, findNearestDir, findNearestFile } from './internal/project-root.js'
|
|
@@ -35,6 +36,8 @@ export interface OutputStyle {
|
|
|
35
36
|
description: string
|
|
36
37
|
body: string
|
|
37
38
|
keepCodingInstructions: boolean
|
|
39
|
+
/** Claude's `force-for-plugin`: a plugin style applying automatically. */
|
|
40
|
+
forceForPlugin: boolean
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
function field(frontmatter: string, key: string): string {
|
|
@@ -47,7 +50,20 @@ export function parseStyle(content: string, fallbackName: string): OutputStyle {
|
|
|
47
50
|
const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content)
|
|
48
51
|
const frontmatter = match ? match[1] : ''
|
|
49
52
|
const body = match ? content.slice(match[0].length) : content
|
|
50
|
-
return {
|
|
53
|
+
return {
|
|
54
|
+
name: field(frontmatter, 'name') || fallbackName,
|
|
55
|
+
description: field(frontmatter, 'description'),
|
|
56
|
+
body: body.trim(),
|
|
57
|
+
keepCodingInstructions: field(frontmatter, 'keep-coding-instructions') === 'true',
|
|
58
|
+
forceForPlugin: field(frontmatter, 'force-for-plugin') === 'true',
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Claude's `force-for-plugin` (plugin output styles only): the first loaded style
|
|
63
|
+
* carrying it applies automatically, overriding the outputStyle setting. The
|
|
64
|
+
* caller passes only plugin-loaded styles. */
|
|
65
|
+
export function forcedPluginStyle(styles: OutputStyle[]): OutputStyle | undefined {
|
|
66
|
+
return styles.find((style) => style.forceForPlugin)
|
|
51
67
|
}
|
|
52
68
|
|
|
53
69
|
/** Equivalents of Claude's built-in styles, shipped with pi-code as the
|
|
@@ -137,8 +153,10 @@ export function settingsFiles(cwd: string, home: string, trusted: boolean): stri
|
|
|
137
153
|
return claudeSettingsChain(cwd, home, trusted)
|
|
138
154
|
}
|
|
139
155
|
|
|
140
|
-
/** The `outputStyle` recorded in settings, last file winning
|
|
141
|
-
|
|
156
|
+
/** The `outputStyle` recorded in settings, last file winning; a managed policy
|
|
157
|
+
* value wins over every file, per Claude's settings precedence. */
|
|
158
|
+
export function readActiveStyleName(files: string[], managed: Record<string, unknown> = readManagedSettings()): string | undefined {
|
|
159
|
+
if (typeof managed.outputStyle === 'string') return managed.outputStyle
|
|
142
160
|
let name: string | undefined
|
|
143
161
|
for (const file of files) {
|
|
144
162
|
try {
|
|
@@ -186,7 +204,10 @@ export default function outputStylesExtension(pi: ExtensionAPI) {
|
|
|
186
204
|
const nearestLocal = findNearestFile(ctx.cwd, path.join('.claude', 'settings.local.json'))
|
|
187
205
|
const claudeDir = findNearestDir(ctx.cwd, '.claude') ?? path.join(ctx.cwd, '.claude')
|
|
188
206
|
localSettingsPath = nearestLocal ?? path.join(claudeDir, 'settings.local.json')
|
|
189
|
-
|
|
207
|
+
// Claude's force-for-plugin: the first loaded forced plugin style applies
|
|
208
|
+
// automatically, overriding the outputStyle setting.
|
|
209
|
+
const forced = forcedPluginStyle(loadStyles(pluginStyleDirs(home)))
|
|
210
|
+
activeName = forced?.name ?? readActiveStyleName(settingsFiles(ctx.cwd, home, trusted))
|
|
190
211
|
const active = styleForName(styles, activeName)
|
|
191
212
|
if (active) ctx.ui.notify(`Output style: ${active.name}`, 'info')
|
|
192
213
|
})
|
|
@@ -157,8 +157,23 @@ function readSkillBody(name: string, skillDirs: string[]): string | undefined {
|
|
|
157
157
|
* the intent into a read-only toolset unless the file pins tools itself. */
|
|
158
158
|
const READ_ONLY_TOOLS = ['read', 'grep', 'find', 'ls']
|
|
159
159
|
|
|
160
|
+
/** The agent's name per Claude's naming rules: a plugin agent registers under the
|
|
161
|
+
* scoped id `<plugin>:<name>` with the filename standing in for a missing name;
|
|
162
|
+
* elsewhere the frontmatter name is required and `:` is reserved for plugin ids,
|
|
163
|
+
* so a file carrying one is not loaded. */
|
|
164
|
+
function agentName(frontmatter: Record<string, unknown>, filePath: string, pluginName?: string): string | null {
|
|
165
|
+
const declared = typeof frontmatter.name === 'string' ? frontmatter.name.trim() : ''
|
|
166
|
+
if (pluginName !== undefined) return `${pluginName}:${declared || path.basename(filePath, '.md')}`
|
|
167
|
+
if (!declared) return null
|
|
168
|
+
if (declared.includes(':')) {
|
|
169
|
+
console.warn(`pi-code-subagent: ignoring agent ${filePath}: names cannot contain ":", which is reserved for plugin-scoped identifiers`)
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
return declared
|
|
173
|
+
}
|
|
174
|
+
|
|
160
175
|
/** Parse one agent markdown file; null when it is not a usable agent definition. */
|
|
161
|
-
function parseAgentFile(content: string, source: AgentSource, filePath: string): AgentConfig | null {
|
|
176
|
+
function parseAgentFile(content: string, source: AgentSource, filePath: string, pluginName?: string): AgentConfig | null {
|
|
162
177
|
let parsed: { frontmatter: Record<string, unknown>; body: string }
|
|
163
178
|
try {
|
|
164
179
|
parsed = parseFrontmatter<Record<string, unknown>>(content)
|
|
@@ -166,7 +181,7 @@ function parseAgentFile(content: string, source: AgentSource, filePath: string):
|
|
|
166
181
|
return null // malformed YAML must not abort discovery for the whole directory
|
|
167
182
|
}
|
|
168
183
|
const { frontmatter, body } = parsed
|
|
169
|
-
const name =
|
|
184
|
+
const name = agentName(frontmatter, filePath, pluginName)
|
|
170
185
|
const description = typeof frontmatter.description === 'string' ? frontmatter.description : ''
|
|
171
186
|
if (!name || !description) return null
|
|
172
187
|
const tools = parseToolsField(frontmatter.tools, true)
|
|
@@ -198,6 +213,8 @@ function parseAgentFile(content: string, source: AgentSource, filePath: string):
|
|
|
198
213
|
memory: parseMemoryField(frontmatter.memory),
|
|
199
214
|
maxTurns: parseMaxTurns(frontmatter.maxTurns),
|
|
200
215
|
isolation,
|
|
216
|
+
hooks: frontmatter.hooks !== null && typeof frontmatter.hooks === 'object' && !Array.isArray(frontmatter.hooks) ? (frontmatter.hooks as Record<string, unknown>) : undefined,
|
|
217
|
+
background: frontmatter.background === true ? true : undefined,
|
|
201
218
|
systemPrompt: body,
|
|
202
219
|
source,
|
|
203
220
|
filePath,
|
|
@@ -262,6 +279,12 @@ export interface AgentConfig {
|
|
|
262
279
|
maxTurns?: number
|
|
263
280
|
/** Claude's `isolation: worktree`: run the child in a temporary git worktree. */
|
|
264
281
|
isolation?: 'worktree'
|
|
282
|
+
/** Claude's frontmatter `hooks`, scoped to this subagent: passed to the child
|
|
283
|
+
* via env, with Stop converted to SubagentStop (see agentHooksEnv). */
|
|
284
|
+
hooks?: Record<string, unknown>
|
|
285
|
+
/** Claude's `background: true`: keep this agent in the background even when
|
|
286
|
+
* asked to run it in the foreground. */
|
|
287
|
+
background?: boolean
|
|
265
288
|
systemPrompt: string
|
|
266
289
|
source: AgentSource
|
|
267
290
|
filePath: string
|
|
@@ -274,7 +297,7 @@ export interface AgentDiscoveryResult {
|
|
|
274
297
|
|
|
275
298
|
/** Claude scans .claude/agents recursively so agents can be organized into
|
|
276
299
|
* subfolders (agents/review/, agents/research/); the walk mirrors that. */
|
|
277
|
-
function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
|
|
300
|
+
function loadAgentsFromDir(dir: string, source: AgentSource, pluginName?: string): AgentConfig[] {
|
|
278
301
|
const agents: AgentConfig[] = []
|
|
279
302
|
|
|
280
303
|
let entries: fs.Dirent[]
|
|
@@ -287,7 +310,7 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
|
|
|
287
310
|
for (const entry of entries) {
|
|
288
311
|
const filePath = path.join(dir, entry.name)
|
|
289
312
|
if (entry.isDirectory()) {
|
|
290
|
-
agents.push(...loadAgentsFromDir(filePath, source))
|
|
313
|
+
agents.push(...loadAgentsFromDir(filePath, source, pluginName))
|
|
291
314
|
continue
|
|
292
315
|
}
|
|
293
316
|
if (!entry.name.endsWith('.md')) continue
|
|
@@ -300,7 +323,7 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
|
|
|
300
323
|
continue
|
|
301
324
|
}
|
|
302
325
|
|
|
303
|
-
const agent = parseAgentFile(content, source, filePath)
|
|
326
|
+
const agent = parseAgentFile(content, source, filePath, pluginName)
|
|
304
327
|
if (agent) agents.push(agent)
|
|
305
328
|
}
|
|
306
329
|
|
|
@@ -323,13 +346,14 @@ export type AgentSource = 'user' | 'project' | 'builtin' | 'plugin'
|
|
|
323
346
|
/** Bundled default agents (Explore, Plan, general-purpose), lowest precedence. */
|
|
324
347
|
const BUILTIN_AGENTS_DIR = path.join(import.meta.dirname, 'agents')
|
|
325
348
|
|
|
326
|
-
/** Agent directories of every enabled plugin
|
|
349
|
+
/** Agent directories of every enabled plugin, each with its plugin name (Claude
|
|
350
|
+
* scopes plugin agent ids as `<plugin>:<name>`): `agents/` unless the manifest
|
|
327
351
|
* points elsewhere. Plugins are user-installed, so user scope only decides. */
|
|
328
|
-
function pluginAgentDirs(home: string): string
|
|
352
|
+
function pluginAgentDirs(home: string): Array<{ dir: string; pluginName: string }> {
|
|
329
353
|
return installedPlugins(home).flatMap((plugin) => {
|
|
330
354
|
const declared = plugin.manifest.agents
|
|
331
355
|
const dirs = Array.isArray(declared) ? declared : [typeof declared === 'string' ? declared : 'agents']
|
|
332
|
-
return dirs.map((dir) => path.resolve(plugin.root, String(dir)))
|
|
356
|
+
return dirs.map((dir) => ({ dir: path.resolve(plugin.root, String(dir)), pluginName: plugin.name }))
|
|
333
357
|
})
|
|
334
358
|
}
|
|
335
359
|
|
|
@@ -344,7 +368,7 @@ export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryRe
|
|
|
344
368
|
|
|
345
369
|
// Plugins load after builtins and before the user's own dirs, so a user agent
|
|
346
370
|
// wins a name clash with a plugin's, and ~/.pi/agent/agents wins over ~/.claude.
|
|
347
|
-
const userAgents = scope === 'project' ? [] : [...loadAgentsFromDir(BUILTIN_AGENTS_DIR, 'builtin'), ...pluginAgentDirs(os.homedir()).flatMap((
|
|
371
|
+
const userAgents = scope === 'project' ? [] : [...loadAgentsFromDir(BUILTIN_AGENTS_DIR, 'builtin'), ...pluginAgentDirs(os.homedir()).flatMap((entry) => loadAgentsFromDir(entry.dir, 'plugin', entry.pluginName)), ...loadAgentsFromDir(claudeUserDir, 'user'), ...loadAgentsFromDir(userDir, 'user')]
|
|
348
372
|
// project .claude/agents loads first so project .pi/agents wins on name conflicts
|
|
349
373
|
const projectAgents = scope === 'user' ? [] : [...projectClaudeDirs.flatMap((dir) => loadAgentsFromDir(dir, 'project')), ...(projectPiDir ? loadAgentsFromDir(projectPiDir, 'project') : [])]
|
|
350
374
|
|
|
@@ -43,6 +43,8 @@ export interface BackgroundSpawn {
|
|
|
43
43
|
command: string
|
|
44
44
|
args: string[]
|
|
45
45
|
cwd: string
|
|
46
|
+
/** Extra child environment (agent-frontmatter hooks ride here). */
|
|
47
|
+
env?: Record<string, string>
|
|
46
48
|
/** The --system-prompt body, kept so a resume can rebuild the file the
|
|
47
49
|
* completing run deleted. Without it the resumed child is handed a path that no
|
|
48
50
|
* longer exists, and pi falls back to using that path as the prompt text. */
|
|
@@ -213,12 +215,14 @@ function withRebuiltPrompt(spawnSpec: BackgroundSpawn): string[] {
|
|
|
213
215
|
}
|
|
214
216
|
}
|
|
215
217
|
|
|
216
|
-
export function startBackgroundRun(agent: string, task: string, invocation: BackgroundSpawn, onComplete: (run: BackgroundRun) => void): string | null {
|
|
218
|
+
export function startBackgroundRun(agent: string, task: string, invocation: BackgroundSpawn, onComplete: (run: BackgroundRun) => void, presetId?: string): string | null {
|
|
217
219
|
// Checked here, synchronously with registration: callers await temp-file writes
|
|
218
220
|
// between any check of their own and this call, so a parallel tool-call batch
|
|
219
221
|
// could otherwise all pass that earlier check and overshoot the cap.
|
|
220
222
|
if (activeBackgroundRuns() >= MAX_BACKGROUND_RUNS) return null
|
|
221
|
-
|
|
223
|
+
// A preset id lets the caller run SubagentStart hooks pre-spawn with the same
|
|
224
|
+
// id the run will carry.
|
|
225
|
+
const id = presetId ?? `bg-${randomUUID().slice(0, 8)}`
|
|
222
226
|
// A stable session id per run: the child persists its session, so a follow-up can
|
|
223
227
|
// resume it instead of starting cold.
|
|
224
228
|
const sessionId = `pi-code-${id}-${randomUUID().slice(0, 8)}`
|
|
@@ -240,7 +244,7 @@ function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (
|
|
|
240
244
|
// Its own group, so cancelling reaches any grandchild the agent spawned.
|
|
241
245
|
detached: true,
|
|
242
246
|
// The marker lets the child's subagent tool refuse to nest further.
|
|
243
|
-
env: { ...process.env, PI_CODE_SUBAGENT: '1' },
|
|
247
|
+
env: { ...process.env, PI_CODE_SUBAGENT: '1', ...invocation.env },
|
|
244
248
|
})
|
|
245
249
|
run.live = true
|
|
246
250
|
const killGroup = (signal: NodeJS.Signals): void => {
|
|
@@ -30,6 +30,7 @@ import { capForContext } from '../internal/output-guard.js'
|
|
|
30
30
|
import { isProjectApproved, isProjectApprovedSilently } from '../internal/project-approval.js'
|
|
31
31
|
import { repoRoot } from '../internal/project-root.js'
|
|
32
32
|
import { SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
|
|
33
|
+
import { runSubagentStartHooks } from '../internal/subagent-hooks.js'
|
|
33
34
|
import { autoMemoryEnabled, capIndexForPrompt, INDEX_MAX_BYTES, INDEX_MAX_LINES, memorySettingsFiles, readMemorySettings } from '../memory.js'
|
|
34
35
|
import { skillDirs } from '../skills.js'
|
|
35
36
|
import { type AgentConfig, type AgentMemoryScope, type AgentScope, type AgentSource, discoverAgents, expandMcpToolPatterns, resolveModelAlias, withPreloadedSkills } from './agents.js'
|
|
@@ -151,6 +152,10 @@ interface RunAgentOptions {
|
|
|
151
152
|
onUpdate?: OnUpdateCallback
|
|
152
153
|
makeDetails: (results: SingleResult[]) => SubagentDetails
|
|
153
154
|
onPhase?: SubagentPhaseSink
|
|
155
|
+
/** The child's run id, set by the wrapper so the spawn env can carry it. */
|
|
156
|
+
agentId?: string
|
|
157
|
+
/** SubagentStart hook context, injected ahead of the child's first prompt. */
|
|
158
|
+
startContexts?: string[]
|
|
154
159
|
/** Skill directories to preload from, resolved where project trust is known. */
|
|
155
160
|
skillRoots?: string[]
|
|
156
161
|
/** Models this user can actually run, for resolving a tier alias. */
|
|
@@ -208,10 +213,13 @@ async function runSingleAgent(options: RunAgentOptions): Promise<SingleResult> {
|
|
|
208
213
|
}
|
|
209
214
|
}
|
|
210
215
|
const agentId = `fg-${randomUUID().slice(0, 8)}`
|
|
216
|
+
// SubagentStart hooks run pre-spawn through the seam so their additionalContext
|
|
217
|
+
// reaches the child before its first prompt.
|
|
218
|
+
const startContexts = await runSubagentStartHooks(agent.name, agentId)
|
|
211
219
|
options.onPhase?.('start', agent.name, agentId)
|
|
212
220
|
let result: SingleResult | undefined
|
|
213
221
|
try {
|
|
214
|
-
result = await runSingleAgentInner(options)
|
|
222
|
+
result = await runSingleAgentInner({ ...options, agentId, startContexts })
|
|
215
223
|
return result
|
|
216
224
|
} finally {
|
|
217
225
|
options.onPhase?.('stop', agent.name, agentId, result ? getFinalOutput(result.messages) || undefined : undefined)
|
|
@@ -302,7 +310,7 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
|
|
|
302
310
|
args.push('--system-prompt', tmpPromptPath)
|
|
303
311
|
}
|
|
304
312
|
|
|
305
|
-
args.push(
|
|
313
|
+
args.push(taskWithStartContext(task, options.startContexts ?? []))
|
|
306
314
|
let wasAborted = false
|
|
307
315
|
|
|
308
316
|
const exitCode = await new Promise<number>((resolve) => {
|
|
@@ -315,7 +323,7 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
|
|
|
315
323
|
// direct child orphans a build or dev server the agent started.
|
|
316
324
|
detached: true,
|
|
317
325
|
// The marker lets the child's subagent tool refuse to nest further.
|
|
318
|
-
env: { ...process.env, PI_CODE_SUBAGENT: '1' },
|
|
326
|
+
env: { ...process.env, PI_CODE_SUBAGENT: '1', ...agentHooksEnv(agent, options.agentId ?? '') },
|
|
319
327
|
})
|
|
320
328
|
let buffer = ''
|
|
321
329
|
let assistantTurns = 0
|
|
@@ -764,9 +772,41 @@ function agentInvocationArgs(agent: AgentConfig, aliasModel?: string): string[]
|
|
|
764
772
|
// grant granted nothing.
|
|
765
773
|
if (agent.tools && agent.tools.length > 0) args.push('--tools', expandMcpToolPatterns(agent.tools, knownMcpAliases).join(','))
|
|
766
774
|
if (agent.disallowedTools && agent.disallowedTools.length > 0) args.push('--exclude-tools', expandMcpToolPatterns(agent.disallowedTools, knownMcpAliases).join(','))
|
|
775
|
+
// Claude: "Explore and Plan are the only subagents that omit CLAUDE.md" (and no
|
|
776
|
+
// field or setting changes which agents skip them), to keep research fast.
|
|
777
|
+
if (agent.source === 'builtin' && (agent.name === 'Explore' || agent.name === 'Plan')) args.push('--no-context-files')
|
|
767
778
|
return args
|
|
768
779
|
}
|
|
769
780
|
|
|
781
|
+
/** Claude's agent-frontmatter hooks ride to the child as env; the child's hooks
|
|
782
|
+
* extension merges them for the run only (they die with the process, matching
|
|
783
|
+
* "only while that subagent is running"). Stop converts to SubagentStop, the
|
|
784
|
+
* event the child fires when it completes, as Claude documents. */
|
|
785
|
+
function agentHooksEnv(agent: AgentConfig, agentId: string): Record<string, string> {
|
|
786
|
+
if (!agent.hooks) return {}
|
|
787
|
+
const hooks: Record<string, unknown> = { ...agent.hooks }
|
|
788
|
+
const stop = hooks.Stop
|
|
789
|
+
delete hooks.Stop
|
|
790
|
+
if (Array.isArray(stop)) hooks.SubagentStop = [...(Array.isArray(hooks.SubagentStop) ? (hooks.SubagentStop as unknown[]) : []), ...stop]
|
|
791
|
+
return { PI_CODE_AGENT_HOOKS: JSON.stringify({ agent: agent.name, id: agentId, hooks }) }
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** Whether a run belongs in the background: the caller asked, or Claude's
|
|
795
|
+
* `background: true` frontmatter keeps the agent there even on a foreground ask
|
|
796
|
+
* (single mode). */
|
|
797
|
+
function wantsBackground(params: { background?: boolean; agent?: string }, agents: AgentConfig[]): boolean {
|
|
798
|
+
if (params.background) return true
|
|
799
|
+
return params.agent !== undefined && agents.find((a) => a.name === params.agent)?.background === true
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** The task argument with any SubagentStart hook context ahead of it, per Claude:
|
|
803
|
+
* "added to the subagent's context at the start of its conversation, before its
|
|
804
|
+
* first prompt". */
|
|
805
|
+
function taskWithStartContext(task: string, contexts: string[]): string {
|
|
806
|
+
const context = contexts.filter(Boolean).join('\n')
|
|
807
|
+
return context ? `${context}\n\nTask: ${task}` : `Task: ${task}`
|
|
808
|
+
}
|
|
809
|
+
|
|
770
810
|
function backgroundCapResult(makeDetails: MakeDetails): ToolResult {
|
|
771
811
|
return {
|
|
772
812
|
content: [{ type: 'text', text: `Too many background runs (max ${MAX_BACKGROUND_RUNS} running). Wait for one to finish; check progress with {status: true}.` }],
|
|
@@ -853,35 +893,45 @@ async function runBackgroundMode(params: SubagentParamsStatic, context: Backgrou
|
|
|
853
893
|
// foreground path).
|
|
854
894
|
args.push('--system-prompt', tmpPrompt.filePath)
|
|
855
895
|
}
|
|
856
|
-
|
|
896
|
+
// The id is preset so SubagentStart hooks run pre-spawn with the id the run
|
|
897
|
+
// will actually carry, and their context lands before the child's first prompt.
|
|
898
|
+
const presetId = `bg-${randomUUID().slice(0, 8)}`
|
|
899
|
+
const startContexts = await runSubagentStartHooks(agent.name, presetId)
|
|
900
|
+
args.push(taskWithStartContext(task, startContexts))
|
|
857
901
|
const invocation = getPiInvocation(args)
|
|
858
|
-
const id = startBackgroundRun(
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
902
|
+
const id = startBackgroundRun(
|
|
903
|
+
agent.name,
|
|
904
|
+
task,
|
|
905
|
+
{ command: invocation.command, args: invocation.args, cwd: worktree?.dir ?? runCwd, env: agentHooksEnv(agent, presetId), promptBody: tmpPrompt ? promptBody : undefined, maxTurns: agent.maxTurns },
|
|
906
|
+
(run) => {
|
|
907
|
+
removeTmpPrompt(tmpPrompt)
|
|
908
|
+
const finish = (): void => {
|
|
909
|
+
// Both calls throw once the session that started the run is disposed. driveRun's
|
|
910
|
+
// catch covers the synchronous path, but the worktree branch reaches here from an
|
|
911
|
+
// async continuation outside it, so the guard must live in finish itself.
|
|
912
|
+
try {
|
|
913
|
+
pi.events.emit(SUBAGENT_CHANNEL, { phase: 'stop', agentType: run.agent, agentId: run.id, ...(run.output?.trim() ? { lastAssistantMessage: run.output.trim() } : {}) })
|
|
914
|
+
pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
|
|
915
|
+
} catch {
|
|
916
|
+
// Session disposed after the run outlived it; nothing to notify.
|
|
917
|
+
}
|
|
869
918
|
}
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
919
|
+
if (!worktree) {
|
|
920
|
+
finish()
|
|
921
|
+
return
|
|
922
|
+
}
|
|
923
|
+
// Cleanup only removes a pristine worktree; a kept one is reported in the
|
|
924
|
+
// completion text so the parent knows where the changes live.
|
|
925
|
+
const keptWorktree = worktree
|
|
926
|
+
void cleanupAgentWorktree(runCwd, keptWorktree)
|
|
927
|
+
.then((outcome) => {
|
|
928
|
+
if (outcome === 'kept') run.output = `${run.output ?? ''}\n[isolation: worktree kept at ${keptWorktree.dir} (branch ${keptWorktree.branch}); the agent's changes live there]`.trim()
|
|
929
|
+
})
|
|
930
|
+
.catch(() => {})
|
|
931
|
+
.finally(finish)
|
|
932
|
+
},
|
|
933
|
+
presetId,
|
|
934
|
+
)
|
|
885
935
|
if (id === null) {
|
|
886
936
|
// Lost the cap race to a parallel batch: the atomic check inside startBackgroundRun refused.
|
|
887
937
|
removeTmpPrompt(tmpPrompt)
|
|
@@ -1553,7 +1603,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1553
1603
|
// unavailable tier still falls back to the session model.
|
|
1554
1604
|
const availableModels = ctx.modelRegistry?.getAvailable?.() ?? []
|
|
1555
1605
|
|
|
1556
|
-
if (params
|
|
1606
|
+
if (wantsBackground(params, agents)) return runBackgroundMode(params, { agents, defaultCwd: ctx.cwd, pi, makeDetails, skillRoots, availableModels, projectApproved }, (id) => rememberBackgroundRun(id))
|
|
1557
1607
|
|
|
1558
1608
|
const mode: ModeContext = {
|
|
1559
1609
|
agents,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.34",
|
|
4
4
|
"description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, and subagents",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|