pi-code 1.0.4 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +25 -13
  2. package/extensions/claude-rules.ts +158 -54
  3. package/extensions/commands.ts +417 -41
  4. package/extensions/context-imports.ts +446 -61
  5. package/extensions/hooks.ts +473 -73
  6. package/extensions/init.ts +81 -0
  7. package/extensions/internal/agent-run.ts +42 -0
  8. package/extensions/internal/bash-rules.ts +27 -0
  9. package/extensions/internal/command-file.ts +423 -66
  10. package/extensions/internal/html-markdown.ts +71 -0
  11. package/extensions/internal/instruction-events.ts +70 -0
  12. package/extensions/internal/managed-settings.ts +38 -0
  13. package/extensions/internal/mcp-call.ts +28 -0
  14. package/extensions/internal/mcp-oauth.ts +177 -0
  15. package/extensions/internal/model-complete.ts +68 -0
  16. package/extensions/internal/path-rules.ts +80 -0
  17. package/extensions/internal/plugins.ts +138 -0
  18. package/extensions/internal/project-approval.ts +2 -3
  19. package/extensions/internal/project-root.ts +78 -0
  20. package/extensions/internal/shell-split.ts +65 -0
  21. package/extensions/internal/strip-comments.ts +100 -0
  22. package/extensions/internal/web-transport.ts +3 -1
  23. package/extensions/mcp.ts +579 -30
  24. package/extensions/memory.ts +158 -35
  25. package/extensions/notify.ts +76 -4
  26. package/extensions/output-styles.ts +34 -6
  27. package/extensions/plan-mode/utils.ts +3 -57
  28. package/extensions/question.ts +2 -2
  29. package/extensions/skills.ts +11 -1
  30. package/extensions/status-line.ts +100 -5
  31. package/extensions/subagent/agents.ts +72 -61
  32. package/extensions/subagent/background.ts +25 -6
  33. package/extensions/subagent/index.ts +310 -31
  34. package/extensions/web.ts +93 -15
  35. package/package.json +1 -1
@@ -23,12 +23,15 @@ import { StringEnum } from '@earendil-works/pi-ai'
23
23
  import { type ExtensionAPI, type ExtensionContext, getMarkdownTheme, type Theme, withFileMutationQueue } from '@earendil-works/pi-coding-agent'
24
24
  import { Container, Markdown, Spacer, Text } from '@earendil-works/pi-tui'
25
25
  import { type Static, Type } from 'typebox'
26
+ import { type AgentRunRequest, setAgentRunner } from '../internal/agent-run.js'
26
27
  import { capForContext } from '../internal/output-guard.js'
27
28
  import { isProjectApproved, isProjectApprovedSilently } from '../internal/project-approval.js'
29
+ import { repoRoot } from '../internal/project-root.js'
28
30
  import { SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
31
+ import { autoMemoryEnabled, capIndexForPrompt, INDEX_MAX_BYTES, INDEX_MAX_LINES, memorySettingsFiles, readMemorySettings } from '../memory.js'
29
32
  import { skillDirs } from '../skills.js'
30
- import { type AgentConfig, type AgentScope, discoverAgents, resolveModelAlias, withPreloadedSkills } from './agents.js'
31
- import { activeBackgroundRuns, backgroundRun, backgroundStatusText, cancelBackgroundRun, MAX_BACKGROUND_RUNS, resumeBackgroundRun, startBackgroundRun } from './background.js'
33
+ import { type AgentConfig, type AgentMemoryScope, type AgentScope, type AgentSource, discoverAgents, resolveModelAlias, withPreloadedSkills } from './agents.js'
34
+ import { activeBackgroundRuns, type BackgroundRun, backgroundRun, backgroundStatusText, cancelBackgroundRun, MAX_BACKGROUND_RUNS, resumeBackgroundRun, startBackgroundRun } from './background.js'
32
35
 
33
36
  const MAX_PARALLEL_TASKS = 8
34
37
  const MAX_CONCURRENCY = 4
@@ -140,7 +143,7 @@ interface UsageStats {
140
143
 
141
144
  interface SingleResult {
142
145
  agent: string
143
- agentSource: 'user' | 'project' | 'builtin' | 'unknown'
146
+ agentSource: 'user' | 'project' | 'builtin' | 'plugin' | 'unknown'
144
147
  task: string
145
148
  exitCode: number
146
149
  messages: Message[]
@@ -264,6 +267,8 @@ interface RunAgentOptions {
264
267
  skillRoots?: string[]
265
268
  /** Models this user can actually run, for resolving a tier alias. */
266
269
  availableModels?: ReadonlyArray<{ id: string }>
270
+ /** Whether repo-controlled config (a project/local agent memory store) may be read. */
271
+ projectApproved?: boolean
267
272
  }
268
273
 
269
274
  /** Publishes a child run's start/stop for the hooks extension's SubagentStart/Stop. */
@@ -299,7 +304,16 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
299
304
  }
300
305
  }
301
306
 
302
- const args = agentInvocationArgs(agent, resolveModelAlias(agent.modelAlias, options.availableModels ?? []))
307
+ const runCwd = cwd ?? defaultCwd
308
+ // Project/local memory is anchored at the SESSION project (defaultCwd), not the
309
+ // model-supplied runCwd: projectApproved gates the session's repo, so anchoring the
310
+ // store on a different (possibly unapproved) cwd would inject that repo's memory as
311
+ // trusted. User-scope memory ignores cwd, so this is safe for it too.
312
+ const memorySection = agentMemoryPromptSection(agent, defaultCwd, options.projectApproved ?? false)
313
+ // A memory-enabled child must be able to manage its store files even when the
314
+ // agent pins a tools allowlist.
315
+ const invocationAgent = memorySection ? { ...agent, tools: withMemoryTools(agent.tools) } : agent
316
+ const args = agentInvocationArgs(invocationAgent, resolveModelAlias(agent.modelAlias, options.availableModels ?? []))
303
317
 
304
318
  let tmpPromptDir: string | null = null
305
319
  let tmpPromptPath: string | null = null
@@ -326,9 +340,9 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
326
340
  }
