pi-code 1.0.5 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/extensions/commands.ts +303 -85
- package/extensions/context-imports.ts +162 -91
- package/extensions/git-checkpoint.ts +40 -0
- package/extensions/hooks.ts +203 -36
- package/extensions/internal/command-file.ts +91 -48
- package/extensions/internal/html-markdown.ts +11 -1
- package/extensions/internal/instruction-events.ts +2 -2
- package/extensions/internal/managed-settings.ts +1 -1
- package/extensions/internal/mcp-oauth.ts +44 -8
- package/extensions/internal/path-rules.ts +69 -2
- package/extensions/internal/plugins.ts +29 -16
- package/extensions/internal/strip-comments.ts +56 -33
- package/extensions/mcp.ts +462 -84
- package/extensions/memory.ts +29 -19
- package/extensions/notify.ts +1 -2
- package/extensions/status-line.ts +8 -3
- package/extensions/subagent/background.ts +11 -0
- package/extensions/subagent/index.ts +119 -5
- package/extensions/web.ts +39 -26
- package/package.json +1 -1
package/extensions/memory.ts
CHANGED
|
@@ -159,6 +159,33 @@ export function saveMemory(dir: string, indexPath: string, name: string | undefi
|
|
|
159
159
|
return { content: [{ type: 'text', text: `Saved memory ${name}.` }], details: {} }
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/** The read action: a memory's body, capped for context, or a not-found message. */
|
|
163
|
+
function readMemory(dir: string, name: string): { content: Array<{ type: 'text'; text: string }>; details: Record<string, never> } {
|
|
164
|
+
try {
|
|
165
|
+
const body = fs.readFileSync(path.join(dir, `${name}.md`), 'utf-8')
|
|
166
|
+
return { content: [{ type: 'text', text: capForContext(body) }], details: {} }
|
|
167
|
+
} catch {
|
|
168
|
+
return { content: [{ type: 'text', text: `No memory named ${name}.` }], details: {} }
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The delete action: remove a memory file and its index line. The index is read
|
|
173
|
+
* before anything is removed: refusing on a failed read must leave both the memory
|
|
174
|
+
* file and the index as they were. */
|
|
175
|
+
function deleteMemory(dir: string, indexPath: string, name: string): { content: Array<{ type: 'text'; text: string }>; details: Record<string, never> } {
|
|
176
|
+
let index: string
|
|
177
|
+
try {
|
|
178
|
+
index = readIndex(dir)
|
|
179
|
+
} catch (error) {
|
|
180
|
+
return { content: [{ type: 'text', text: `Memory delete failed: ${error instanceof Error ? error.message : String(error)}. Nothing was deleted.` }], details: {} }
|
|
181
|
+
}
|
|
182
|
+
fs.rmSync(path.join(dir, `${name}.md`), { force: true })
|
|
183
|
+
const remaining = removeIndexLine(index, name)
|
|
184
|
+
if (remaining) writeIndex(indexPath, remaining)
|
|
185
|
+
else fs.rmSync(indexPath, { force: true })
|
|
186
|
+
return { content: [{ type: 'text', text: `Deleted memory ${name}.` }], details: {} }
|
|
187
|
+
}
|
|
188
|
+
|
|
162
189
|
/** The index as injected into the prompt, bounded like Claude's startup load. */
|
|
163
190
|
export function capIndexForPrompt(index: string): string {
|
|
164
191
|
const loaded = stripNonLoaded(index)
|
|
@@ -327,29 +354,12 @@ export default function memoryExtension(pi: ExtensionAPI) {
|
|
|
327
354
|
|
|
328
355
|
if (params.action === 'read') {
|
|
329
356
|
if (!name) return { content: [{ type: 'text' as const, text: 'read requires name.' }], details: {} }
|
|
330
|
-
|
|
331
|
-
const body = fs.readFileSync(path.join(dir, `${name}.md`), 'utf-8')
|
|
332
|
-
return { content: [{ type: 'text' as const, text: capForContext(body) }], details: {} }
|
|
333
|
-
} catch {
|
|
334
|
-
return { content: [{ type: 'text' as const, text: `No memory named ${name}.` }], details: {} }
|
|
335
|
-
}
|
|
357
|
+
return readMemory(dir, name)
|
|
336
358
|
}
|
|
337
359
|
|
|
338
360
|
if (params.action === 'delete') {
|
|
339
361
|
if (!name) return { content: [{ type: 'text' as const, text: 'delete requires name.' }], details: {} }
|
|
340
|
-
|
|
341
|
-
// must leave both the memory file and the index as they were.
|
|
342
|
-
let index: string
|
|
343
|
-
try {
|
|
344
|
-
index = readIndex(dir)
|
|
345
|
-
} catch (error) {
|
|
346
|
-
return { content: [{ type: 'text' as const, text: `Memory delete failed: ${error instanceof Error ? error.message : String(error)}. Nothing was deleted.` }], details: {} }
|
|
347
|
-
}
|
|
348
|
-
fs.rmSync(path.join(dir, `${name}.md`), { force: true })
|
|
349
|
-
const remaining = removeIndexLine(index, name)
|
|
350
|
-
if (remaining) writeIndex(indexPath, remaining)
|
|
351
|
-
else fs.rmSync(indexPath, { force: true })
|
|
352
|
-
return { content: [{ type: 'text' as const, text: `Deleted memory ${name}.` }], details: {} }
|
|
362
|
+
return deleteMemory(dir, indexPath, name)
|
|
353
363
|
}
|
|
354
364
|
|
|
355
365
|
const index = readIndexQuietly(dir)
|
package/extensions/notify.ts
CHANGED
|
@@ -104,10 +104,9 @@ export default function notifyExtension(pi: ExtensionAPI) {
|
|
|
104
104
|
// Claude's "appear to be away" check. Undefined until the first prompt this session.
|
|
105
105
|
let lastInputAt: number | undefined
|
|
106
106
|
|
|
107
|
-
pi.on('session_start', async (_event,
|
|
107
|
+
pi.on('session_start', async (_event, _ctx) => {
|
|
108
108
|
channel = resolveNotifChannel(readPreferredNotifChannel(os.homedir()))
|
|
109
109
|
lastInputAt = undefined
|
|
110
|
-
void ctx
|
|
111
110
|
})
|
|
112
111
|
|
|
113
112
|
pi.on('input', async () => {
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* analogue, off the shared bus), and on the optional `refreshInterval` timer
|
|
10
10
|
* (minimum 1s). A project-defined command is arbitrary shell, so project settings
|
|
11
11
|
* count only once the project is already approved, read without prompting.
|
|
12
|
+
* Claude's `disableAllHooks` setting turns the configured command off too, and
|
|
13
|
+
* the built-in segment stands in.
|
|
12
14
|
*
|
|
13
15
|
* Without a configured statusLine, the built-in segment shows turn state plus
|
|
14
16
|
* running session cost, summed from per-message usage on the current branch so it
|
|
@@ -24,7 +26,7 @@ import * as os from 'node:os'
|
|
|
24
26
|
import * as path from 'node:path'
|
|
25
27
|
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
26
28
|
|
|
27
|
-
import { hookFiles, runHookCommand } from './hooks.js'
|
|
29
|
+
import { hookFiles, readDisableAllHooks, runHookCommand } from './hooks.js'
|
|
28
30
|
import { isPlanModeState, PLAN_MODE_CHANNEL } from './internal/plan-mode-state.js'
|
|
29
31
|
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
30
32
|
import { readActiveStyleName, settingsFiles } from './output-styles.js'
|
|
@@ -250,7 +252,7 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
250
252
|
})
|
|
251
253
|
// The last message's token usage, for the breakdown getContextUsage() omits.
|
|
252
254
|
pi.on('message_end', async (event) => {
|
|
253
|
-
const usage = (event as { message?: { usage?: typeof lastUsage } }).message?.usage
|
|
255
|
+
const usage = (event as { message?: { usage?: NonNullable<typeof lastUsage> } }).message?.usage
|
|
254
256
|
if (usage) lastUsage = usage
|
|
255
257
|
})
|
|
256
258
|
|
|
@@ -271,7 +273,10 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
271
273
|
// the keys meant for it. An undecided project simply skips project settings.
|
|
272
274
|
const trusted = isProjectApprovedSilently(ctx)
|
|
273
275
|
projectApproved = trusted
|
|
274
|
-
|
|
276
|
+
const files = hookFiles(ctx.cwd, os.homedir(), trusted)
|
|
277
|
+
// Claude's disableAllHooks also turns off the custom statusLine command; the
|
|
278
|
+
// built-in segment still renders as the fallback.
|
|
279
|
+
config = readDisableAllHooks(files) ? undefined : readStatusLineConfig(files)
|
|
275
280
|
if (config?.refreshInterval) {
|
|
276
281
|
refreshTimer = setInterval(() => scheduleRefresh(), config.refreshInterval * 1000)
|
|
277
282
|
}
|
|
@@ -26,6 +26,10 @@ export interface BackgroundRun {
|
|
|
26
26
|
/** True until the child process actually closes: a cancelled child that ignores
|
|
27
27
|
* SIGTERM is still alive and must keep holding its concurrency slot. */
|
|
28
28
|
live?: boolean
|
|
29
|
+
/** Monotonic finish order, stamped when the run completes. Eviction drops the
|
|
30
|
+
* earliest-finished runs by this, not Map insertion (start) order: a long run
|
|
31
|
+
* started first but finished last must not vanish the instant it completes. */
|
|
32
|
+
finishedAt?: number
|
|
29
33
|
/** pi session the child ran under, so a follow-up can continue its context. */
|
|
30
34
|
sessionId: string
|
|
31
35
|
/** How the child was spawned, so a follow-up can repeat it with a new task. */
|
|
@@ -63,8 +67,13 @@ export function activeBackgroundRuns(): number {
|
|
|
63
67
|
return [...runs.values()].filter((run) => run.live || run.state === 'running').length
|
|
64
68
|
}
|
|
65
69
|
|
|
70
|
+
/** Stamps BackgroundRun.finishedAt; a counter rather than a clock so two runs
|
|
71
|
+
* completing in the same millisecond still evict in their true finish order. */
|
|
72
|
+
let finishSequence = 0
|
|
73
|
+
|
|
66
74
|
function evictFinishedRuns(): void {
|
|
67
75
|
const finished = [...runs.values()].filter((run) => !run.live && run.state !== 'running')
|
|
76
|
+
finished.sort((a, b) => (a.finishedAt ?? 0) - (b.finishedAt ?? 0))
|
|
68
77
|
for (const stale of finished.slice(0, Math.max(0, finished.length - MAX_FINISHED_RUNS))) runs.delete(stale.id)
|
|
69
78
|
}
|
|
70
79
|
|
|
@@ -160,6 +169,7 @@ export function resumeBackgroundRun(id: string, task: string, onComplete: (run:
|
|
|
160
169
|
run.output = undefined
|
|
161
170
|
run.exitCode = undefined
|
|
162
171
|
run.stderr = undefined
|
|
172
|
+
run.finishedAt = undefined
|
|
163
173
|
driveRun(run, { ...run.spawn, args }, onComplete)
|
|
164
174
|
return 'resumed'
|
|
165
175
|
}
|
|
@@ -256,6 +266,7 @@ function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (
|
|
|
256
266
|
const complete = (): void => {
|
|
257
267
|
if (completed) return
|
|
258
268
|
completed = true
|
|
269
|
+
run.finishedAt = ++finishSequence
|
|
259
270
|
evictFinishedRuns()
|
|
260
271
|
// A run outlives the session that started it, and pi's loader wires assertActive()
|
|
261
272
|
// into every runtime call, so notifying a disposed session throws. This fires from
|
|
@@ -30,8 +30,8 @@ import { repoRoot } from '../internal/project-root.js'
|
|
|
30
30
|
import { SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
|
|
31
31
|
import { autoMemoryEnabled, capIndexForPrompt, INDEX_MAX_BYTES, INDEX_MAX_LINES, memorySettingsFiles, readMemorySettings } from '../memory.js'
|
|
32
32
|
import { skillDirs } from '../skills.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'
|
|
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'
|
|
35
35
|
|
|
36
36
|
const MAX_PARALLEL_TASKS = 8
|
|
37
37
|
const MAX_CONCURRENCY = 4
|
|
@@ -557,6 +557,67 @@ export function cancelResultText(id: string): string {
|
|
|
557
557
|
return `Unknown background run: ${id}.\n\n${backgroundStatusText()}`
|
|
558
558
|
}
|
|
559
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
|
+
|
|
560
621
|
/** Everything a mode handler needs from the surrounding execute() call. */
|
|
561
622
|
interface ModeContext {
|
|
562
623
|
agents: AgentConfig[]
|
|
@@ -729,7 +790,20 @@ function removeTmpPrompt(tmpPrompt: { dir: string; filePath: string } | undefine
|
|
|
729
790
|
}
|
|
730
791
|
}
|
|
731
792
|
|
|
732
|
-
|
|
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
|
|
733
807
|
const task = params.task
|
|
734
808
|
const agentName = params.agent
|
|
735
809
|
if (!task || !agentName) {
|
|
@@ -775,6 +849,7 @@ async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConf
|
|
|
775
849
|
removeTmpPrompt(tmpPrompt)
|
|
776
850
|
return backgroundCapResult(makeDetails)
|
|
777
851
|
}
|
|
852
|
+
onStarted?.(id)
|
|
778
853
|
pi.events.emit(SUBAGENT_CHANNEL, { phase: 'start', agentType: agent.name, agentId: id })
|
|
779
854
|
return {
|
|
780
855
|
content: [{ type: 'text', text: `Started background run ${id} (${agent.name}). A notification will arrive on completion; check progress with {status: true}.` }],
|
|
@@ -1249,6 +1324,21 @@ function renderParallelResult(results: SingleResult[], expanded: boolean, theme:
|
|
|
1249
1324
|
}
|
|
1250
1325
|
|
|
1251
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
|
+
|
|
1252
1342
|
const notifyBackgroundCompletion = (run: { id: string; agent: string; state: string; turns: number; output?: string; stderr?: string }): void => {
|
|
1253
1343
|
// Runs through driveRun's guard, same as the background-mode callback above.
|
|
1254
1344
|
// The stop event fires here too, so SubagentStop hooks see resumed runs end.
|
|
@@ -1344,7 +1434,11 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1344
1434
|
})
|
|
1345
1435
|
|
|
1346
1436
|
if (params.resume) {
|
|
1347
|
-
|
|
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')([]) }
|
|
1348
1442
|
}
|
|
1349
1443
|
|
|
1350
1444
|
if (params.cancel) {
|
|
@@ -1385,7 +1479,7 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1385
1479
|
// unavailable tier still falls back to the session model.
|
|
1386
1480
|
const availableModels = ctx.modelRegistry?.getAvailable?.() ?? []
|
|
1387
1481
|
|
|
1388
|
-
if (params.background) return runBackgroundMode(params, agents, ctx.cwd, pi, makeDetails, skillRoots, availableModels, projectApproved)
|
|
1482
|
+
if (params.background) return runBackgroundMode(params, { agents, defaultCwd: ctx.cwd, pi, makeDetails, skillRoots, availableModels, projectApproved }, (id) => rememberBackgroundRun(id))
|
|
1389
1483
|
|
|
1390
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 }) }
|
|
1391
1485
|
|
|
@@ -1426,4 +1520,24 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
1426
1520
|
return new Text(text?.type === 'text' ? text.text : '(no output)', 0, 0)
|
|
1427
1521
|
},
|
|
1428
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
|
+
})
|
|
1429
1543
|
}
|
package/extensions/web.ts
CHANGED
|
@@ -233,6 +233,40 @@ async function fetchText(rawUrl: string, transport = httpFetch): Promise<{ text:
|
|
|
233
233
|
const FETCH_CACHE_TTL_MS = 15 * 60 * 1000
|
|
234
234
|
const FETCH_CACHE_MAX_ENTRIES = 50
|
|
235
235
|
|
|
236
|
+
type FetchCache = Map<string, { expires: number; body: string }>
|
|
237
|
+
|
|
238
|
+
/** Store a freshly fetched body. Drop expired entries first, then evict the oldest
|
|
239
|
+
* live one if still full; deleting before set keeps Map insertion order a true
|
|
240
|
+
* recency order, so a refreshed URL moves to the newest slot instead of keeping its
|
|
241
|
+
* stale one. Only a delivered body reaches here, so a thrown fetch retries next call. */
|
|
242
|
+
function rememberFetch(cache: FetchCache, url: string, body: string, now: number): void {
|
|
243
|
+
cache.delete(url)
|
|
244
|
+
for (const [key, entry] of cache) {
|
|
245
|
+
if (entry.expires <= now) cache.delete(key)
|
|
246
|
+
}
|
|
247
|
+
if (cache.size >= FETCH_CACHE_MAX_ENTRIES) {
|
|
248
|
+
const oldest = cache.keys().next().value
|
|
249
|
+
if (oldest !== undefined) cache.delete(oldest)
|
|
250
|
+
}
|
|
251
|
+
cache.set(url, { expires: now + FETCH_CACHE_TTL_MS, body })
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Claude's WebFetch runs the prompt over the page with a fast model and returns
|
|
255
|
+
* that answer, not the raw page. Best-effort: any failure (no model, provider error)
|
|
256
|
+
* yields null so the caller falls back to the markdown. */
|
|
257
|
+
async function answerFromPage(model: Parameters<typeof completeText>[0], prompt: string, url: string, body: string, signal?: AbortSignal): Promise<string | null> {
|
|
258
|
+
try {
|
|
259
|
+
const answer = await completeText(model, `${prompt}\n\nAnswer using only the page content below, fetched from ${url}:\n\n${body}`, {
|
|
260
|
+
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.',
|
|
261
|
+
maxTokens: 1024,
|
|
262
|
+
signal,
|
|
263
|
+
})
|
|
264
|
+
return answer || null
|
|
265
|
+
} catch {
|
|
266
|
+
return null
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
236
270
|
export default function webExtension(pi: ExtensionAPI) {
|
|
237
271
|
const fetchCache = new Map<string, { expires: number; body: string }>()
|
|
238
272
|
pi.registerTool({
|
|
@@ -280,35 +314,14 @@ export default function webExtension(pi: ExtensionAPI) {
|
|
|
280
314
|
} else {
|
|
281
315
|
const { text, contentType } = await fetchText(params.url)
|
|
282
316
|
body = capFetchChars(contentType.includes('html') ? htmlToMarkdown(text) : text)
|
|
283
|
-
|
|
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 })
|
|
317
|
+
rememberFetch(fetchCache, params.url, body, now)
|
|
296
318
|
}
|
|
297
319
|
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
// error) falls back to the markdown, so web_fetch always returns something.
|
|
320
|
+
// Best-effort prompt-over-page: a failure returns null, so web_fetch always
|
|
321
|
+
// falls back to the raw markdown and returns something.
|
|
301
322
|
if (params.prompt && ctx?.model) {
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
}
|
|
323
|
+
const answer = await answerFromPage(ctx.model, params.prompt, params.url, body, signal)
|
|
324
|
+
if (answer) return { content: [{ type: 'text' as const, text: answer }], details: {} }
|
|
312
325
|
}
|
|
313
326
|
// The char cap alone admits thousands of short lines; pi's tool-output budget
|
|
314
327
|
// bounds lines too, which the shared guard enforces.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
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",
|