pi-code 1.0.3 → 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.
- package/README.md +25 -13
- package/extensions/claude-rules.ts +158 -54
- package/extensions/commands.ts +185 -27
- package/extensions/context-imports.ts +358 -41
- package/extensions/git-checkpoint.ts +22 -1
- package/extensions/hooks.ts +397 -79
- package/extensions/init.ts +81 -0
- package/extensions/internal/agent-run.ts +42 -0
- package/extensions/internal/bash-rules.ts +27 -0
- package/extensions/internal/command-file.ts +377 -53
- package/extensions/internal/html-markdown.ts +61 -0
- package/extensions/internal/instruction-events.ts +70 -0
- package/extensions/internal/managed-settings.ts +38 -0
- package/extensions/internal/mcp-call.ts +28 -0
- package/extensions/internal/mcp-oauth.ts +171 -0
- package/extensions/internal/model-complete.ts +68 -0
- package/extensions/internal/path-rules.ts +80 -0
- package/extensions/internal/plugins.ts +125 -0
- package/extensions/internal/project-approval.ts +2 -3
- package/extensions/internal/project-root.ts +78 -0
- package/extensions/internal/shell-split.ts +65 -0
- package/extensions/internal/strip-comments.ts +77 -0
- package/extensions/internal/web-transport.ts +3 -1
- package/extensions/mcp.ts +290 -31
- package/extensions/memory.ts +168 -23
- package/extensions/notify.ts +78 -5
- package/extensions/output-styles.ts +34 -6
- package/extensions/plan-mode/index.ts +55 -9
- package/extensions/plan-mode/utils.ts +3 -57
- package/extensions/question.ts +2 -2
- package/extensions/skills.ts +11 -1
- package/extensions/status-line.ts +97 -4
- package/extensions/subagent/agents.ts +72 -61
- package/extensions/subagent/background.ts +114 -25
- package/extensions/subagent/index.ts +227 -44
- package/extensions/web.ts +87 -16
- 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, backgroundStatusText, cancelBackgroundRun, MAX_BACKGROUND_RUNS, resumeBackgroundRun, startBackgroundRun } from './background.js'
|
|
33
|
+
import { type AgentConfig, type AgentMemoryScope, type AgentScope, discoverAgents, resolveModelAlias, withPreloadedSkills } from './agents.js'
|
|
34
|
+
import { activeBackgroundRuns, 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
|
|
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
|
|
330
|
-
if (
|
|
331
|
-
const tmp = await writePromptToTempFile(agent.name,
|
|
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,13 +354,17 @@ 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:
|
|
357
|
+
cwd: runCwd,
|
|
344
358
|
shell: false,
|
|
345
359
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
360
|
+
// Its own group, so an abort reaches grandchildren too: killing only the
|
|
361
|
+
// direct child orphans a build or dev server the agent started.
|
|
362
|
+
detached: true,
|
|
346
363
|
// The marker lets the child's subagent tool refuse to nest further.
|
|
347
364
|
env: { ...process.env, PI_CODE_SUBAGENT: '1' },
|
|
348
365
|
})
|
|
349
366
|
let buffer = ''
|
|
367
|
+
let assistantTurns = 0
|
|
350
368
|
|
|
351
369
|
const processLine = (line: string) => {
|
|
352
370
|
if (!line.trim()) return
|
|
@@ -362,7 +380,13 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
|
|
|
362
380
|
if (event.type === 'message_end') {
|
|
363
381
|
const msg = event.message as Message
|
|
364
382
|
currentResult.messages.push(msg)
|
|
365
|
-
if (msg.role === 'assistant')
|
|
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
|
+
}
|
|
366
390
|
emitUpdate()
|
|
367
391
|
} else if (event.type === 'tool_result_end') {
|
|
368
392
|
currentResult.messages.push(event.message as Message)
|
|
@@ -401,19 +425,24 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
|
|
|
401
425
|
resolve(1)
|
|
402
426
|
})
|
|
403
427
|
|
|
428
|
+
const killGroup = (sig: NodeJS.Signals): void => {
|
|
429
|
+
try {
|
|
430
|
+
process.kill(-proc.pid!, sig)
|
|
431
|
+
} catch {
|
|
432
|
+
try {
|
|
433
|
+
proc.kill(sig)
|
|
434
|
+
} catch {
|
|
435
|
+
/* already gone */
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
404
439
|
if (signal) {
|
|
405
440
|
onAbort = () => {
|
|
406
441
|
wasAborted = true
|
|
407
|
-
|
|
442
|
+
killGroup('SIGTERM')
|
|
408
443
|
// proc.killed only reports that the signal was sent, not that the child died. Escalate
|
|
409
444
|
// on a timer that the 'close' handler clears once the child has actually exited.
|
|
410
|
-
killTimer = setTimeout(() =>
|
|
411
|
-
try {
|
|
412
|
-
proc.kill('SIGKILL')
|
|
413
|
-
} catch {
|
|
414
|
-
/* already gone */
|
|
415
|
-
}
|
|
416
|
-
}, 5000)
|
|
445
|
+
killTimer = setTimeout(() => killGroup('SIGKILL'), 5000)
|
|
417
446
|
}
|
|
418
447
|
if (signal.aborted) onAbort()
|
|
419
448
|
else signal.addEventListener('abort', onAbort, { once: true })
|
|
@@ -498,17 +527,25 @@ type ChainStepParam = Static<typeof ChainItem>
|
|
|
498
527
|
type TaskItemParam = Static<typeof TaskItem>
|
|
499
528
|
|
|
500
529
|
/** The completion notice a background run sends when it finishes. */
|
|
501
|
-
export function backgroundCompletionText(run: { id: string; agent: string; state: string; turns: number; output?: string }): string {
|
|
530
|
+
export function backgroundCompletionText(run: { id: string; agent: string; state: string; turns: number; output?: string; stderr?: string }): string {
|
|
502
531
|
const output = capForContext(run.output ?? '') || '(no output)'
|
|
503
|
-
|
|
532
|
+
// A child that dies at boot writes its reason only to stderr; without this the
|
|
533
|
+
// notice reads "failed after 0 turns ... (no output)" with nothing to act on.
|
|
534
|
+
const diagnostics = run.state === 'failed' && run.stderr ? `\n\nstderr tail:\n${capForContext(run.stderr)}` : ''
|
|
535
|
+
return `Background subagent run ${run.id} (${run.agent}) ${run.state} after ${run.turns} turns.\n\n${output}${diagnostics}`
|
|
504
536
|
}
|
|
505
537
|
|
|
506
538
|
/** What to tell the model about a resume request. */
|
|
507
|
-
export function resumeResultText(id: string, task: string | undefined, onComplete: (run: { id: string; agent: string; state: string; turns: number; output?: string }) => void): string {
|
|
539
|
+
export function resumeResultText(id: string, task: string | undefined, onComplete: (run: { id: string; agent: string; state: string; turns: number; output?: string; stderr?: string }) => void, onResumed?: (run: { id: string; agent: string }) => void): string {
|
|
508
540
|
if (!task) return 'Pass task with resume: the follow-up needs an instruction.'
|
|
509
541
|
const outcome = resumeBackgroundRun(id, task, onComplete)
|
|
510
|
-
if (outcome === 'resumed')
|
|
542
|
+
if (outcome === 'resumed') {
|
|
543
|
+
const run = backgroundRun(id)
|
|
544
|
+
if (run) onResumed?.({ id: run.id, agent: run.agent })
|
|
545
|
+
return `Resumed background run ${id} with the follow-up task; a notification will arrive on completion.`
|
|
546
|
+
}
|
|
511
547
|
if (outcome === 'still-running') return `Background run ${id} is still running; wait for it or cancel it first.`
|
|
548
|
+
if (outcome === 'at-capacity') return `Background run cap reached (${MAX_BACKGROUND_RUNS} concurrent); wait for a run to finish before resuming ${id}.`
|
|
512
549
|
return `Unknown background run: ${id}.\n\n${backgroundStatusText()}`
|
|
513
550
|
}
|
|
514
551
|
|
|
@@ -530,6 +567,7 @@ interface ModeContext {
|
|
|
530
567
|
onPhase?: SubagentPhaseSink
|
|
531
568
|
skillRoots: string[]
|
|
532
569
|
availableModels: ReadonlyArray<{ id: string }>
|
|
570
|
+
projectApproved: boolean
|
|
533
571
|
}
|
|
534
572
|
|
|
535
573
|
async function checkProjectAgentGate(params: SubagentParamsStatic, agents: AgentConfig[], ctx: ExtensionContext, projectAgentsDir: string | null, gateMode: SubagentMode, makeDetails: MakeDetails): Promise<ToolResult | null> {
|
|
@@ -562,6 +600,100 @@ async function checkProjectAgentGate(params: SubagentParamsStatic, agents: Agent
|
|
|
562
600
|
return null
|
|
563
601
|
}
|
|
564
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
|
+
|
|
565
697
|
/** CLI args shared by foreground and background children, from the agent's config. */
|
|
566
698
|
function agentInvocationArgs(agent: AgentConfig, aliasModel?: string): string[] {
|
|
567
699
|
const args: string[] = ['--mode', 'json', '-p', '--no-session']
|
|
@@ -597,7 +729,7 @@ function removeTmpPrompt(tmpPrompt: { dir: string; filePath: string } | undefine
|
|
|
597
729
|
}
|
|
598
730
|
}
|
|
599
731
|
|
|
600
|
-
async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConfig[], defaultCwd: string, pi: ExtensionAPI, makeDetails: MakeDetails, skillRoots: string[], availableModels: ReadonlyArray<{ id: string }
|
|
732
|
+
async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConfig[], defaultCwd: string, pi: ExtensionAPI, makeDetails: MakeDetails, skillRoots: string[], availableModels: ReadonlyArray<{ id: string }>, projectApproved: boolean): Promise<ToolResult> {
|
|
601
733
|
const task = params.task
|
|
602
734
|
const agentName = params.agent
|
|
603
735
|
if (!task || !agentName) {
|
|
@@ -617,16 +749,20 @@ async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConf
|
|
|
617
749
|
if (activeBackgroundRuns() >= MAX_BACKGROUND_RUNS) {
|
|
618
750
|
return backgroundCapResult(makeDetails)
|
|
619
751
|
}
|
|
620
|
-
const
|
|
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))
|
|
621
757
|
let tmpPrompt: { dir: string; filePath: string } | undefined
|
|
622
|
-
const
|
|
623
|
-
if (
|
|
624
|
-
tmpPrompt = await writePromptToTempFile(agent.name,
|
|
758
|
+
const promptBody = childPromptBody(agent, skillRoots, memorySection)
|
|
759
|
+
if (promptBody.trim()) {
|
|
760
|
+
tmpPrompt = await writePromptToTempFile(agent.name, promptBody)
|
|
625
761
|
args.push('--append-system-prompt', tmpPrompt.filePath)
|
|
626
762
|
}
|
|
627
763
|
args.push(`Task: ${task}`)
|
|
628
764
|
const invocation = getPiInvocation(args)
|
|
629
|
-
const id = startBackgroundRun(agent.name, task, { command: invocation.command, args: invocation.args, cwd:
|
|
765
|
+
const id = startBackgroundRun(agent.name, task, { command: invocation.command, args: invocation.args, cwd: runCwd, promptBody: tmpPrompt ? promptBody : undefined, maxTurns: agent.maxTurns }, (run) => {
|
|
630
766
|
removeTmpPrompt(tmpPrompt)
|
|
631
767
|
// Both calls throw once the session that started the run is disposed; driveRun
|
|
632
768
|
// catches for the whole callback, so neither can escape into the child's close
|
|
@@ -684,6 +820,7 @@ async function runChainMode(chain: ChainStepParam[], mode: ModeContext): Promise
|
|
|
684
820
|
onPhase: mode.onPhase,
|
|
685
821
|
skillRoots: mode.skillRoots,
|
|
686
822
|
availableModels: mode.availableModels,
|
|
823
|
+
projectApproved: mode.projectApproved,
|
|
687
824
|
})
|
|
688
825
|
results.push(result)
|
|
689
826
|
|
|
@@ -756,6 +893,7 @@ async function runParallelMode(tasks: TaskItemParam[], mode: ModeContext): Promi
|
|
|
756
893
|
// preload and its model tier alias silently do nothing in parallel mode only.
|
|
757
894
|
skillRoots: mode.skillRoots,
|
|
758
895
|
availableModels: mode.availableModels,
|
|
896
|
+
projectApproved: mode.projectApproved,
|
|
759
897
|
// Per-task update callback
|
|
760
898
|
onUpdate: (partial) => {
|
|
761
899
|
const live = partial.details?.results[0]
|
|
@@ -805,6 +943,7 @@ async function runSingleMode(agentName: string, task: string, cwd: string | unde
|
|
|
805
943
|
onPhase: mode.onPhase,
|
|
806
944
|
skillRoots: mode.skillRoots,
|
|
807
945
|
availableModels: mode.availableModels,
|
|
946
|
+
projectApproved: mode.projectApproved,
|
|
808
947
|
})
|
|
809
948
|
const isError = result.exitCode !== 0 || result.stopReason === 'error' || result.stopReason === 'aborted'
|
|
810
949
|
if (isError) {
|
|
@@ -825,8 +964,8 @@ interface CallItem {
|
|
|
825
964
|
task: string
|
|
826
965
|
}
|
|
827
966
|
|
|
828
|
-
function renderChainCall(chain: CallItem[], scope: AgentScope, theme: Theme): Text {
|
|
829
|
-
let text = theme.fg('toolTitle', theme.bold('subagent ')) + theme.fg('accent', `chain (${chain.length} steps)`) +
|
|
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)
|
|
830
969
|
for (let i = 0; i < Math.min(chain.length, 3); i++) {
|
|
831
970
|
const step = chain[i]
|
|
832
971
|
// Clean up {previous} placeholder for display
|
|
@@ -843,8 +982,8 @@ function renderChainCall(chain: CallItem[], scope: AgentScope, theme: Theme): Te
|
|
|
843
982
|
return new Text(text, 0, 0)
|
|
844
983
|
}
|
|
845
984
|
|
|
846
|
-
function renderParallelCall(tasks: CallItem[], scope: AgentScope, theme: Theme): Text {
|
|
847
|
-
let text = theme.fg('toolTitle', theme.bold('subagent ')) + theme.fg('accent', `parallel (${tasks.length} tasks)`) +
|
|
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)
|
|
848
987
|
for (const t of tasks.slice(0, 3)) {
|
|
849
988
|
const preview = t.task.length > 40 ? `${t.task.slice(0, 40)}...` : t.task
|
|
850
989
|
const taskLabel = theme.fg('accent', t.agent) + theme.fg('dim', ` ${preview}`)
|
|
@@ -857,11 +996,13 @@ function renderParallelCall(tasks: CallItem[], scope: AgentScope, theme: Theme):
|
|
|
857
996
|
return new Text(text, 0, 0)
|
|
858
997
|
}
|
|
859
998
|
|
|
860
|
-
|
|
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 {
|
|
861
1002
|
const agentName = agent || '...'
|
|
862
1003
|
let preview = '...'
|
|
863
1004
|
if (task) preview = task.length > 60 ? `${task.slice(0, 60)}...` : task
|
|
864
|
-
let text = theme.fg('toolTitle', theme.bold('subagent ')) + theme.fg('accent', agentName) +
|
|
1005
|
+
let text = theme.fg('toolTitle', theme.bold('subagent ')) + theme.fg('accent', agentName) + scopeTag(scope, theme)
|
|
865
1006
|
text += `\n ${theme.fg('dim', preview)}`
|
|
866
1007
|
return new Text(text, 0, 0)
|
|
867
1008
|
}
|
|
@@ -1108,11 +1249,44 @@ function renderParallelResult(results: SingleResult[], expanded: boolean, theme:
|
|
|
1108
1249
|
}
|
|
1109
1250
|
|
|
1110
1251
|
export default function subagentExtension(pi: ExtensionAPI) {
|
|
1111
|
-
const notifyBackgroundCompletion = (run: { id: string; agent: string; state: string; turns: number; output?: string }): void => {
|
|
1252
|
+
const notifyBackgroundCompletion = (run: { id: string; agent: string; state: string; turns: number; output?: string; stderr?: string }): void => {
|
|
1112
1253
|
// Runs through driveRun's guard, same as the background-mode callback above.
|
|
1254
|
+
// The stop event fires here too, so SubagentStop hooks see resumed runs end.
|
|
1255
|
+
pi.events.emit(SUBAGENT_CHANNEL, { phase: 'stop', agentType: run.agent, agentId: run.id })
|
|
1113
1256
|
pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
|
|
1114
1257
|
}
|
|
1115
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
|
+
|
|
1116
1290
|
// Claude surfaces each agent's description so the model can pick one autonomously.
|
|
1117
1291
|
// Rebuilt per turn (agents are rediscovered per invocation too); project agents are
|
|
1118
1292
|
// included only when the project is already approved, read without prompting, since
|
|
@@ -1133,13 +1307,17 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1133
1307
|
'Delegate tasks to specialized subagents with isolated context.',
|
|
1134
1308
|
'Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).',
|
|
1135
1309
|
'Single mode also supports background: true for long tasks; a notification arrives on completion and {status: true} lists runs.',
|
|
1136
|
-
'
|
|
1137
|
-
'
|
|
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.',
|
|
1138
1312
|
].join(' '),
|
|
1139
1313
|
parameters: SubagentParams,
|
|
1140
1314
|
|
|
1141
1315
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
1142
|
-
|
|
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')
|
|
1143
1321
|
// Children carry PI_CODE_SUBAGENT; without this check they could spawn
|
|
1144
1322
|
// grandchildren without limit.
|
|
1145
1323
|
if (process.env.PI_CODE_SUBAGENT) {
|
|
@@ -1166,7 +1344,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1166
1344
|
})
|
|
1167
1345
|
|
|
1168
1346
|
if (params.resume) {
|
|
1169
|
-
return { content: [{ type: 'text', text: resumeResultText(params.resume, params.task, notifyBackgroundCompletion) }], details: makeDetails('single')([]) }
|
|
1347
|
+
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')([]) }
|
|
1170
1348
|
}
|
|
1171
1349
|
|
|
1172
1350
|
if (params.cancel) {
|
|
@@ -1197,16 +1375,19 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1197
1375
|
const gateResult = await checkProjectAgentGate(params, agents, ctx, discovery.projectAgentsDir, gateMode, makeDetails)
|
|
1198
1376
|
if (gateResult) return gateResult
|
|
1199
1377
|
|
|
1200
|
-
// Project skills only preload
|
|
1201
|
-
// gate the skills extension applies
|
|
1202
|
-
|
|
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)
|
|
1203
1384
|
// Tier aliases resolve against what this user is authenticated for; an
|
|
1204
1385
|
// unavailable tier still falls back to the session model.
|
|
1205
1386
|
const availableModels = ctx.modelRegistry?.getAvailable?.() ?? []
|
|
1206
1387
|
|
|
1207
|
-
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)
|
|
1208
1389
|
|
|
1209
|
-
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 }) }
|
|
1210
1391
|
|
|
1211
1392
|
if (params.chain?.length) return runChainMode(params.chain, mode)
|
|
1212
1393
|
if (params.tasks?.length) return runParallelMode(params.tasks, mode)
|
|
@@ -1220,7 +1401,9 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1220
1401
|
},
|
|
1221
1402
|
|
|
1222
1403
|
renderCall(args, theme, _context) {
|
|
1223
|
-
|
|
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
|
|
1224
1407
|
if (args.chain && args.chain.length > 0) return renderChainCall(args.chain, scope, theme)
|
|
1225
1408
|
if (args.tasks && args.tasks.length > 0) return renderParallelCall(args.tasks, scope, theme)
|
|
1226
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
|
-
|
|
80
|
-
|
|
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
|
|
|
@@ -194,6 +209,9 @@ async function fetchText(rawUrl: string, transport = httpFetch): Promise<{ text:
|
|
|
194
209
|
userAgent: USER_AGENT,
|
|
195
210
|
})
|
|
196
211
|
if (response.status >= 300 && response.status < 400) {
|
|
212
|
+
// The hop's body is never read; without the cancel its socket stays held
|
|
213
|
+
// until the 20s abort timeout, once per hop.
|
|
214
|
+
void response.body?.cancel().catch(() => {})
|
|
197
215
|
const location = response.headers.get('location')
|
|
198
216
|
if (!location) throw new Error(`redirect without location from ${url.hostname}`)
|
|
199
217
|
url = new URL(location, url)
|
|
@@ -202,13 +220,21 @@ async function fetchText(rawUrl: string, transport = httpFetch): Promise<{ text:
|
|
|
202
220
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error(`unsupported redirect scheme ${url.protocol} from ${rawUrl}`)
|
|
203
221
|
continue
|
|
204
222
|
}
|
|
205
|
-
if (!response.ok)
|
|
223
|
+
if (!response.ok) {
|
|
224
|
+
void response.body?.cancel().catch(() => {})
|
|
225
|
+
throw new Error(`HTTP ${response.status} for ${url}`)
|
|
226
|
+
}
|
|
206
227
|
return { text: await readCapped(response), contentType: response.headers.get('content-type') ?? '' }
|
|
207
228
|
}
|
|
208
229
|
throw new Error(`too many redirects for ${rawUrl}`)
|
|
209
230
|
}
|
|
210
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
|
+
|
|
211
236
|
export default function webExtension(pi: ExtensionAPI) {
|
|
237
|
+
const fetchCache = new Map<string, { expires: number; body: string }>()
|
|
212
238
|
pi.registerTool({
|
|
213
239
|
name: 'web_search',
|
|
214
240
|
label: 'Web search',
|
|
@@ -216,10 +242,14 @@ export default function webExtension(pi: ExtensionAPI) {
|
|
|
216
242
|
parameters: Type.Object({
|
|
217
243
|
query: Type.String({ description: 'Search query' }),
|
|
218
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' })),
|
|
219
247
|
}),
|
|
220
248
|
async execute(_id, params) {
|
|
249
|
+
// Claude documents allowed/blocked domains as mutually exclusive; allowed wins.
|
|
221
250
|
const { text } = await fetchText(SEARCH_ENDPOINT + encodeURIComponent(params.query))
|
|
222
|
-
const
|
|
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)
|
|
223
253
|
if (results.length === 0) {
|
|
224
254
|
return { content: [{ type: 'text' as const, text: 'No results found.' }], details: {} }
|
|
225
255
|
}
|
|
@@ -231,14 +261,55 @@ export default function webExtension(pi: ExtensionAPI) {
|
|
|
231
261
|
pi.registerTool({
|
|
232
262
|
name: 'web_fetch',
|
|
233
263
|
label: 'Web fetch',
|
|
234
|
-
description: 'Fetch a URL and return its content
|
|
235
|
-
parameters: Type.Object({
|
|
236
|
-
|
|
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) {
|
|
237
270
|
if (!/^https?:\/\//.test(params.url)) {
|
|
238
271
|
return { content: [{ type: 'text' as const, text: 'Only http(s) URLs are supported.' }], details: {} }
|
|
239
272
|
}
|
|
240
|
-
const
|
|
241
|
-
|
|
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
|
+
}
|
|
242
313
|
// The char cap alone admits thousands of short lines; pi's tool-output budget
|
|
243
314
|
// bounds lines too, which the shared guard enforces.
|
|
244
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.
|
|
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",
|