pi-code 1.0.60 → 1.0.62
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 +2 -0
- package/extensions/claude-rules.ts +9 -8
- package/extensions/commands.ts +45 -11
- package/extensions/env-settings.ts +19 -1
- package/extensions/hooks/claude-tools.ts +9 -7
- package/extensions/hooks/config.ts +20 -3
- package/extensions/hooks/index.ts +14 -3
- package/extensions/hooks/matcher.ts +36 -8
- package/extensions/hooks/runners.ts +36 -2
- package/extensions/internal/claude-tool-names.ts +70 -0
- package/extensions/internal/command-file.ts +50 -38
- package/extensions/internal/mcp-oauth.ts +16 -3
- package/extensions/internal/path-rules.ts +20 -5
- package/extensions/internal/plugins.ts +11 -0
- package/extensions/internal/project-approval.ts +8 -3
- package/extensions/internal/project-root.ts +45 -10
- package/extensions/internal/scope-rules.ts +117 -0
- package/extensions/internal/settings-chain.ts +33 -6
- package/extensions/internal/web-transport.ts +7 -0
- package/extensions/mcp/config.ts +5 -1
- package/extensions/mcp/index.ts +11 -5
- package/extensions/mcp/oauth-flow.ts +1 -1
- package/extensions/mcp/transport.ts +19 -3
- package/extensions/skills.ts +20 -2
- package/extensions/status-line.ts +8 -4
- package/extensions/subagent/index.ts +4 -2
- package/extensions/thinking.ts +14 -8
- package/extensions/web.ts +74 -21
- package/package.json +1 -1
|
@@ -64,7 +64,7 @@ export async function runInteractiveOAuth(name: string, config: { url: string; o
|
|
|
64
64
|
config.oauth,
|
|
65
65
|
config.url,
|
|
66
66
|
)
|
|
67
|
-
const { server, port } = await startCallbackServer(provider.savedRedirectPort())
|
|
67
|
+
const { server, port } = await startCallbackServer(provider.savedRedirectPort(), provider.configuredRedirectPort() !== undefined)
|
|
68
68
|
provider.bindRedirectPort(port)
|
|
69
69
|
try {
|
|
70
70
|
const transport = makeTransport(provider)
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { execFile } from 'node:child_process'
|
|
8
|
+
import * as fs from 'node:fs'
|
|
8
9
|
import { pathToFileURL } from 'node:url'
|
|
9
10
|
// SSE is deprecated in favour of Streamable HTTP, but the SDK notes servers still on
|
|
10
11
|
// the old spec exist, so this stays as a fallback for the migration period.
|
|
@@ -182,8 +183,8 @@ function helperEnv(name: string, config: HttpServerConfig): NodeJS.ProcessEnv {
|
|
|
182
183
|
}
|
|
183
184
|
|
|
184
185
|
/** The env a stdio server process starts with: the SDK allowlist, the config's own
|
|
185
|
-
* env block, and Claude's path variables
|
|
186
|
-
*
|
|
186
|
+
* env block, and Claude's three path variables. Claude: "All three are exported as
|
|
187
|
+
* environment variables to hook processes and to MCP and LSP server subprocesses." */
|
|
187
188
|
function stdioEnv(config: StdioServerConfig, fill: (value: string) => string, session?: SessionDirs): Record<string, string> {
|
|
188
189
|
// CLAUDECODE marks every subprocess; the long-lived server deliberately gets no
|
|
189
190
|
// CLAUDE_CODE_CHILD_SESSION, which Claude reserves for per-call children.
|
|
@@ -191,6 +192,16 @@ function stdioEnv(config: StdioServerConfig, fill: (value: string) => string, se
|
|
|
191
192
|
for (const [key, value] of Object.entries(config.env ?? {})) env[key] = fill(value)
|
|
192
193
|
if (session) env.CLAUDE_PROJECT_DIR = session.projectDir
|
|
193
194
|
if (config.pluginRoot !== undefined) env.CLAUDE_PLUGIN_ROOT = config.pluginRoot
|
|
195
|
+
// The data dir is "created on first reference"; handing the path to a server is that
|
|
196
|
+
// reference, so the server does not have to mkdir it before using it.
|
|
197
|
+
if (config.pluginDataDir !== undefined) {
|
|
198
|
+
env.CLAUDE_PLUGIN_DATA = config.pluginDataDir
|
|
199
|
+
try {
|
|
200
|
+
fs.mkdirSync(config.pluginDataDir, { recursive: true })
|
|
201
|
+
} catch {
|
|
202
|
+
// The server still starts; one that needs the directory reports its own failure.
|
|
203
|
+
}
|
|
204
|
+
}
|
|
194
205
|
return env
|
|
195
206
|
}
|
|
196
207
|
|
|
@@ -234,7 +245,12 @@ export async function connect(name: string, config: ServerConfig, authUi?: AuthU
|
|
|
234
245
|
await connectWithTimeout(client, transport, `connect ${name}`)
|
|
235
246
|
return client
|
|
236
247
|
}
|
|
237
|
-
|
|
248
|
+
// An absent or blank `url` is a server nobody finished configuring, not a malformed
|
|
249
|
+
// one. `new URL('')` throws "Invalid URL", which reads as a typo in a real address and
|
|
250
|
+
// sends people looking for one; name the actual state instead.
|
|
251
|
+
const rawUrl = fill(config.url ?? '').trim()
|
|
252
|
+
if (rawUrl === '') throw new Error(`${name} is not configured: it has no url`)
|
|
253
|
+
const url = new URL(rawUrl)
|
|
238
254
|
if (config.type === 'ws' || config.type === 'websocket') {
|
|
239
255
|
// The SDK's WebSocket transport takes only a url: it carries no headers, bearer
|
|
240
256
|
// token, or headersHelper output. Warn rather than silently dropping configured
|
package/extensions/skills.ts
CHANGED
|
@@ -39,6 +39,13 @@ import { errorMessage, isDirectory, isRecord } from './internal/values.js'
|
|
|
39
39
|
* directory is included only for approved projects: pi's loader surfaces every skill's
|
|
40
40
|
* name and description to the model, so an untrusted repository would otherwise get
|
|
41
41
|
* text into the prompt without the user ever agreeing to load its config. */
|
|
42
|
+
/** The extra skill directories a manifest declares, as a list. A string is one entry,
|
|
43
|
+
* a list is itself, anything else declares none. */
|
|
44
|
+
function declaredSkillDirs(declared: unknown): string[] {
|
|
45
|
+
if (Array.isArray(declared)) return declared.map(String)
|
|
46
|
+
return typeof declared === 'string' ? [declared] : []
|
|
47
|
+
}
|
|
48
|
+
|
|
42
49
|
export function skillDirs(cwd: string, home: string, trusted: boolean): string[] {
|
|
43
50
|
// Claude's precedence: enterprise (the skills directory beside the managed
|
|
44
51
|
// settings file) overrides personal, and personal overrides project; discovery
|
|
@@ -48,9 +55,20 @@ export function skillDirs(cwd: string, home: string, trusted: boolean): string[]
|
|
|
48
55
|
// skill by its directory, so a plugin skill registers without Claude's
|
|
49
56
|
// /plugin: prefix; a rename-free approximation, disclosed in the README.
|
|
50
57
|
for (const plugin of installedPlugins(home)) {
|
|
51
|
-
|
|
52
|
-
|
|
58
|
+
// Claude: "Adds to the default: `skills`. The default `skills/` directory is always
|
|
59
|
+
// scanned, and directories listed in `skills` are loaded alongside it." Treating the
|
|
60
|
+
// declaration as a replacement silently dropped every skill in the conventional
|
|
61
|
+
// location. (The reference's one exception, a marketplace entry whose source resolves
|
|
62
|
+
// to the marketplace root, is a marketplace shape pi-code does not model.)
|
|
63
|
+
const extra = declaredSkillDirs(plugin.manifest.skills)
|
|
64
|
+
const dirs = [...extra, 'skills']
|
|
53
65
|
candidates.push(...dirs.map((dir) => pluginComponentPath(plugin, String(dir))).filter((dir): dir is string => dir !== undefined))
|
|
66
|
+
// NOT SUPPORTED: Claude's single-skill layout, "a plugin that ships exactly one skill
|
|
67
|
+
// can place SKILL.md directly at the plugin root". skillPaths is handed to pi's own
|
|
68
|
+
// loader, which owns the layout and looks for <dir>/<name>/SKILL.md; adding the plugin
|
|
69
|
+
// root here does not surface root/SKILL.md and does start scanning every sibling
|
|
70
|
+
// directory (hooks/, agents/, commands/) for skills. Supporting it needs a loader that
|
|
71
|
+
// accepts a directory that IS the skill, which is pi's call, not this extension's.
|
|
54
72
|
}
|
|
55
73
|
// Claude loads skills from every .claude/skills between cwd and the repository
|
|
56
74
|
// root; the list goes nearest-first so findClaudeSkill's first match is the
|
|
@@ -36,7 +36,7 @@ import { claudeEffortLevel } from './internal/effort.js'
|
|
|
36
36
|
import { readManagedSettings } from './internal/managed-settings.js'
|
|
37
37
|
import { isPlanModeState, PLAN_MODE_CHANNEL } from './internal/plan-mode-state.js'
|
|
38
38
|
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
39
|
-
import { repoRoot } from './internal/project-root.js'
|
|
39
|
+
import { gitRoot, repoRoot } from './internal/project-root.js'
|
|
40
40
|
import { readSettingsChain } from './internal/settings-chain.js'
|
|
41
41
|
import { watchSettingsFiles } from './internal/settings-watch.js'
|
|
42
42
|
import { readActiveStyleName, settingsFiles } from './output-styles.js'
|
|
@@ -47,7 +47,9 @@ const DEBOUNCE_MS = 300
|
|
|
47
47
|
/** Whether cwd sits in a git worktree: `.git` is a file pointing at the main
|
|
48
48
|
* checkout there, and a directory in an ordinary clone. */
|
|
49
49
|
function isGitWorktree(cwd: string): boolean {
|
|
50
|
-
|
|
50
|
+
// gitRoot, not repoRoot: repoRoot resolves a worktree to its main checkout, which is
|
|
51
|
+
// exactly the thing this needs to see before it is resolved away.
|
|
52
|
+
const root = gitRoot(cwd)
|
|
51
53
|
if (root === undefined) return false
|
|
52
54
|
try {
|
|
53
55
|
return fs.statSync(path.join(root, '.git')).isFile()
|
|
@@ -350,8 +352,10 @@ export default function statusLine(pi: ExtensionAPI) {
|
|
|
350
352
|
// Everything below can touch ctx after an await, and every ctx getter throws
|
|
351
353
|
// once the session is disposed. This promise is started from a timer with no
|
|
352
354
|
// awaiter, so an escaping rejection becomes an uncaughtException and exits pi.
|
|
353
|
-
const result = await runHookCommand(config.command, buildPayload(ctx), COMMAND_TIMEOUT_MS,
|
|
354
|
-
|
|
355
|
+
const result = await runHookCommand(config.command, buildPayload(ctx), COMMAND_TIMEOUT_MS, {
|
|
356
|
+
onChild: (kill) => {
|
|
357
|
+
killInflight = kill
|
|
358
|
+
},
|
|
355
359
|
})
|
|
356
360
|
// Claude: "Your script can output multiple lines to create a richer display."
|
|
357
361
|
// pi has one row for every extension status and replaces newlines with spaces
|
|
@@ -56,8 +56,10 @@ export default function subagentExtension(pi: ExtensionAPI) {
|
|
|
56
56
|
|
|
57
57
|
const notifyBackgroundCompletion = (run: { id: string; agent: string; state: string; turns: number; output?: string; stderr?: string }): void => {
|
|
58
58
|
// Runs through driveRun's guard, same as the background-mode callback above.
|
|
59
|
-
// The stop event fires here too, so SubagentStop hooks see resumed runs end
|
|
60
|
-
|
|
59
|
+
// The stop event fires here too, so SubagentStop hooks see resumed runs end, and it
|
|
60
|
+
// carries the run's final assistant text: docs/subagents.md states SubagentStop
|
|
61
|
+
// receives last_assistant_message unconditionally, and a resumed run is no exception.
|
|
62
|
+
pi.events.emit(SUBAGENT_CHANNEL, { phase: 'stop', agentType: run.agent, agentId: run.id, lastAssistantMessage: run.output })
|
|
61
63
|
pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
|
|
62
64
|
}
|
|
63
65
|
|
package/extensions/thinking.ts
CHANGED
|
@@ -23,15 +23,21 @@ export function thinkingRank(level: ThinkingLevel): number {
|
|
|
23
23
|
return Math.max(i, 0)
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/** The reasoning level a prompt requests
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
26
|
+
/** The reasoning level a prompt requests, or undefined when it names none.
|
|
27
|
+
*
|
|
28
|
+
* `ultrathink` is the only keyword, per Claude: "Include `ultrathink` anywhere in your
|
|
29
|
+
* prompt to request deeper reasoning on that turn ... Claude Code passes other phrases
|
|
30
|
+
* such as 'think', 'think hard', and 'think more' through as ordinary prompt text and
|
|
31
|
+
* doesn't recognize them as keywords." Escalating on those surprised anyone who merely
|
|
32
|
+
* used the word in a sentence. Matched on a word boundary so `rethink` and `thinking`
|
|
33
|
+
* never trip it.
|
|
34
|
+
*
|
|
35
|
+
* Divergence: Claude adds an in-context instruction and leaves the API effort level
|
|
36
|
+
* unchanged. pi has no separate in-context channel for this, and its thinking level IS
|
|
37
|
+
* how deeper reasoning is requested, so the keyword raises the level for the turn and
|
|
38
|
+
* restores it after. Same intent, the only mechanism pi has. */
|
|
30
39
|
export function requestedThinkingLevel(text: string): ThinkingLevel | undefined {
|
|
31
|
-
|
|
32
|
-
if (/\bthink harder\b/i.test(text) || /\bthink hard\b/i.test(text)) return 'high'
|
|
33
|
-
if (/\bthink\b/i.test(text)) return 'medium'
|
|
34
|
-
return undefined
|
|
40
|
+
return /\bultrathink\b/i.test(text) ? 'max' : undefined
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
export default function thinkingExtension(pi: ExtensionAPI) {
|
package/extensions/web.ts
CHANGED
|
@@ -195,7 +195,45 @@ async function readCapped(response: Response): Promise<string> {
|
|
|
195
195
|
return text.slice(0, MAX_RAW_CHARS)
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
-
|
|
198
|
+
/** Either the page, or the cross-host redirect Claude reports instead of following. */
|
|
199
|
+
type FetchOutcome = { kind: 'body'; text: string; contentType: string } | { kind: 'redirect'; to: string }
|
|
200
|
+
|
|
201
|
+
/** Claude upgrades an http URL to https before fetching; the rest of the URL is
|
|
202
|
+
* untouched, so a caller's path, query and fragment survive. */
|
|
203
|
+
function upgradeToHttps(rawUrl: string): string {
|
|
204
|
+
const url = new URL(rawUrl)
|
|
205
|
+
if (url.protocol === 'http:') url.protocol = 'https:'
|
|
206
|
+
return url.href
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Cross-host redirect policy. `report` is WebFetch's documented behavior; `follow`
|
|
210
|
+
* keeps the old chase for the internal search fetch, whose endpoint is ours and whose
|
|
211
|
+
* hops Claude's WebFetch rule does not describe. Stated at every call site rather than
|
|
212
|
+
* defaulted, so a new caller has to decide which it wants. */
|
|
213
|
+
type CrossHost = 'follow' | 'report'
|
|
214
|
+
|
|
215
|
+
/** The next hop of a redirect, or the naming result that ends the fetch. Split from
|
|
216
|
+
* fetchText so the loop reads as request-then-hop; every guard the hop needs lives
|
|
217
|
+
* here. */
|
|
218
|
+
function redirectTarget(response: Response, url: URL, rawUrl: string, crossHost: CrossHost): { kind: 'next'; next: URL } | { kind: 'redirect'; to: string } {
|
|
219
|
+
// The hop's body is never read; without the cancel its socket stays held
|
|
220
|
+
// until the 20s abort timeout, once per hop.
|
|
221
|
+
void response.body?.cancel().catch(() => {})
|
|
222
|
+
const location = response.headers.get('location')
|
|
223
|
+
if (!location) throw new Error(`redirect without location from ${url.hostname}`)
|
|
224
|
+
const next = new URL(location, url)
|
|
225
|
+
// Only the caller's URL was scheme-checked; a redirect could hand back data: or
|
|
226
|
+
// file:, which carry no host for the address guard to inspect.
|
|
227
|
+
if (next.protocol !== 'http:' && next.protocol !== 'https:') throw new Error(`unsupported redirect scheme ${next.protocol} from ${rawUrl}`)
|
|
228
|
+
// Claude: "When a URL redirects to a different host, WebFetch returns a text
|
|
229
|
+
// result that names the original URL and the redirect target instead of following
|
|
230
|
+
// it." The target is never requested, so the per-hop address guard never sees it
|
|
231
|
+
// and never needs to: a cross-host hop cannot reach anything at all from here.
|
|
232
|
+
if (crossHost === 'report' && next.hostname.toLowerCase() !== url.hostname.toLowerCase()) return { kind: 'redirect', to: next.href }
|
|
233
|
+
return { kind: 'next', next }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function fetchText(rawUrl: string, crossHost: CrossHost, transport = httpFetch): Promise<FetchOutcome> {
|
|
199
237
|
let url = new URL(rawUrl)
|
|
200
238
|
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
201
239
|
// Resolve, validate and pin per hop: a redirect target gets the same guarantee.
|
|
@@ -206,28 +244,32 @@ async function fetchText(rawUrl: string, transport = httpFetch): Promise<{ text:
|
|
|
206
244
|
userAgent: USER_AGENT,
|
|
207
245
|
})
|
|
208
246
|
if (response.status >= 300 && response.status < 400) {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const location = response.headers.get('location')
|
|
213
|
-
if (!location) throw new Error(`redirect without location from ${url.hostname}`)
|
|
214
|
-
url = new URL(location, url)
|
|
215
|
-
// Only the caller's URL was scheme-checked; a redirect could hand back data: or
|
|
216
|
-
// file:, which carry no host for the address guard to inspect.
|
|
217
|
-
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error(`unsupported redirect scheme ${url.protocol} from ${rawUrl}`)
|
|
247
|
+
const hop = redirectTarget(response, url, rawUrl, crossHost)
|
|
248
|
+
if (hop.kind === 'redirect') return hop
|
|
249
|
+
url = hop.next
|
|
218
250
|
continue
|
|
219
251
|
}
|
|
220
252
|
if (!response.ok) {
|
|
221
253
|
void response.body?.cancel().catch(() => {})
|
|
222
254
|
throw new Error(`HTTP ${response.status} for ${url}`)
|
|
223
255
|
}
|
|
224
|
-
return { text: await readCapped(response), contentType: response.headers.get('content-type') ?? '' }
|
|
256
|
+
return { kind: 'body', text: await readCapped(response), contentType: response.headers.get('content-type') ?? '' }
|
|
225
257
|
}
|
|
226
258
|
throw new Error(`too many redirects for ${rawUrl}`)
|
|
227
259
|
}
|
|
228
260
|
|
|
229
|
-
/** Claude documents a 15-minute per-URL cache for WebFetch
|
|
230
|
-
|
|
261
|
+
/** Claude documents a 15-minute per-URL cache for WebFetch, overridable by env:
|
|
262
|
+
* "set CLAUDE_CODE_WEBFETCH_CACHE_TTL_MS to change how long WebFetch keeps each
|
|
263
|
+
* response". Read per store so a change takes effect without a restart; an unset,
|
|
264
|
+
* blank, negative or non-numeric value keeps the documented default. */
|
|
265
|
+
const FETCH_CACHE_TTL_DEFAULT_MS = 15 * 60 * 1000
|
|
266
|
+
|
|
267
|
+
function fetchCacheTtlMs(): number {
|
|
268
|
+
const raw = process.env.CLAUDE_CODE_WEBFETCH_CACHE_TTL_MS
|
|
269
|
+
if (raw === undefined || raw.trim() === '') return FETCH_CACHE_TTL_DEFAULT_MS
|
|
270
|
+
const parsed = Number(raw)
|
|
271
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : FETCH_CACHE_TTL_DEFAULT_MS
|
|
272
|
+
}
|
|
231
273
|
const FETCH_CACHE_MAX_ENTRIES = 50
|
|
232
274
|
|
|
233
275
|
type FetchCache = Map<string, { expires: number; body: string }>
|
|
@@ -245,7 +287,7 @@ function rememberFetch(cache: FetchCache, url: string, body: string, now: number
|
|
|
245
287
|
const oldest = cache.keys().next().value
|
|
246
288
|
if (oldest !== undefined) cache.delete(oldest)
|
|
247
289
|
}
|
|
248
|
-
cache.set(url, { expires: now +
|
|
290
|
+
cache.set(url, { expires: now + fetchCacheTtlMs(), body })
|
|
249
291
|
}
|
|
250
292
|
|
|
251
293
|
/** Claude's WebFetch runs the prompt over the page with a fast model and returns
|
|
@@ -278,7 +320,8 @@ export default function webExtension(pi: ExtensionAPI) {
|
|
|
278
320
|
}),
|
|
279
321
|
async execute(_id, params) {
|
|
280
322
|
// Claude documents allowed/blocked domains as mutually exclusive; allowed wins.
|
|
281
|
-
const
|
|
323
|
+
const outcome = await fetchText(SEARCH_ENDPOINT + encodeURIComponent(params.query), 'follow')
|
|
324
|
+
const text = outcome.kind === 'body' ? outcome.text : ''
|
|
282
325
|
const limit = Math.min(params.count ?? 5, 10)
|
|
283
326
|
const results = filterByDomain(parseSearchResults(text, 10), params.allowed_domains, params.blocked_domains).slice(0, limit)
|
|
284
327
|
if (results.length === 0) {
|
|
@@ -292,7 +335,8 @@ export default function webExtension(pi: ExtensionAPI) {
|
|
|
292
335
|
pi.registerTool({
|
|
293
336
|
name: 'web_fetch',
|
|
294
337
|
label: 'Web fetch',
|
|
295
|
-
description:
|
|
338
|
+
description:
|
|
339
|
+
'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. http URLs are upgraded to https, a redirect to a different host is reported rather than followed, and responses are cached per URL for 15 minutes by default (CLAUDE_CODE_WEBFETCH_CACHE_TTL_MS).',
|
|
296
340
|
parameters: Type.Object({
|
|
297
341
|
url: Type.String({ description: 'Absolute http(s) URL to fetch' }),
|
|
298
342
|
prompt: Type.Optional(Type.String({ description: 'What to extract or answer from the page; returns the model’s answer instead of the raw markdown' })),
|
|
@@ -301,17 +345,26 @@ export default function webExtension(pi: ExtensionAPI) {
|
|
|
301
345
|
if (!/^https?:\/\//.test(params.url)) {
|
|
302
346
|
return { content: [{ type: 'text' as const, text: 'Only http(s) URLs are supported.' }], details: {} }
|
|
303
347
|
}
|
|
348
|
+
// Claude: "HTTP URLs are automatically upgraded to HTTPS." Upgrading here rather
|
|
349
|
+
// than inside the fetch keeps one spelling for the cache key and the prompt text
|
|
350
|
+
// too, so http:// and https:// of the same page share a single entry.
|
|
351
|
+
const target = upgradeToHttps(params.url)
|
|
304
352
|
const now = Date.now()
|
|
305
353
|
// The cache holds the raw markdown, so a second fetch with a different prompt
|
|
306
354
|
// still reuses it: retrieval and the optional prompt step are separate.
|
|
307
|
-
const cached = fetchCache.get(
|
|
355
|
+
const cached = fetchCache.get(target)
|
|
308
356
|
let body: string
|
|
309
357
|
if (cached && cached.expires > now) {
|
|
310
358
|
body = cached.body
|
|
311
359
|
} else {
|
|
312
|
-
const
|
|
313
|
-
body
|
|
314
|
-
|
|
360
|
+
const outcome = await fetchText(target, 'report')
|
|
361
|
+
// A cross-host redirect has no body to cache or summarize: the naming result is
|
|
362
|
+
// the answer, and Claude fetches the target with a second call if it wants it.
|
|
363
|
+
if (outcome.kind === 'redirect') {
|
|
364
|
+
return { content: [{ type: 'text' as const, text: `${target} redirects to a different host: ${outcome.to}\nFetch ${outcome.to} directly to read it.` }], details: {} }
|
|
365
|
+
}
|
|
366
|
+
body = capFetchChars(outcome.contentType.includes('html') ? htmlToMarkdown(outcome.text) : outcome.text)
|
|
367
|
+
rememberFetch(fetchCache, target, body, now)
|
|
315
368
|
}
|
|
316
369
|
|
|
317
370
|
// Best-effort prompt-over-page: a failure returns null, so web_fetch always
|
|
@@ -319,7 +372,7 @@ export default function webExtension(pi: ExtensionAPI) {
|
|
|
319
372
|
// usage rides on the result either way, so pi counts it in session totals.
|
|
320
373
|
let usage: Usage | undefined
|
|
321
374
|
if (params.prompt && ctx?.model) {
|
|
322
|
-
const answer = await answerFromPage(ctx.model, params.prompt,
|
|
375
|
+
const answer = await answerFromPage(ctx.model, params.prompt, target, body, signal)
|
|
323
376
|
if (answer?.text) return { content: [{ type: 'text' as const, text: answer.text }], details: {}, usage: answer.usage }
|
|
324
377
|
// An empty answer still cost the completion; the fallback carries its usage.
|
|
325
378
|
usage = answer?.usage
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.62",
|
|
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, subagents, and goals",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|