327
341
 
328
342
  try {
329
- const promptWithSkills = withPreloadedSkills(agent.systemPrompt, agent.skills, options.skillRoots ?? [])
330
- if (promptWithSkills.trim()) {
331
- const tmp = await writePromptToTempFile(agent.name, promptWithSkills)
343
+ const promptBody = childPromptBody(agent, options.skillRoots ?? [], memorySection)
344
+ if (promptBody.trim()) {
345
+ const tmp = await writePromptToTempFile(agent.name, promptBody)
332
346
  tmpPromptDir = tmp.dir
333
347
  tmpPromptPath = tmp.filePath
334
348
  args.push('--append-system-prompt', tmpPromptPath)
@@ -340,7 +354,7 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
340
354
  const exitCode = await new Promise<number>((resolve) => {
341
355
  const invocation = getPiInvocation(args)
342
356
  const proc = spawn(invocation.command, invocation.args, {
343
- cwd: cwd ?? defaultCwd,
357
+ cwd: runCwd,
344
358
  shell: false,
345
359
  stdio: ['ignore', 'pipe', 'pipe'],
346
360
  // Its own group, so an abort reaches grandchildren too: killing only the
@@ -350,6 +364,7 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
350
364
  env: { ...process.env, PI_CODE_SUBAGENT: '1' },
351
365
  })
352
366
  let buffer = ''
367
+ let assistantTurns = 0
353
368
 
354
369
  const processLine = (line: string) => {
355
370
  if (!line.trim()) return
@@ -365,7 +380,13 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
365
380
  if (event.type === 'message_end') {
366
381
  const msg = event.message as Message
367
382
  currentResult.messages.push(msg)
368
- if (msg.role === 'assistant') accumulateAssistantMessage(currentResult, msg)
383
+ if (msg.role === 'assistant') {
384
+ accumulateAssistantMessage(currentResult, msg)
385
+ assistantTurns++
386
+ // Claude's maxTurns cap: end the child at the turn boundary once it has
387
+ // produced its Nth turn, so the collected output is kept and no turn is cut.
388
+ if (agent.maxTurns && assistantTurns >= agent.maxTurns) killGroup('SIGTERM')
389
+ }
369
390
  emitUpdate()
370
391
  } else if (event.type === 'tool_result_end') {
371
392
  currentResult.messages.push(event.message as Message)
@@ -536,6 +557,67 @@ export function cancelResultText(id: string): string {
536
557
  return `Unknown background run: ${id}.\n\n${backgroundStatusText()}`
537
558
  }
538
559
 
560
+ /** The registry fields the /tasks listing prints. */
561
+ type BackgroundRunView = Pick<BackgroundRun, 'id' | 'agent' | 'task' | 'state' | 'turns' | 'output' | 'stderr'>
562
+
563
+ const TASK_PREVIEW_CHARS = 60
564
+ const TAIL_PREVIEW_CHARS = 200
565
+
566
+ /** Truncate to at most `max` codepoints, iterating by codepoint so a multi-byte
567
+ * character on the boundary is never cut into a lone surrogate. Returns the whole
568
+ * string when it already fits, so a caller can tell it did not clip. */
569
+ function clipCodepoints(text: string, max: number): string {
570
+ const points = Array.from(text)
571
+ return points.length > max ? points.slice(0, max).join('') : text
572
+ }
573
+
574
+ /** A one-line tail of what a run last said: the stderr tail for a failure (the only
575
+ * diagnostics a boot failure leaves), the latest assistant text otherwise. */
576
+ function runOutputTail(run: BackgroundRunView): string | undefined {
577
+ const stderrTail = run.state === 'failed' ? run.stderr?.trim() : undefined
578
+ const raw = (stderrTail || run.output)?.trim()
579
+ if (!raw) return undefined
580
+ const last = raw.split('\n').at(-1)?.trim() ?? ''
581
+ const shortened = clipCodepoints(last, TAIL_PREVIEW_CHARS)
582
+ const clipped = shortened === last ? last : `${shortened}...`
583
+ return stderrTail ? `stderr: ${clipped}` : clipped
584
+ }
585
+
586
+ /** The /tasks listing: one line per background run, plus the short output tail the
587
+ * registry's own status lines omit. A pure formatter so it tests against a plain list. */
588
+ export function tasksStatusText(runs: ReadonlyArray<BackgroundRunView>): string {
589
+ if (runs.length === 0) return 'No background subagent runs in this session.'
590
+ return runs
591
+ .map((run) => {
592
+ const plural = run.turns === 1 ? '' : 's'
593
+ const label = run.state === 'running' ? 'running' : `${run.state} (${run.turns} turn${plural})`
594
+ const head = `${run.id} ${run.agent}: ${label} - ${clipCodepoints(run.task, TASK_PREVIEW_CHARS)}`
595
+ const tail = runOutputTail(run)
596
+ return tail ? `${head}\n ${tail}` : head
597
+ })
598
+ .join('\n')
599
+ }
600
+
601
+ /** Listing order for /agents: lowest to highest precedence, matching how discovery
602
+ * lets a later source win a name clash. */
603
+ const AGENT_SOURCE_ORDER: ReadonlyArray<AgentSource> = ['builtin', 'plugin', 'user', 'project']
604
+
605
+ const AGENTS_DIR_HINT = 'Add agents as markdown files under ~/.claude/agents (user) or .claude/agents (project).'
606
+
607
+ /** The /agents listing: the discovered roster grouped by source, with file paths.
608
+ * A pure formatter so it tests against a sample roster. */
609
+ export function agentsListText(agents: ReadonlyArray<Pick<AgentConfig, 'name' | 'source' | 'filePath'>>): string {
610
+ if (agents.length === 0) return `No agents discovered.\n${AGENTS_DIR_HINT}`
611
+ const sections: string[] = []
612
+ for (const source of AGENT_SOURCE_ORDER) {
613
+ const group = agents.filter((agent) => agent.source === source)
614
+ if (group.length === 0) continue
615
+ const lines = group.map((agent) => ` ${agent.name} - ${agent.filePath}`).join('\n')
616
+ sections.push(`${source}:\n${lines}`)
617
+ }
618
+ return `${sections.join('\n')}\n\n${AGENTS_DIR_HINT}`
619
+ }
620
+
539
621
  /** Everything a mode handler needs from the surrounding execute() call. */
540
622
  interface ModeContext {
541
623
  agents: AgentConfig[]
@@ -546,6 +628,7 @@ interface ModeContext {
546
628
  onPhase?: SubagentPhaseSink
547
629
  skillRoots: string[]
548
630
  availableModels: ReadonlyArray<{ id: string }>
631
+ projectApproved: boolean
549
632
  }
550
633
 
551
634
  async function checkProjectAgentGate(params: SubagentParamsStatic, agents: AgentConfig[], ctx: ExtensionContext, projectAgentsDir: string | null, gateMode: SubagentMode, makeDetails: MakeDetails): Promise<ToolResult | null> {
@@ -578,6 +661,100 @@ async function checkProjectAgentGate(params: SubagentParamsStatic, agents: Agent
578
661
  return null
579
662
  }
580
663
 
664
+ /** The system prompt for Claude's experimental `type: "agent"` hooks: the subagent
665
+ * inspects with read-only tools and returns the same JSON decision a command hook's
666
+ * stdout carries. A hook-supplied `systemPrompt` is appended after it. */
667
+ export const AGENT_HOOK_SYSTEM = [
668
+ 'You are a Claude Code agent hook verifying whether an action should proceed.',
669
+ 'Use the Read, Grep, and Glob tools to inspect files as needed before deciding.',
670
+ 'When done, respond with ONLY a JSON object and nothing else:',
671
+ '{"hookSpecificOutput":{"permissionDecision":"allow"|"deny"|"ask","permissionDecisionReason":"<short reason>"}}',
672
+ 'Use "allow" to let the action proceed, "deny" to block it, "ask" to require the user to confirm.',
673
+ ].join('\n')
674
+
675
+ /** A throwaway agent config for one agent-hook run: read-only inspection tools, the
676
+ * hook's model (a fast default when unset), and the decision-returning system prompt. */
677
+ export function buildHookAgent(request: Pick<AgentRunRequest, 'model' | 'systemPrompt'>): AgentConfig {
678
+ return {
679
+ name: 'agent-hook',
680
+ description: 'Verifies a hook condition using read-only inspection tools.',
681
+ tools: ['read', 'grep', 'find'],
682
+ model: request.model,
683
+ systemPrompt: request.systemPrompt ? `${AGENT_HOOK_SYSTEM}\n\n${request.systemPrompt}` : AGENT_HOOK_SYSTEM,
684
+ source: 'builtin',
685
+ filePath: '',
686
+ }
687
+ }
688
+
689
+ /** The file-management tools a memory-enabled child needs for its store. */
690
+ const MEMORY_TOOLS = ['read', 'write', 'edit']
691
+
692
+ /** Where an agent's own persistent memory lives, per its `memory:` scope (Claude:
693
+ * user -> ~/.claude/agent-memory/<name>, project -> <root>/.claude/agent-memory/<name>,
694
+ * local -> <root>/.claude/agent-memory-local/<name>). The name comes from frontmatter
695
+ * a repository can control, so it is sanitized before becoming a path segment. */
696
+ export function agentMemoryDir(scope: AgentMemoryScope, name: string, cwd: string, home: string): string {
697
+ const sanitized = name.replace(/[^\w.-]+/g, '_')
698
+ // A name of only dots ('.', '..') survives the character filter but still traverses.
699
+ const segment = /^\.+$/.test(sanitized) ? '_' : sanitized
700
+ if (scope === 'user') return path.join(home, '.claude', 'agent-memory', segment)
701
+ const root = repoRoot(cwd) ?? cwd
702
+ return path.join(root, '.claude', scope === 'project' ? 'agent-memory' : 'agent-memory-local', segment)
703
+ }
704
+
705
+ /** The prompt section giving a memory-enabled child its own persistent store: the
706
+ * directory, read/write/curation instructions, and its MEMORY.md capped like the
707
+ * parent's index load (first 200 lines or 25KB, whichever comes first). */
708
+ export function agentMemorySection(dir: string, memoryMd: string): string {
709
+ const indexPath = path.join(dir, 'MEMORY.md')
710
+ const capped = capIndexForPrompt(memoryMd)
711
+ const current = capped.trim() ? `Current ${indexPath}:\n\n${capped}` : `${indexPath} does not exist yet; create it once you have something worth keeping.`
712
+ return [
713
+ '## Agent memory',
714
+ '',
715
+ `You have a persistent memory directory at ${dir} that survives across sessions.`,
716
+ 'Use the read, write, and edit tools to record durable insights, project patterns, and lessons learned there, and consult them when relevant.',
717
+ `Only the first ${INDEX_MAX_LINES} lines or ${INDEX_MAX_BYTES} bytes of ${indexPath} are loaded at startup, so keep it a concise, curated index and move details into separate files in the directory.`,
718
+ '',
719
+ current,
720
+ ].join('\n')
721
+ }
722
+
723
+ /** The memory section for one run, or undefined when the agent declares no memory,
724
+ * auto memory is off, or a repo-scoped store is not approved. Subagent memory is part
725
+ * of auto memory, so the same settings chain and env kill switch gate it. */
726
+ export function agentMemoryPromptSection(agent: Pick<AgentConfig, 'memory' | 'name'>, cwd: string, projectApproved: boolean): string | undefined {
727
+ if (!agent.memory) return undefined
728
+ // project and local stores live under the repository's .claude, a repo-controlled
729
+ // path; like rules, they are only read once the project is approved.
730
+ if (agent.memory !== 'user' && !projectApproved) return undefined
731
+ const settings = readMemorySettings(memorySettingsFiles(cwd, os.homedir(), projectApproved))
732
+ if (!autoMemoryEnabled(settings.autoMemoryEnabled, process.env)) return undefined
733
+ const dir = agentMemoryDir(agent.memory, agent.name, cwd, os.homedir())
734
+ let memoryMd = ''
735
+ try {
736
+ memoryMd = fs.readFileSync(path.join(dir, 'MEMORY.md'), 'utf-8')
737
+ } catch {
738
+ // no store yet: the section still tells the child where to create one
739
+ }
740
+ return agentMemorySection(dir, memoryMd)
741
+ }
742
+
743
+ /** Widen a restricted agent's allowlist so it can manage its memory files. An
744
+ * unrestricted agent (no allowlist) already has every tool. */
745
+ export function withMemoryTools(tools: string[] | undefined): string[] | undefined {
746
+ if (!tools || tools.length === 0) return tools
747
+ return [...tools, ...MEMORY_TOOLS.filter((tool) => !tools.includes(tool))]
748
+ }
749
+
750
+ /** The child's --append-system-prompt body: the skills-preloaded prompt plus the
751
+ * agent memory section, without a stray separator when either part is empty. */
752
+ function childPromptBody(agent: AgentConfig, skillRoots: string[], memorySection: string | undefined): string {
753
+ const prompt = withPreloadedSkills(agent.systemPrompt, agent.skills, skillRoots)
754
+ if (!memorySection) return prompt
755
+ return [prompt, memorySection].filter((part) => part.trim()).join('\n\n')
756
+ }
757
+
581
758
  /** CLI args shared by foreground and background children, from the agent's config. */
582
759
  function agentInvocationArgs(agent: AgentConfig, aliasModel?: string): string[] {
583
760
  const args: string[] = ['--mode', 'json', '-p', '--no-session']
@@ -613,7 +790,20 @@ function removeTmpPrompt(tmpPrompt: { dir: string; filePath: string } | undefine
613
790
  }
614
791
  }
615
792
 
616
- async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConfig[], defaultCwd: string, pi: ExtensionAPI, makeDetails: MakeDetails, skillRoots: string[], availableModels: ReadonlyArray<{ id: string }>): Promise<ToolResult> {
793
+ /** Everything runBackgroundMode needs from the surrounding execute() call, grouped so
794
+ * the parameter list stays in bounds. */
795
+ interface BackgroundContext {
796
+ agents: AgentConfig[]
797
+ defaultCwd: string
798
+ pi: ExtensionAPI
799
+ makeDetails: MakeDetails
800
+ skillRoots: string[]
801
+ availableModels: ReadonlyArray<{ id: string }>
802
+ projectApproved: boolean
803
+ }
804
+
805
+ async function runBackgroundMode(params: SubagentParamsStatic, context: BackgroundContext, onStarted?: (id: string) => void): Promise<ToolResult> {
806
+ const { agents, defaultCwd, pi, makeDetails, skillRoots, availableModels, projectApproved } = context
617
807
  const task = params.task
618
808
  const agentName = params.agent
619
809
  if (!task || !agentName) {
@@ -633,16 +823,20 @@ async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConf
633
823
  if (activeBackgroundRuns() >= MAX_BACKGROUND_RUNS) {
634
824
  return backgroundCapResult(makeDetails)
635
825
  }
636
- const args = agentInvocationArgs(agent, resolveModelAlias(agent.modelAlias, availableModels))
826
+ const runCwd = params.cwd ?? defaultCwd
827
+ // Anchor project/local memory at the session project (defaultCwd), which is the one
828
+ // projectApproved gated; see the foreground path for why runCwd must not be used.
829
+ const memorySection = agentMemoryPromptSection(agent, defaultCwd, projectApproved)
830
+ const args = agentInvocationArgs(memorySection ? { ...agent, tools: withMemoryTools(agent.tools) } : agent, resolveModelAlias(agent.modelAlias, availableModels))
637
831
  let tmpPrompt: { dir: string; filePath: string } | undefined
638
- const promptWithSkills = withPreloadedSkills(agent.systemPrompt, agent.skills, skillRoots)
639
- if (promptWithSkills.trim()) {
640
- tmpPrompt = await writePromptToTempFile(agent.name, promptWithSkills)
832
+ const promptBody = childPromptBody(agent, skillRoots, memorySection)
833
+ if (promptBody.trim()) {
834
+ tmpPrompt = await writePromptToTempFile(agent.name, promptBody)
641
835
  args.push('--append-system-prompt', tmpPrompt.filePath)
642
836
  }
643
837
  args.push(`Task: ${task}`)
644
838
  const invocation = getPiInvocation(args)
645
- const id = startBackgroundRun(agent.name, task, { command: invocation.command, args: invocation.args, cwd: params.cwd ?? defaultCwd, promptBody: tmpPrompt ? promptWithSkills : undefined }, (run) => {
839
+ const id = startBackgroundRun(agent.name, task, { command: invocation.command, args: invocation.args, cwd: runCwd, promptBody: tmpPrompt ? promptBody : undefined, maxTurns: agent.maxTurns }, (run) => {
646
840
  removeTmpPrompt(tmpPrompt)
647
841
  // Both calls throw once the session that started the run is disposed; driveRun
648
842
  // catches for the whole callback, so neither can escape into the child's close
@@ -655,6 +849,7 @@ async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConf
655
849
  removeTmpPrompt(tmpPrompt)
656
850
  return backgroundCapResult(makeDetails)
657
851
  }
852
+ onStarted?.(id)
658
853
  pi.events.emit(SUBAGENT_CHANNEL, { phase: 'start', agentType: agent.name, agentId: id })
659
854
  return {
660
855
  content: [{ type: 'text', text: `Started background run ${id} (${agent.name}). A notification will arrive on completion; check progress with {status: true}.` }],
@@ -700,6 +895,7 @@ async function runChainMode(chain: ChainStepParam[], mode: ModeContext): Promise
700
895
  onPhase: mode.onPhase,
701
896
  skillRoots: mode.skillRoots,
702
897
  availableModels: mode.availableModels,
898
+ projectApproved: mode.projectApproved,
703
899
  })
704
900
  results.push(result)
705
901
 
@@ -772,6 +968,7 @@ async function runParallelMode(tasks: TaskItemParam[], mode: ModeContext): Promi
772
968
  // preload and its model tier alias silently do nothing in parallel mode only.
773
969
  skillRoots: mode.skillRoots,
774
970
  availableModels: mode.availableModels,
971
+ projectApproved: mode.projectApproved,
775
972
  // Per-task update callback
776
973
  onUpdate: (partial) => {
777
974
  const live = partial.details?.results[0]
@@ -821,6 +1018,7 @@ async function runSingleMode(agentName: string, task: string, cwd: string | unde
821
1018
  onPhase: mode.onPhase,
822
1019
  skillRoots: mode.skillRoots,
823
1020
  availableModels: mode.availableModels,
1021
+ projectApproved: mode.projectApproved,
824
1022
  })
825
1023
  const isError = result.exitCode !== 0 || result.stopReason === 'error' || result.stopReason === 'aborted'
826
1024
  if (isError) {
@@ -841,8 +1039,8 @@ interface CallItem {
841
1039
  task: string
842
1040
  }
843
1041
 
844
- function renderChainCall(chain: CallItem[], scope: AgentScope, theme: Theme): Text {
845
- let text = theme.fg('toolTitle', theme.bold('subagent ')) + theme.fg('accent', `chain (${chain.length} steps)`) + theme.fg('muted', ` [${scope}]`)
1042
+ function renderChainCall(chain: CallItem[], scope: AgentScope | undefined, theme: Theme): Text {
1043
+ let text = theme.fg('toolTitle', theme.bold('subagent ')) + theme.fg('accent', `chain (${chain.length} steps)`) + scopeTag(scope, theme)
846
1044
  for (let i = 0; i < Math.min(chain.length, 3); i++) {
847
1045
  const step = chain[i]
848
1046
  // Clean up {previous} placeholder for display
@@ -859,8 +1057,8 @@ function renderChainCall(chain: CallItem[], scope: AgentScope, theme: Theme): Te
859
1057
  return new Text(text, 0, 0)
860
1058
  }
861
1059
 
862
- function renderParallelCall(tasks: CallItem[], scope: AgentScope, theme: Theme): Text {
863
- let text = theme.fg('toolTitle', theme.bold('subagent ')) + theme.fg('accent', `parallel (${tasks.length} tasks)`) + theme.fg('muted', ` [${scope}]`)
1060
+ function renderParallelCall(tasks: CallItem[], scope: AgentScope | undefined, theme: Theme): Text {
1061
+ let text = theme.fg('toolTitle', theme.bold('subagent ')) + theme.fg('accent', `parallel (${tasks.length} tasks)`) + scopeTag(scope, theme)
864
1062
  for (const t of tasks.slice(0, 3)) {
865
1063
  const preview = t.task.length > 40 ? `${t.task.slice(0, 40)}...` : t.task
866
1064
  const taskLabel = theme.fg('accent', t.agent) + theme.fg('dim', ` ${preview}`)
@@ -873,11 +1071,13 @@ function renderParallelCall(tasks: CallItem[], scope: AgentScope, theme: Theme):
873
1071
  return new Text(text, 0, 0)
874
1072
  }
875
1073
 
876
- function renderSingleCall(agent: string | undefined, task: string | undefined, scope: AgentScope, theme: Theme): Text {
1074
+ const scopeTag = (scope: AgentScope | undefined, theme: Theme): string => (scope ? theme.fg('muted', ` [${scope}]`) : '')
1075
+
1076
+ function renderSingleCall(agent: string | undefined, task: string | undefined, scope: AgentScope | undefined, theme: Theme): Text {
877
1077
  const agentName = agent || '...'
878
1078
  let preview = '...'
879
1079
  if (task) preview = task.length > 60 ? `${task.slice(0, 60)}...` : task
880
- let text = theme.fg('toolTitle', theme.bold('subagent ')) + theme.fg('accent', agentName) + theme.fg('muted', ` [${scope}]`)
1080
+ let text = theme.fg('toolTitle', theme.bold('subagent ')) + theme.fg('accent', agentName) + scopeTag(scope, theme)
881
1081
  text += `\n ${theme.fg('dim', preview)}`
882
1082
  return new Text(text, 0, 0)
883
1083
  }
@@ -1124,6 +1324,21 @@ function renderParallelResult(results: SingleResult[], expanded: boolean, theme:
1124
1324
  }
1125
1325
 
1126
1326
  export default function subagentExtension(pi: ExtensionAPI) {
1327
+ // /tasks resolves these against the registry at print time. background.ts owns the
1328
+ // run records but does not enumerate them, so the ids started here are remembered;
1329
+ // a run the registry has since evicted simply drops out of the listing.
1330
+ const startedBackgroundRuns = new Set<string>()
1331
+
1332
+ // The registry self-caps and evicts old runs, so an id kept here after its record is
1333
+ // gone is dead weight. Drop those on every add, bounding the set to the registry's
1334
+ // live capacity rather than letting it grow for the whole session.
1335
+ const rememberBackgroundRun = (id: string): void => {
1336
+ for (const known of startedBackgroundRuns) {
1337
+ if (!backgroundRun(known)) startedBackgroundRuns.delete(known)
1338
+ }
1339
+ startedBackgroundRuns.add(id)
1340
+ }
1341
+
1127
1342
  const notifyBackgroundCompletion = (run: { id: string; agent: string; state: string; turns: number; output?: string; stderr?: string }): void => {
1128
1343
  // Runs through driveRun's guard, same as the background-mode callback above.
1129
1344
  // The stop event fires here too, so SubagentStop hooks see resumed runs end.
@@ -1131,6 +1346,37 @@ export default function subagentExtension(pi: ExtensionAPI) {
1131
1346
  pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
1132
1347
  }
1133
1348
 
1349
+ // Claude's experimental `type: "agent"` hooks spawn a read-only subagent to verify a
1350
+ // condition. The hooks extension reaches it through the agent-run seam; register a
1351
+ // runner that reuses the same single-run machinery as the subagent tool. cwd and the
1352
+ // available model list are captured per session so a hook run lands in the right repo.
1353
+ let hookCwd = process.cwd()
1354
+ let hookModels: ReadonlyArray<{ id: string }> = []
1355
+ pi.on('session_start', async (_event, ctx) => {
1356
+ hookCwd = ctx.cwd
1357
+ try {
1358
+ hookModels = ctx.modelRegistry?.getAvailable?.() ?? []
1359
+ } catch {
1360
+ hookModels = []
1361
+ }
1362
+ setAgentRunner(async (request) => {
1363
+ // A subagent session must not spawn further agents; agent hooks inside one are
1364
+ // skipped (the seam rejection is non-blocking in runAgentHook).
1365
+ if (process.env.PI_CODE_SUBAGENT) throw new Error('agent hooks do not run inside a subagent')
1366
+ const agent = buildHookAgent(request)
1367
+ const result = await runSingleAgent({
1368
+ defaultCwd: hookCwd,
1369
+ agents: [agent],
1370
+ agentName: agent.name,
1371
+ task: request.prompt,
1372
+ signal: request.signal,
1373
+ makeDetails: (results): SubagentDetails => ({ mode: 'single', agentScope: 'user', projectAgentsDir: null, results }),
1374
+ availableModels: hookModels,
1375
+ })
1376
+ return getFinalOutput(result.messages)
1377
+ })
1378
+ })
1379
+
1134
1380
  // Claude surfaces each agent's description so the model can pick one autonomously.
1135
1381
  // Rebuilt per turn (agents are rediscovered per invocation too); project agents are
1136
1382
  // included only when the project is already approved, read without prompting, since
@@ -1151,13 +1397,17 @@ export default function subagentExtension(pi: ExtensionAPI) {
1151
1397
  'Delegate tasks to specialized subagents with isolated context.',
1152
1398
  'Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).',
1153
1399
  'Single mode also supports background: true for long tasks; a notification arrives on completion and {status: true} lists runs.',
1154
- 'Default agent scope is "user" (from ~/.claude/agents and ~/.pi/agent/agents).',
1155
- 'To enable project-local agents in .claude/agents or .pi/agents, set agentScope: "both" (or "project").',
1400
+ 'Agents come from ~/.claude/agents and ~/.pi/agent/agents, plus project .claude/agents and .pi/agents once the project is trusted.',
1401
+ 'agentScope: "user" or "project" narrows to one source.',
1156
1402
  ].join(' '),
1157
1403
  parameters: SubagentParams,
1158
1404
 
1159
1405
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
1160
- const agentScope: AgentScope = params.agentScope ?? 'user'
1406
+ // Claude merges project agents into the default roster (project wins on a name
1407
+ // clash), and the roster above advertises them under the same approval check,
1408
+ // so a default call can reach every agent it lists. An explicit agentScope
1409
+ // still narrows or widens; the invocation gate below applies either way.
1410
+ const agentScope: AgentScope = params.agentScope ?? (isProjectApprovedSilently(ctx) ? 'both' : 'user')
1161
1411
  // Children carry PI_CODE_SUBAGENT; without this check they could spawn
1162
1412
  // grandchildren without limit.
1163
1413
  if (process.env.PI_CODE_SUBAGENT) {
@@ -1184,7 +1434,11 @@ export default function subagentExtension(pi: ExtensionAPI) {
1184
1434
  })
1185
1435
 
1186
1436
  if (params.resume) {
1187
- return { content: [{ type: 'text', text: resumeResultText(params.resume, params.task, notifyBackgroundCompletion, (run) => pi.events.emit(SUBAGENT_CHANNEL, { phase: 'start', agentType: run.agent, agentId: run.id })) }], details: makeDetails('single')([]) }
1437
+ const onResumed = (run: { id: string; agent: string }): void => {
1438
+ rememberBackgroundRun(run.id)
1439
+ pi.events.emit(SUBAGENT_CHANNEL, { phase: 'start', agentType: run.agent, agentId: run.id })
1440
+ }
1441
+ return { content: [{ type: 'text', text: resumeResultText(params.resume, params.task, notifyBackgroundCompletion, onResumed) }], details: makeDetails('single')([]) }
1188
1442
  }
1189
1443
 
1190
1444
  if (params.cancel) {
@@ -1215,16 +1469,19 @@ export default function subagentExtension(pi: ExtensionAPI) {
1215
1469
  const gateResult = await checkProjectAgentGate(params, agents, ctx, discovery.projectAgentsDir, gateMode, makeDetails)
1216
1470
  if (gateResult) return gateResult
1217
1471
 
1218
- // Project skills only preload once the project is approved, matching the
1219
- // gate the skills extension applies to discovery itself.
1220
- const skillRoots = skillDirs(ctx.cwd, os.homedir(), isProjectApprovedSilently(ctx))
1472
+ // Project skills only preload and project/local agent memory stores only load
1473
+ // once the project is approved, matching the gate the skills extension applies
1474
+ // to discovery itself. Read after the project-agent gate above so an approval
1475
+ // the user just granted there counts.
1476
+ const projectApproved = isProjectApprovedSilently(ctx)
1477
+ const skillRoots = skillDirs(ctx.cwd, os.homedir(), projectApproved)
1221
1478
  // Tier aliases resolve against what this user is authenticated for; an
1222
1479
  // unavailable tier still falls back to the session model.
1223
1480
  const availableModels = ctx.modelRegistry?.getAvailable?.() ?? []
1224
1481
 
1225
- if (params.background) return runBackgroundMode(params, agents, ctx.cwd, pi, makeDetails, skillRoots, availableModels)
1482
+ if (params.background) return runBackgroundMode(params, { agents, defaultCwd: ctx.cwd, pi, makeDetails, skillRoots, availableModels, projectApproved }, (id) => rememberBackgroundRun(id))
1226
1483
 
1227
- const mode: ModeContext = { agents, defaultCwd: ctx.cwd, signal, onUpdate, makeDetails, skillRoots, availableModels, onPhase: (phase, agentType, agentId) => pi.events.emit(SUBAGENT_CHANNEL, { phase, agentType, agentId }) }
1484
+ const mode: ModeContext = { agents, defaultCwd: ctx.cwd, signal, onUpdate, makeDetails, skillRoots, availableModels, projectApproved, onPhase: (phase, agentType, agentId) => pi.events.emit(SUBAGENT_CHANNEL, { phase, agentType, agentId }) }
1228
1485
 
1229
1486
  if (params.chain?.length) return runChainMode(params.chain, mode)
1230
1487
  if (params.tasks?.length) return runParallelMode(params.tasks, mode)
@@ -1238,7 +1495,9 @@ export default function subagentExtension(pi: ExtensionAPI) {
1238
1495
  },
1239
1496
 
1240
1497
  renderCall(args, theme, _context) {
1241
- const scope: AgentScope = args.agentScope ?? 'user'
1498
+ // No tag when the call left the scope to the contextual default: the label
1499
+ // cannot know here whether that resolved to user or both.
1500
+ const scope = args.agentScope
1242
1501
  if (args.chain && args.chain.length > 0) return renderChainCall(args.chain, scope, theme)
1243
1502
  if (args.tasks && args.tasks.length > 0) return renderParallelCall(args.tasks, scope, theme)
1244
1503
  return renderSingleCall(args.agent, args.task, scope, theme)
@@ -1261,4 +1520,24 @@ export default function subagentExtension(pi: ExtensionAPI) {
1261
1520
  return new Text(text?.type === 'text' ? text.text : '(no output)', 0, 0)
1262
1521
  },
1263
1522
  })
1523
+
1524
+ // Claude's /tasks: background-run status at a glance, returning immediately without
1525
+ // interrupting the agent; the only other way to see these is to ask the model.
1526
+ pi.registerCommand('tasks', {
1527
+ description: 'Show background subagent runs',
1528
+ handler: async (_args, ctx) => {
1529
+ const runs = [...startedBackgroundRuns].map((id) => backgroundRun(id)).filter((run): run is BackgroundRun => run !== undefined)
1530
+ ctx.ui.notify(tasksStatusText(runs), 'info')
1531
+ },
1532
+ })
1533
+
1534
+ // Claude's /agents: the discovered roster with sources and paths. Approval is read
1535
+ // silently, like the roster above: project agents list only once the project is trusted.
1536
+ pi.registerCommand('agents', {
1537
+ description: 'List discovered subagents and where they come from',
1538
+ handler: async (_args, ctx) => {
1539
+ const { agents } = discoverAgents(ctx.cwd, isProjectApprovedSilently(ctx) ? 'both' : 'user')
1540
+ ctx.ui.notify(agentsListText(agents), 'info')
1541
+ },
1542
+ })
1264
1543
  }