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.
@@ -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
- try {
296
- files.push({ path: candidate, content: fs.readFileSync(candidate, 'utf-8') })
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
- try {
721
- const userClaudeMd = path.join(claudeConfigDir(os.homedir()), 'CLAUDE.md')
722
- userContext = { path: userClaudeMd, content: fs.readFileSync(userClaudeMd, 'utf-8') }
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
- try {
739
- localContexts.push({ path: candidate, content: fs.readFileSync(candidate, 'utf-8') })
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
- try {
746
- projectDotClaude = { path: dotClaudeMd, content: fs.readFileSync(dotClaudeMd, 'utf-8') }
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/SubagentStop ride pi-code's own subagent extension, which publishes
68
- * child-run lifecycle on the shared bus (notify-style: a child has already exited by
69
- * the time SubagentStop fires, so its exit-2 block semantics cannot be honored).
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
- if (!isSubagentPhaseEvent(data) || !sessionCtx) return
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: eventName, agent_type: data.agentType, agent_id: data.agentId, ...(data.phase === 'stop' && data.lastAssistantMessage !== undefined ? { last_assistant_message: data.lastAssistantMessage } : {}) }
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[eventName], data.agentType), payload, boundRunner(ctx))
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
+ }
@@ -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
- // Claude reports an explicit error rather than writing a memory the next session
160
- // would never load, and says what to do about it.
161
- if (indexWouldOverflow(index, name, description)) {
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: [{ type: 'text', text: `Memory index is full (${INDEX_MAX_LINES} entries or ${INDEX_MAX_BYTES} bytes). Delete or consolidate memories before saving ${name}.` }],
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
- fs.mkdirSync(dir, { recursive: true })
168
- // A memory with frontmatter records its write time; one without is left as-is.
169
- fs.writeFileSync(path.join(dir, `${name}.md`), stampModified(content, now))
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
- export function readMemorySettings(files: string[]): { autoMemoryEnabled?: unknown; autoMemoryDirectory?: unknown } {
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: 'Persistent memory across sessions. Save durable facts, user preferences, corrections, and project decisions that are not derivable from the code. Actions: save (name + description + content), read (name), delete (name), list.',
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 { name: field(frontmatter, 'name') || fallbackName, description: field(frontmatter, 'description'), body: body.trim(), keepCodingInstructions: field(frontmatter, 'keep-coding-instructions') === 'true' }
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
- export function readActiveStyleName(files: string[]): string | undefined {
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
- activeName = readActiveStyleName(settingsFiles(ctx.cwd, home, trusted))
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 = typeof frontmatter.name === 'string' ? frontmatter.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: `agents/` unless the manifest
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((dir) => loadAgentsFromDir(dir, 'plugin')), ...loadAgentsFromDir(claudeUserDir, 'user'), ...loadAgentsFromDir(userDir, 'user')]
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
- const id = `bg-${randomUUID().slice(0, 8)}`
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(`Task: ${task}`)
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
- args.push(`Task: ${task}`)
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(agent.name, task, { command: invocation.command, args: invocation.args, cwd: worktree?.dir ?? runCwd, promptBody: tmpPrompt ? promptBody : undefined, maxTurns: agent.maxTurns }, (run) => {
859
- removeTmpPrompt(tmpPrompt)
860
- const finish = (): void => {
861
- // Both calls throw once the session that started the run is disposed. driveRun's
862
- // catch covers the synchronous path, but the worktree branch reaches here from an
863
- // async continuation outside it, so the guard must live in finish itself.
864
- try {
865
- pi.events.emit(SUBAGENT_CHANNEL, { phase: 'stop', agentType: run.agent, agentId: run.id, ...(run.output?.trim() ? { lastAssistantMessage: run.output.trim() } : {}) })
866
- pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
867
- } catch {
868
- // Session disposed after the run outlived it; nothing to notify.
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
- if (!worktree) {
872
- finish()
873
- return
874
- }
875
- // Cleanup only removes a pristine worktree; a kept one is reported in the
876
- // completion text so the parent knows where the changes live.
877
- const keptWorktree = worktree
878
- void cleanupAgentWorktree(runCwd, keptWorktree)
879
- .then((outcome) => {
880
- if (outcome === 'kept') run.output = `${run.output ?? ''}\n[isolation: worktree kept at ${keptWorktree.dir} (branch ${keptWorktree.branch}); the agent's changes live there]`.trim()
881
- })
882
- .catch(() => {})
883
- .finally(finish)
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.background) return runBackgroundMode(params, { agents, defaultCwd: ctx.cwd, pi, makeDetails, skillRoots, availableModels, projectApproved }, (id) => rememberBackgroundRun(id))
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.32",
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",