pi-code 1.0.4 → 1.0.5

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 +179 -21
  4. package/extensions/context-imports.ts +353 -39
  5. package/extensions/hooks.ts +351 -63
  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 +373 -59
  10. package/extensions/internal/html-markdown.ts +61 -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 +171 -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 +125 -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 +77 -0
  22. package/extensions/internal/web-transport.ts +3 -1
  23. package/extensions/mcp.ts +272 -28
  24. package/extensions/memory.ts +129 -16
  25. package/extensions/notify.ts +77 -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 +93 -3
  31. package/extensions/subagent/agents.ts +72 -61
  32. package/extensions/subagent/background.ts +25 -6
  33. package/extensions/subagent/index.ts +194 -29
  34. package/extensions/web.ts +80 -15
  35. package/package.json +1 -1
@@ -23,11 +23,14 @@ 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'
33
+ import { type AgentConfig, type AgentMemoryScope, type AgentScope, discoverAgents, resolveModelAlias, withPreloadedSkills } from './agents.js'
31
34
  import { activeBackgroundRuns, backgroundRun, backgroundStatusText, cancelBackgroundRun, MAX_BACKGROUND_RUNS, resumeBackgroundRun, startBackgroundRun } from './background.js'
32
35
 
33
36
  const MAX_PARALLEL_TASKS = 8
@@ -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)
@@ -546,6 +567,7 @@ interface ModeContext {
546
567
  onPhase?: SubagentPhaseSink
547
568
  skillRoots: string[]
548
569
  availableModels: ReadonlyArray<{ id: string }>
570
+ projectApproved: boolean
549
571
  }
550
572
 
