pi-code 1.0.61 → 1.0.63

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.
@@ -6,11 +6,12 @@
6
6
  * worktree; everything about how the child was configured lives in child.ts.
7
7
  */
8
8
 
9
- import { spawn } from 'node:child_process'
9
+ import { type ChildProcessByStdio, spawn } from 'node:child_process'
10
10
  import { randomUUID } from 'node:crypto'
11
11
  import * as fs from 'node:fs'
12
12
  import * as os from 'node:os'
13
13
  import * as path from 'node:path'
14
+ import type { Readable } from 'node:stream'
14
15
 
15
16
  import type { AgentToolResult } from '@earendil-works/pi-agent-core'
16
17
  import type { Message } from '@earendil-works/pi-ai'
@@ -123,6 +124,29 @@ function appendPartialNote(result: SingleResult): void {
123
124
  if (result.partial) appendResultNote(result, '[Output is partial: the subagent stopped at its maxTurns limit.]')
124
125
  }
125
126
 
127
+ /** The pi child, or the error spawning it threw synchronously. Node normally
128
+ * reports a spawn failure through the child's async 'error' event, but some
129
+ * failures (confirmed on Linux: posix_spawn detects E2BIG immediately) throw at
130
+ * the spawn() call site instead; catching it here, in one place with an explicit
131
+ * return type, keeps the caller's non-null stdout/stderr narrowing that a bare
132
+ * try/catch around an inline spawn() call loses. */
133
+ function spawnChild(command: string, args: string[], options: { cwd: string; env: NodeJS.ProcessEnv }): { proc: ChildProcessByStdio<null, Readable, Readable> } | { error: Error } {
134
+ try {
135
+ return {
136
+ proc: spawn(command, args, {
137
+ ...options,
138
+ shell: false,
139
+ stdio: ['ignore', 'pipe', 'pipe'],
140
+ // Its own group, so an abort reaches grandchildren too: killing only the
141
+ // direct child orphans a build or dev server the agent started.
142
+ detached: true,
143
+ }),
144
+ }
145
+ } catch (error) {
146
+ return { error: error as Error }
147
+ }
148
+ }
149
+
126
150
  export async function runSingleAgent(options: RunAgentOptions): Promise<SingleResult> {
127
151
  const agent = options.agents.find((a) => a.name === options.agentName)
128
152
  if (!agent) return runSingleAgentInner(options)
@@ -244,16 +268,17 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
244
268
 
245
269
  const exitCode = await new Promise<number>((resolve) => {
246
270
  const invocation = getPiInvocation(args)
247
- const proc = spawn(invocation.command, invocation.args, {
271
+ const spawned = spawnChild(invocation.command, invocation.args, {
248
272
  cwd: worktree?.dir ?? runCwd,
249
- shell: false,
250
- stdio: ['ignore', 'pipe', 'pipe'],
251
- // Its own group, so an abort reaches grandchildren too: killing only the
252
- // direct child orphans a build or dev server the agent started.
253
- detached: true,
254
273
  // The marker lets the child's subagent tool refuse to nest further.
255
274
  env: { ...process.env, PI_CODE_SUBAGENT: '1', ...agentHooksEnv(agent, options.agentId ?? '') },
256
275
  })
276
+ if ('error' in spawned) {
277
+ if (!currentResult.stderr) currentResult.stderr = spawned.error.message
278
+ resolve(1)
279
+ return
280
+ }
281
+ const proc = spawned.proc
257
282
  let buffer = ''
258
283
  let assistantTurns = 0
259
284
 
@@ -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 through Claude's think keywords, or undefined
27
- * when it names none. Checked most-specific first so `think harder` does not fall
28
- * through to the bare-`think` branch, and on word boundaries so `rethink`/`thinking`
29
- * and the whole word `ultrathink` never trip the bare match. */
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
- if (/\bultrathink\b/i.test(text)) return 'max'
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
- async function fetchText(rawUrl: string, transport = httpFetch): Promise<{ text: string; contentType: string }> {
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
- // The hop's body is never read; without the cancel its socket stays held
210
- // until the 20s abort timeout, once per hop.
211
- void response.body?.cancel().catch(() => {})
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
- const FETCH_CACHE_TTL_MS = 15 * 60 * 1000
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 + FETCH_CACHE_TTL_MS, body })
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 { text } = await fetchText(SEARCH_ENDPOINT + encodeURIComponent(params.query))
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: '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.',
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(params.url)
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 { text, contentType } = await fetchText(params.url)
313
- body = capFetchChars(contentType.includes('html') ? htmlToMarkdown(text) : text)
314
- rememberFetch(fetchCache, params.url, body, now)
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, params.url, body, signal)
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.61",
3
+ "version": "1.0.63",
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",