551
573
  async function checkProjectAgentGate(params: SubagentParamsStatic, agents: AgentConfig[], ctx: ExtensionContext, projectAgentsDir: string | null, gateMode: SubagentMode, makeDetails: MakeDetails): Promise<ToolResult | null> {
@@ -578,6 +600,100 @@ async function checkProjectAgentGate(params: SubagentParamsStatic, agents: Agent
578
600
  return null
579
601
  }
580
602
 
603
+ /** The system prompt for Claude's experimental `type: "agent"` hooks: the subagent
604
+ * inspects with read-only tools and returns the same JSON decision a command hook's
605
+ * stdout carries. A hook-supplied `systemPrompt` is appended after it. */
606
+ export const AGENT_HOOK_SYSTEM = [
607
+ 'You are a Claude Code agent hook verifying whether an action should proceed.',
608
+ 'Use the Read, Grep, and Glob tools to inspect files as needed before deciding.',
609
+ 'When done, respond with ONLY a JSON object and nothing else:',
610
+ '{"hookSpecificOutput":{"permissionDecision":"allow"|"deny"|"ask","permissionDecisionReason":"<short reason>"}}',
611
+ 'Use "allow" to let the action proceed, "deny" to block it, "ask" to require the user to confirm.',
612
+ ].join('\n')
613
+
614
+ /** A throwaway agent config for one agent-hook run: read-only inspection tools, the
615
+ * hook's model (a fast default when unset), and the decision-returning system prompt. */
616
+ export function buildHookAgent(request: Pick<AgentRunRequest, 'model' | 'systemPrompt'>): AgentConfig {
617
+ return {
618
+ name: 'agent-hook',
619
+ description: 'Verifies a hook condition using read-only inspection tools.',
620
+ tools: ['read', 'grep', 'find'],
621
+ model: request.model,
622
+ systemPrompt: request.systemPrompt ? `${AGENT_HOOK_SYSTEM}\n\n${request.systemPrompt}` : AGENT_HOOK_SYSTEM,
623
+ source: 'builtin',
624
+ filePath: '',
625
+ }
626
+ }
627
+
628
+ /** The file-management tools a memory-enabled child needs for its store. */
629
+ const MEMORY_TOOLS = ['read', 'write', 'edit']
630
+
631
+ /** Where an agent's own persistent memory lives, per its `memory:` scope (Claude:
632
+ * user -> ~/.claude/agent-memory/<name>, project -> <root>/.claude/agent-memory/<name>,
633
+ * local -> <root>/.claude/agent-memory-local/<name>). The name comes from frontmatter
634
+ * a repository can control, so it is sanitized before becoming a path segment. */
635
+ export function agentMemoryDir(scope: AgentMemoryScope, name: string, cwd: string, home: string): string {
636
+ const sanitized = name.replace(/[^\w.-]+/g, '_')
637
+ // A name of only dots ('.', '..') survives the character filter but still traverses.
638
+ const segment = /^\.+$/.test(sanitized) ? '_' : sanitized
639
+ if (scope === 'user') return path.join(home, '.claude', 'agent-memory', segment)
640
+ const root = repoRoot(cwd) ?? cwd
641
+ return path.join(root, '.claude', scope === 'project' ? 'agent-memory' : 'agent-memory-local', segment)
642
+ }
643
+
644
+ /** The prompt section giving a memory-enabled child its own persistent store: the
645
+ * directory, read/write/curation instructions, and its MEMORY.md capped like the
646
+ * parent's index load (first 200 lines or 25KB, whichever comes first). */
647
+ export function agentMemorySection(dir: string, memoryMd: string): string {
648
+ const indexPath = path.join(dir, 'MEMORY.md')
649
+ const capped = capIndexForPrompt(memoryMd)
650
+ const current = capped.trim() ? `Current ${indexPath}:\n\n${capped}` : `${indexPath} does not exist yet; create it once you have something worth keeping.`
651
+ return [
652
+ '## Agent memory',
653
+ '',
654
+ `You have a persistent memory directory at ${dir} that survives across sessions.`,
655
+ 'Use the read, write, and edit tools to record durable insights, project patterns, and lessons learned there, and consult them when relevant.',
656
+ `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.`,
657
+ '',
658
+ current,
659
+ ].join('\n')
660
+ }
661
+
662
+ /** The memory section for one run, or undefined when the agent declares no memory,
663
+ * auto memory is off, or a repo-scoped store is not approved. Subagent memory is part
664
+ * of auto memory, so the same settings chain and env kill switch gate it. */
665
+ export function agentMemoryPromptSection(agent: Pick<AgentConfig, 'memory' | 'name'>, cwd: string, projectApproved: boolean): string | undefined {
666
+ if (!agent.memory) return undefined
667
+ // project and local stores live under the repository's .claude, a repo-controlled
668
+ // path; like rules, they are only read once the project is approved.
669
+ if (agent.memory !== 'user' && !projectApproved) return undefined
670
+ const settings = readMemorySettings(memorySettingsFiles(cwd, os.homedir(), projectApproved))
671
+ if (!autoMemoryEnabled(settings.autoMemoryEnabled, process.env)) return undefined
672
+ const dir = agentMemoryDir(agent.memory, agent.name, cwd, os.homedir())
673
+ let memoryMd = ''
674
+ try {
675
+ memoryMd = fs.readFileSync(path.join(dir, 'MEMORY.md'), 'utf-8')
676
+ } catch {
677
+ // no store yet: the section still tells the child where to create one
678
+ }
679
+ return agentMemorySection(dir, memoryMd)
680
+ }
681
+
682
+ /** Widen a restricted agent's allowlist so it can manage its memory files. An
683
+ * unrestricted agent (no allowlist) already has every tool. */
684
+ export function withMemoryTools(tools: string[] | undefined): string[] | undefined {
685
+ if (!tools || tools.length === 0) return tools
686
+ return [...tools, ...MEMORY_TOOLS.filter((tool) => !tools.includes(tool))]
687
+ }
688
+
689
+ /** The child's --append-system-prompt body: the skills-preloaded prompt plus the
690
+ * agent memory section, without a stray separator when either part is empty. */
691
+ function childPromptBody(agent: AgentConfig, skillRoots: string[], memorySection: string | undefined): string {
692
+ const prompt = withPreloadedSkills(agent.systemPrompt, agent.skills, skillRoots)
693
+ if (!memorySection) return prompt
694
+ return [prompt, memorySection].filter((part) => part.trim()).join('\n\n')
695
+ }
696
+
581
697
  /** CLI args shared by foreground and background children, from the agent's config. */
582
698
  function agentInvocationArgs(agent: AgentConfig, aliasModel?: string): string[] {
583
699
  const args: string[] = ['--mode', 'json', '-p', '--no-session']
@@ -613,7 +729,7 @@ function removeTmpPrompt(tmpPrompt: { dir: string; filePath: string } | undefine
613
729
  }
614
730
  }
615
731
 
616
- async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConfig[], defaultCwd: string, pi: ExtensionAPI, makeDetails: MakeDetails, skillRoots: string[], availableModels: ReadonlyArray<{ id: string }>): Promise<ToolResult> {
732
+ async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConfig[], defaultCwd: string, pi: ExtensionAPI, makeDetails: MakeDetails, skillRoots: string[], availableModels: ReadonlyArray<{ id: string }>, projectApproved: boolean): Promise<ToolResult> {
617
733
  const task = params.task
618
734
  const agentName = params.agent
619
735
  if (!task || !agentName) {
@@ -633,16 +749,20 @@ async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConf
633
749
  if (activeBackgroundRuns() >= MAX_BACKGROUND_RUNS) {
634
750
  return backgroundCapResult(makeDetails)
635
751
  }
636
- const args = agentInvocationArgs(agent, resolveModelAlias(agent.modelAlias, availableModels))
752
+ const runCwd = params.cwd ?? defaultCwd
753
+ // Anchor project/local memory at the session project (defaultCwd), which is the one
754
+ // projectApproved gated; see the foreground path for why runCwd must not be used.
755
+ const memorySection = agentMemoryPromptSection(agent, defaultCwd, projectApproved)
756
+ const args = agentInvocationArgs(memorySection ? { ...agent, tools: withMemoryTools(agent.tools) } : agent, resolveModelAlias(agent.modelAlias, availableModels))
637
757
  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)
758
+ const promptBody = childPromptBody(agent, skillRoots, memorySection)
759
+ if (promptBody.trim()) {
760
+ tmpPrompt = await writePromptToTempFile(agent.name, promptBody)
641
761
  args.push('--append-system-prompt', tmpPrompt.filePath)
642
762
  }
643
763
  args.push(`Task: ${task}`)
644
764
  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) => {
765
+ const id = startBackgroundRun(agent.name, task, { command: invocation.command, args: invocation.args, cwd: runCwd, promptBody: tmpPrompt ? promptBody : undefined, maxTurns: agent.maxTurns }, (run) => {
646
766
  removeTmpPrompt(tmpPrompt)
647
767
  // Both calls throw once the session that started the run is disposed; driveRun
648
768
  // catches for the whole callback, so neither can escape into the child's close
@@ -700,6 +820,7 @@ async function runChainMode(chain: ChainStepParam[], mode: ModeContext): Promise
700
820
  onPhase: mode.onPhase,
701
821
  skillRoots: mode.skillRoots,
702
822
  availableModels: mode.availableModels,
823
+ projectApproved: mode.projectApproved,
703
824
  })
704
825
  results.push(result)
705
826
 
@@ -772,6 +893,7 @@ async function runParallelMode(tasks: TaskItemParam[], mode: ModeContext): Promi
772
893
  // preload and its model tier alias silently do nothing in parallel mode only.
773
894
  skillRoots: mode.skillRoots,
774
895
  availableModels: mode.availableModels,
896
+ projectApproved: mode.projectApproved,
775
897
  // Per-task update callback
776
898
  onUpdate: (partial) => {
777
899
  const live = partial.details?.results[0]
@@ -821,6 +943,7 @@ async function runSingleMode(agentName: string, task: string, cwd: string | unde
821
943
  onPhase: mode.onPhase,
822
944
  skillRoots: mode.skillRoots,
823
945
  availableModels: mode.availableModels,
946
+ projectApproved: mode.projectApproved,
824
947
  })
825
948
  const isError = result.exitCode !== 0 || result.stopReason === 'error' || result.stopReason === 'aborted'
826
949
  if (isError) {
@@ -841,8 +964,8 @@ interface CallItem {
841
964
  task: string
842
965
  }
843
966
 
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}]`)
967
+ function renderChainCall(chain: CallItem[], scope: AgentScope | undefined, theme: Theme): Text {
968
+ let text = theme.fg('toolTitle', theme.bold('subagent ')) + theme.fg('accent', `chain (${chain.length} steps)`) + scopeTag(scope, theme)
846
969
  for (let i = 0; i < Math.min(chain.length, 3); i++) {
847
970
  const step = chain[i]
848
971
  // Clean up {previous} placeholder for display
@@ -859,8 +982,8 @@ function renderChainCall(chain: CallItem[], scope: AgentScope, theme: Theme): Te
859
982
  return new Text(text, 0, 0)
860
983
  }
861
984
 
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}]`)
985
+ function renderParallelCall(tasks: CallItem[], scope: AgentScope | undefined, theme: Theme): Text {
986
+ let text = theme.fg('toolTitle', theme.bold('subagent ')) + theme.fg('accent', `parallel (${tasks.length} tasks)`) + scopeTag(scope, theme)
864
987
  for (const t of tasks.slice(0, 3)) {
865
988
  const preview = t.task.length > 40 ? `${t.task.slice(0, 40)}...` : t.task
866
989
  const taskLabel = theme.fg('accent', t.agent) + theme.fg('dim', ` ${preview}`)
@@ -873,11 +996,13 @@ function renderParallelCall(tasks: CallItem[], scope: AgentScope, theme: Theme):
873
996
  return new Text(text, 0, 0)
874
997
  }
875
998
 
876
- function renderSingleCall(agent: string | undefined, task: string | undefined, scope: AgentScope, theme: Theme): Text {
999
+ const scopeTag = (scope: AgentScope | undefined, theme: Theme): string => (scope ? theme.fg('muted', ` [${scope}]`) : '')
1000
+
1001
+ function renderSingleCall(agent: string | undefined, task: string | undefined, scope: AgentScope | undefined, theme: Theme): Text {
877
1002
  const agentName = agent || '...'
878
1003
  let preview = '...'
879
1004
  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}]`)
1005
+ let text = theme.fg('toolTitle', theme.bold('subagent ')) + theme.fg('accent', agentName) + scopeTag(scope, theme)
881
1006
  text += `\n ${theme.fg('dim', preview)}`
882
1007
  return new Text(text, 0, 0)
883
1008
  }
@@ -1131,6 +1256,37 @@ export default function subagentExtension(pi: ExtensionAPI) {
1131
1256
  pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
1132
1257
  }
1133
1258
 
1259
+ // Claude's experimental `type: "agent"` hooks spawn a read-only subagent to verify a
1260
+ // condition. The hooks extension reaches it through the agent-run seam; register a
1261
+ // runner that reuses the same single-run machinery as the subagent tool. cwd and the
1262
+ // available model list are captured per session so a hook run lands in the right repo.
1263
+ let hookCwd = process.cwd()
1264
+ let hookModels: ReadonlyArray<{ id: string }> = []
1265
+ pi.on('session_start', async (_event, ctx) => {
1266
+ hookCwd = ctx.cwd
1267
+ try {
1268
+ hookModels = ctx.modelRegistry?.getAvailable?.() ?? []
1269
+ } catch {
1270
+ hookModels = []
1271
+ }
1272
+ setAgentRunner(async (request) => {
1273
+ // A subagent session must not spawn further agents; agent hooks inside one are
1274
+ // skipped (the seam rejection is non-blocking in runAgentHook).
1275
+ if (process.env.PI_CODE_SUBAGENT) throw new Error('agent hooks do not run inside a subagent')
1276
+ const agent = buildHookAgent(request)
1277
+ const result = await runSingleAgent({
1278
+ defaultCwd: hookCwd,
1279
+ agents: [agent],
1280
+ agentName: agent.name,
1281
+ task: request.prompt,
1282
+ signal: request.signal,
1283
+ makeDetails: (results): SubagentDetails => ({ mode: 'single', agentScope: 'user', projectAgentsDir: null, results }),
1284
+ availableModels: hookModels,
1285
+ })
1286
+ return getFinalOutput(result.messages)
1287
+ })
1288
+ })
1289
+
1134
1290
  // Claude surfaces each agent's description so the model can pick one autonomously.
1135
1291
  // Rebuilt per turn (agents are rediscovered per invocation too); project agents are
1136
1292
  // included only when the project is already approved, read without prompting, since
@@ -1151,13 +1307,17 @@ export default function subagentExtension(pi: ExtensionAPI) {
1151
1307
  'Delegate tasks to specialized subagents with isolated context.',
1152
1308
  'Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).',
1153
1309
  '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").',
1310
+ 'Agents come from ~/.claude/agents and ~/.pi/agent/agents, plus project .claude/agents and .pi/agents once the project is trusted.',
1311
+ 'agentScope: "user" or "project" narrows to one source.',
1156
1312
  ].join(' '),
1157
1313
  parameters: SubagentParams,
1158
1314
 
1159
1315
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
1160
- const agentScope: AgentScope = params.agentScope ?? 'user'
1316
+ // Claude merges project agents into the default roster (project wins on a name
1317
+ // clash), and the roster above advertises them under the same approval check,
1318
+ // so a default call can reach every agent it lists. An explicit agentScope
1319
+ // still narrows or widens; the invocation gate below applies either way.
1320
+ const agentScope: AgentScope = params.agentScope ?? (isProjectApprovedSilently(ctx) ? 'both' : 'user')
1161
1321
  // Children carry PI_CODE_SUBAGENT; without this check they could spawn
1162
1322
  // grandchildren without limit.
1163
1323
  if (process.env.PI_CODE_SUBAGENT) {
@@ -1215,16 +1375,19 @@ export default function subagentExtension(pi: ExtensionAPI) {
1215
1375
  const gateResult = await checkProjectAgentGate(params, agents, ctx, discovery.projectAgentsDir, gateMode, makeDetails)
1216
1376
  if (gateResult) return gateResult
1217
1377
 
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))
1378
+ // Project skills only preload and project/local agent memory stores only load
1379
+ // once the project is approved, matching the gate the skills extension applies
1380
+ // to discovery itself. Read after the project-agent gate above so an approval
1381
+ // the user just granted there counts.
1382
+ const projectApproved = isProjectApprovedSilently(ctx)
1383
+ const skillRoots = skillDirs(ctx.cwd, os.homedir(), projectApproved)
1221
1384
  // Tier aliases resolve against what this user is authenticated for; an
1222
1385
  // unavailable tier still falls back to the session model.
1223
1386
  const availableModels = ctx.modelRegistry?.getAvailable?.() ?? []
1224
1387
 
1225
- if (params.background) return runBackgroundMode(params, agents, ctx.cwd, pi, makeDetails, skillRoots, availableModels)
1388
+ if (params.background) return runBackgroundMode(params, agents, ctx.cwd, pi, makeDetails, skillRoots, availableModels, projectApproved)
1226
1389
 
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 }) }
1390
+ 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
1391
 
1229
1392
  if (params.chain?.length) return runChainMode(params.chain, mode)
1230
1393
  if (params.tasks?.length) return runParallelMode(params.tasks, mode)
@@ -1238,7 +1401,9 @@ export default function subagentExtension(pi: ExtensionAPI) {
1238
1401
  },
1239
1402
 
1240
1403
  renderCall(args, theme, _context) {
1241
- const scope: AgentScope = args.agentScope ?? 'user'
1404
+ // No tag when the call left the scope to the contextual default: the label
1405
+ // cannot know here whether that resolved to user or both.
1406
+ const scope = args.agentScope
1242
1407
  if (args.chain && args.chain.length > 0) return renderChainCall(args.chain, scope, theme)
1243
1408
  if (args.tasks && args.tasks.length > 0) return renderParallelCall(args.tasks, scope, theme)
1244
1409
  return renderSingleCall(args.agent, args.task, scope, theme)
package/extensions/web.ts CHANGED
@@ -12,6 +12,8 @@ import type { LookupFunction } from 'node:net'
12
12
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
13
13
  import { Type } from 'typebox'
14
14
 
15
+ import { htmlToMarkdown } from './internal/html-markdown.js'
16
+ import { completeText } from './internal/model-complete.js'
15
17
  import { capForContext } from './internal/output-guard.js'
16
18
  import { httpFetch } from './internal/web-transport.js'
17
19
 
@@ -57,6 +59,26 @@ export function resolveResultUrl(href: string): string {
57
59
  return href.startsWith('//') ? `https:${href}` : href
58
60
  }
59
61
 
62
+ /** Keep results whose host matches an allowed domain (or is not blocked). A domain
63
+ * matches the host itself or any subdomain of it, as Claude's domain scoping does. */
64
+ export function filterByDomain(results: SearchResult[], allowed: string[] | undefined, blocked: string[] | undefined): SearchResult[] {
65
+ const hostOf = (url: string): string => {
66
+ try {
67
+ return new URL(url).hostname.toLowerCase()
68
+ } catch {
69
+ return ''
70
+ }
71
+ }
72
+ const matches = (host: string, domain: string): boolean => {
73
+ const d = domain.toLowerCase().replace(/^\.+/, '')
74
+ return host === d || host.endsWith(`.${d}`)
75
+ }
76
+ let out = results
77
+ if (allowed && allowed.length > 0) out = out.filter((r) => allowed.some((d) => matches(hostOf(r.url), d)))
78
+ else if (blocked && blocked.length > 0) out = out.filter((r) => !blocked.some((d) => matches(hostOf(r.url), d)))
79
+ return out
80
+ }
81
+
60
82
  export function parseSearchResults(html: string, limit: number): SearchResult[] {
61
83
  const results: SearchResult[] = []
62
84
  const anchorPattern = /<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g
@@ -76,15 +98,8 @@ export function parseSearchResults(html: string, limit: number): SearchResult[]
76
98
  return results
77
99
  }
78
100
 
79
- export function htmlToText(html: string): string {
80
- const withoutBlocks = html
81
- .replace(/<script[\s\S]*?<\/script>/gi, ' ')
82
- .replace(/<style[\s\S]*?<\/style>/gi, ' ')
83
- .replace(/<(br|\/p|\/div|\/h[1-6]|\/li|\/tr)[^>]*>/gi, '\n')
84
- const text = decodeEntities(withoutBlocks.replace(/<[^<>]*>/g, ' '))
85
- .replace(/[ \t]+/g, ' ')
86
- .replace(/\n\s+/g, '\n')
87
- .trim()
101
+ /** Cap a converted body at the fetch budget, naming what was dropped. */
102
+ function capFetchChars(text: string): string {
88
103
  return text.length > MAX_FETCH_CHARS ? `${text.slice(0, MAX_FETCH_CHARS)}\n[truncated ${text.length - MAX_FETCH_CHARS} chars]` : text
89
104
  }
90
105
 
@@ -214,7 +229,12 @@ async function fetchText(rawUrl: string, transport = httpFetch): Promise<{ text:
214
229
  throw new Error(`too many redirects for ${rawUrl}`)
215
230
  }
216
231
 
232
+ /** Claude documents a 15-minute per-URL cache for WebFetch. */
233
+ const FETCH_CACHE_TTL_MS = 15 * 60 * 1000
234
+ const FETCH_CACHE_MAX_ENTRIES = 50
235
+
217
236
  export default function webExtension(pi: ExtensionAPI) {
237
+ const fetchCache = new Map<string, { expires: number; body: string }>()
218
238
  pi.registerTool({
219
239
  name: 'web_search',
220
240
  label: 'Web search',
@@ -222,10 +242,14 @@ export default function webExtension(pi: ExtensionAPI) {
222
242
  parameters: Type.Object({
223
243
  query: Type.String({ description: 'Search query' }),
224
244
  count: Type.Optional(Type.Number({ description: 'Max results (default 5)' })),
245
+ allowed_domains: Type.Optional(Type.Array(Type.String(), { description: 'Only include results from these domains' })),
246
+ blocked_domains: Type.Optional(Type.Array(Type.String(), { description: 'Exclude results from these domains' })),
225
247
  }),
226
248
  async execute(_id, params) {
249
+ // Claude documents allowed/blocked domains as mutually exclusive; allowed wins.
227
250
  const { text } = await fetchText(SEARCH_ENDPOINT + encodeURIComponent(params.query))
228
- const results = parseSearchResults(text, Math.min(params.count ?? 5, 10))
251
+ const limit = Math.min(params.count ?? 5, 10)
252
+ const results = filterByDomain(parseSearchResults(text, 10), params.allowed_domains, params.blocked_domains).slice(0, limit)
229
253
  if (results.length === 0) {
230
254
  return { content: [{ type: 'text' as const, text: 'No results found.' }], details: {} }
231
255
  }
@@ -237,14 +261,55 @@ export default function webExtension(pi: ExtensionAPI) {
237
261
  pi.registerTool({
238
262
  name: 'web_fetch',
239
263
  label: 'Web fetch',
240
- description: 'Fetch a URL and return its content as readable text (HTML is stripped).',
241
- parameters: Type.Object({ url: Type.String({ description: 'Absolute http(s) URL to fetch' }) }),
242
- async execute(_id, params) {
264
+ description: 'Fetch a URL and return its content converted to markdown. Pass `prompt` to get a focused answer extracted from the page instead of the raw content. Responses are cached for 15 minutes per URL.',
265
+ parameters: Type.Object({
266
+ url: Type.String({ description: 'Absolute http(s) URL to fetch' }),
267
+ prompt: Type.Optional(Type.String({ description: 'What to extract or answer from the page; returns the model’s answer instead of the raw markdown' })),
268
+ }),
269
+ async execute(_id, params, signal, _onUpdate, ctx) {
243
270
  if (!/^https?:\/\//.test(params.url)) {
244
271
  return { content: [{ type: 'text' as const, text: 'Only http(s) URLs are supported.' }], details: {} }
245
272
  }
246
- const { text, contentType } = await fetchText(params.url)
247
- const body = contentType.includes('html') ? htmlToText(text) : text.slice(0, MAX_FETCH_CHARS)
273
+ const now = Date.now()
274
+ // The cache holds the raw markdown, so a second fetch with a different prompt
275
+ // still reuses it: retrieval and the optional prompt step are separate.
276
+ const cached = fetchCache.get(params.url)
277
+ let body: string
278
+ if (cached && cached.expires > now) {
279
+ body = cached.body
280
+ } else {
281
+ const { text, contentType } = await fetchText(params.url)
282
+ body = capFetchChars(contentType.includes('html') ? htmlToMarkdown(text) : text)
283
+ // Only a delivered body is cached; a thrown fetch must retry next call.
284
+ // Drop expired entries first, then evict the oldest live one if still full;
285
+ // deleting before set keeps Map insertion order a true recency order, so a
286
+ // refreshed URL moves to the newest slot instead of keeping its stale one.
287
+ fetchCache.delete(params.url)
288
+ for (const [url, entry] of fetchCache) {
289
+ if (entry.expires <= now) fetchCache.delete(url)
290
+ }
291
+ if (fetchCache.size >= FETCH_CACHE_MAX_ENTRIES) {
292
+ const oldest = fetchCache.keys().next().value
293
+ if (oldest !== undefined) fetchCache.delete(oldest)
294
+ }
295
+ fetchCache.set(params.url, { expires: now + FETCH_CACHE_TTL_MS, body })
296
+ }
297
+
298
+ // Claude's WebFetch runs the prompt over the page with a fast model and returns
299
+ // that answer, not the raw page. Best-effort: any failure (no model, provider
300
+ // error) falls back to the markdown, so web_fetch always returns something.
301
+ if (params.prompt && ctx?.model) {
302
+ try {
303
+ const answer = await completeText(ctx.model, `${params.prompt}\n\nAnswer using only the page content below, fetched from ${params.url}:\n\n${body}`, {
304
+ system: 'You extract and answer questions from a web page. Answer only from the provided content, concisely. If the content does not contain the answer, say so.',
305
+ maxTokens: 1024,
306
+ signal,
307
+ })
308
+ if (answer) return { content: [{ type: 'text' as const, text: answer }], details: {} }
309
+ } catch {
310
+ // fall through to the raw markdown
311
+ }
312
+ }
248
313
  // The char cap alone admits thousands of short lines; pi's tool-output budget
249
314
  // bounds lines too, which the shared guard enforces.
250
315
  return { content: [{ type: 'text' as const, text: capForContext(body) || '(empty response)' }], details: {} }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
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",