pi-code 1.0.75 → 1.0.76

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 CHANGED
@@ -19,7 +19,7 @@ What a repository ships is treated as untrusted until you approve it: project MC
19
19
 
20
20
  ## Requirements
21
21
 
22
- pi `>=0.79.1` (0.84.x recommended) and Node `>=22.19` for current pi.
22
+ pi `>=0.80.4` (0.84.x recommended) and Node `>=22.19` for current pi.
23
23
 
24
24
  ## Install
25
25
 
@@ -554,7 +554,8 @@ export default function commandsExtension(pi: ExtensionAPI) {
554
554
  }
555
555
 
556
556
  pi.on('session_start', async (_event, ctx) => {
557
- // One extension instance serves every session. A mid-turn /new fires session_start on
557
+ // pi's CLI builds a fresh extension instance per session replacement; only RPC mode can
558
+ // reuse one across sessions. A mid-turn /new there fires session_start on
558
559
  // the same instance while a command's per-run scoping is still pending (its agent_settled
559
560
  // never came). Carrying that into the next session would restore an unrelated tool set,
560
561
  // bash/path scope, model, or effort onto it, so drop the pending state here. Drop only:
@@ -172,6 +172,9 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
172
172
  let promptedRun = false
173
173
  let shadowDir: string | undefined
174
174
  let workTree: string | undefined
175
+ // Set in ensureShadow: whether the live session has no session file (--no-session),
176
+ // whose shadow repo session_shutdown then knows is safe to remove on a real quit.
177
+ let ephemeralShadow = false
175
178
  // Absolute paths this session's edit tools targeted: the whole of what a checkpoint
176
179
  // captures. Seeded on resume from the last commit, so a resumed session keeps
177
180
  // snapshotting the files it was already tracking.
@@ -191,6 +194,7 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
191
194
  async function ensureShadow(ctx: ExtensionContext): Promise<void> {
192
195
  workTree = ctx.cwd
193
196
  const sessionFile = (ctx.sessionManager as { getSessionFile?: () => string | undefined }).getSessionFile?.()
197
+ ephemeralShadow = sessionFile === undefined
194
198
  const checkpointsRoot = path.join(getAgentDir(), 'checkpoints')
195
199
  shadowDir = path.join(checkpointsRoot, sessionSlug(sessionFile))
196
200
  // A resumed session can arrive from a different directory than the one the shadow
@@ -454,7 +458,8 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
454
458
  }
455
459
 
456
460
  pi.on('session_start', async (event, ctx) => {
457
- // One extension instance serves every session. A mid-turn /new fires session_start on
461
+ // pi's CLI builds a fresh extension instance per session replacement; only RPC mode can
462
+ // reuse one across sessions. A mid-turn /new there fires session_start on
458
463
  // the same instance after turn_start took the pre-run snapshot but before turn_end saved
459
464
  // it; that pending ref belongs to the previous session and must not attach to the next
460
465
  // session's first turn_end. Re-arm runNeedsSnapshot too, so the next run snapshots its
@@ -480,6 +485,17 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
480
485
  for (const checkpoint of capCheckpoints(stored)) checkpoints.set(checkpoint.entryId, checkpoint)
481
486
  })
482
487
 
488
+ // A --no-session run has no session file, so nothing can ever resume it or run
489
+ // /rewind from it again once the process exits: its shadow repo, left in place, was
490
+ // pure waste for the 30 days until the retention sweep reached it. Only a genuine
491
+ // quit removes it eagerly; 'new', 'resume' and 'reload' keep the process (and this
492
+ // extension instance) alive, and 'fork' can write a session from the live run's
493
+ // in-memory entries and then fetch refs from exactly this shadow at its own
494
+ // session_start, so this one case is left for the retention sweep as before.
495
+ pi.on('session_shutdown', async (event) => {
496
+ if (ephemeralShadow && shadowDir && event.reason === 'quit') fs.rmSync(shadowDir, { recursive: true, force: true })
497
+ })
498
+
483
499
  // A new agent loop starts a run: the next turn_start snapshots the pre-run tree.
484
500
  // agent_start, not before_agent_start: before_agent_start does not fire for a queued
485
501
  // follow-up message delivered through agent.continue, so gating on it would leave that
@@ -446,7 +446,8 @@ export default function goalExtension(pi: ExtensionAPI) {
446
446
  })
447
447
 
448
448
  pi.on('session_start', (_event, ctx) => {
449
- // One extension instance serves every session: drop the previous session's goal and
449
+ // pi's CLI builds a fresh extension instance per session replacement; RPC mode can reuse
450
+ // one across sessions, so drop the previous session's goal and
450
451
  // timers before reading this session's persisted state.
451
452
  sessionCtx = ctx
452
453
  goal = undefined
@@ -446,7 +446,8 @@ export default function hooksExtension(pi: ExtensionAPI) {
446
446
 
447
447
  pi.on('session_start', async (event, ctx) => {
448
448
  sessionCtx = ctx
449
- // One extension instance serves every session. A mid-turn /new fires session_start on
449
+ // pi's CLI builds a fresh extension instance per session replacement; only RPC mode can
450
+ // reuse one across sessions. A mid-turn /new there fires session_start on
450
451
  // the same instance while a Stop-hook continuation streak is in flight; it must not
451
452
  // carry into the next session, so reset before any early return (disableAllHooks below).
452
453
  stopHookActive = false
@@ -58,30 +58,125 @@ const trimNewlines = (value: string): string => {
58
58
  return value.slice(start, end)
59
59
  }
60
60
 
61
+ /** Every match of the global regex `re` in `html`, as `{start, end}` spans. One linear
62
+ * scan: `matchAll` resumes after each match rather than restarting the search. */
63
+ function matchSpans(html: string, re: RegExp): Array<{ start: number; end: number }> {
64
+ return [...html.matchAll(re)].map((m) => ({ start: m.index, end: m.index + m[0].length }))
65
+ }
66
+
67
+ /**
68
+ * Replace every `open...close` span with `transform(open, body)`. `closeSource(open)`
69
+ * gives the close pattern's regex source for this particular open (a fixed literal for
70
+ * most callers; a backreference to `open[1]` for a shared tag family like
71
+ * script|style|noscript, so each open pairs only with its own tag name). An open with no
72
+ * reachable close is left as literal text, the same as a non-matching `[\s\S]*?` regex
73
+ * would leave it.
74
+ *
75
+ * Both the opens and each distinct close pattern are found with one bounded, linear scan
76
+ * (`openRe`'s attrs never cross a `<`/`>`, and neither does a close tag's), then paired by
77
+ * a single forward walk with a cursor per close pattern that only advances. A page that
78
+ * repeats one unclosed tag thousands of times used to cost one rescan to the end of the
79
+ * document per occurrence (O(n^2) for the lazy `[\s\S]*?<\/tag>` shape this replaces);
80
+ * this costs one pass.
81
+ */
82
+ function replaceTagSpans(html: string, openRe: RegExp, closeSource: (open: RegExpMatchArray) => string, transform: (open: RegExpMatchArray, body: string) => string): string {
83
+ const opens = [...html.matchAll(openRe)]
84
+ if (opens.length === 0) return html
85
+
86
+ const closeSpans = new Map<string, Array<{ start: number; end: number }>>()
87
+ const closeCursor = new Map<string, number>()
88
+
89
+ let out = ''
90
+ let cursor = 0
91
+ for (const open of opens) {
92
+ const openStart = open.index ?? 0
93
+ if (openStart < cursor) continue // inside a span an earlier open of this pass already consumed
94
+ const source = closeSource(open)
95
+ if (!closeSpans.has(source)) {
96
+ closeSpans.set(source, matchSpans(html, new RegExp(source, 'gi')))
97
+ closeCursor.set(source, 0)
98
+ }
99
+ const spans = closeSpans.get(source) as Array<{ start: number; end: number }>
100
+ const openEnd = openStart + open[0].length
101
+ let idx = closeCursor.get(source) as number
102
+ while (idx < spans.length && spans[idx].start < openEnd) idx++
103
+ closeCursor.set(source, idx)
104
+ if (idx >= spans.length) continue // no close anywhere after this open: leave it as text
105
+ out += html.slice(cursor, openStart) + transform(open, html.slice(openEnd, spans[idx].start))
106
+ cursor = spans[idx].end
107
+ }
108
+ return out + html.slice(cursor)
109
+ }
110
+
61
111
  export function htmlToMarkdown(html: string): string {
62
112
  // Pre blocks are lifted out first so no later transform touches their content.
63
113
  const preBodies: string[] = []
64
- let work = html
65
- .replace(/<!--[\s\S]*?-->/g, ' ')
66
- .replace(/<(script|style|noscript|head|svg)\b[^<>]*>[\s\S]*?<\/\1[^<>]*>/gi, ' ')
67
- .replace(/<pre\b[^<>]*>([\s\S]*?)<\/pre>/gi, (_whole, inner: string) => {
68
- preBodies.push(trimNewlines(decodeAllEntities(removeTags(inner))))
114
+ // Each of these bodies can legitimately hold anything up to and including another `<`, so
115
+ // the body itself cannot be bounded like an open tag's attrs; replaceTagSpans keeps the
116
+ // pass linear instead by pairing opens and closes in one pass rather than rescanning the
117
+ // document from every open that turns out to have no close (a broken template or a fetch
118
+ // truncated mid-tag repeats that shape often enough to matter).
119
+ let work = replaceTagSpans(
120
+ html,
121
+ /<!--/g,
122
+ () => '-->',
123
+ () => ' ',
124
+ )
125
+ work = replaceTagSpans(
126
+ work,
127
+ /<(script|style|noscript|head|svg)\b[^<>]*>/gi,
128
+ (open) => `</${open[1]}[^<>]*>`,
129
+ () => ' ',
130
+ )
131
+ work = replaceTagSpans(
132
+ work,
133
+ /<pre\b[^<>]*>/gi,
134
+ () => '</pre[^<>]*>',
135
+ (_open, body) => {
136
+ preBodies.push(trimNewlines(decodeAllEntities(removeTags(body))))
69
137
  return `\n\n\uE000PRE${preBodies.length - 1}\uE000\n\n`
70
- })
138
+ },
139
+ )
71
140
 
72
- work = work
73
- .replace(/<code\b[^<>]*>([\s\S]*?)<\/code>/gi, (_whole, inner: string) => `\`${removeTags(inner)}\``)
74
- // Only real web links become markdown links; fragment and javascript hrefs
75
- // keep their label and lose the target.
76
- .replace(/<a\b[^<>]*?href=(?:"([^"]*)"|'([^']*)')[^<>]*>([\s\S]*?)<\/a>/gi, (_whole, dq: string | undefined, sq: string | undefined, inner: string) => {
77
- const href = decodeAllEntities(dq ?? sq ?? '')
78
- const label = removeTags(inner).trim()
141
+ work = replaceTagSpans(
142
+ work,
143
+ /<code\b[^<>]*>/gi,
144
+ () => '</code[^<>]*>',
145
+ (_open, body) => `\`${removeTags(body)}\``,
146
+ )
147
+ // Only real web links become markdown links; fragment and javascript hrefs
148
+ // keep their label and lose the target.
149
+ work = replaceTagSpans(
150
+ work,
151
+ /<a\b[^<>]*?href=(?:"([^"]*)"|'([^']*)')[^<>]*>/gi,
152
+ () => '</a[^<>]*>',
153
+ (open, body) => {
154
+ const href = decodeAllEntities(open[1] ?? open[2] ?? '')
155
+ const label = removeTags(body).trim()
79
156
  if (!label) return ' '
80
157
  return /^https?:\/\//i.test(href) ? `[${label}](${href})` : label
81
- })
82
- .replace(/<(strong|b)\b[^<>]*>([\s\S]*?)<\/\1>/gi, (_whole, _tag, inner: string) => `**${removeTags(inner).trim()}**`)
83
- .replace(/<(em|i)\b[^<>]*>([\s\S]*?)<\/\1>/gi, (_whole, _tag, inner: string) => `*${removeTags(inner).trim()}*`)
84
- .replace(/<h([1-6])\b[^<>]*>([\s\S]*?)<\/h\1>/gi, (_whole, level: string, inner: string) => `\n\n${'#'.repeat(Number(level))} ${removeTags(inner).trim()}\n\n`)
158
+ },
159
+ )
160
+ work = replaceTagSpans(
161
+ work,
162
+ /<(strong|b)\b[^<>]*>/gi,
163
+ (open) => `</${open[1]}[^<>]*>`,
164
+ (_open, body) => `**${removeTags(body).trim()}**`,
165
+ )
166
+ work = replaceTagSpans(
167
+ work,
168
+ /<(em|i)\b[^<>]*>/gi,
169
+ (open) => `</${open[1]}[^<>]*>`,
170
+ (_open, body) => `*${removeTags(body).trim()}*`,
171
+ )
172
+ work = replaceTagSpans(
173
+ work,
174
+ /<h([1-6])\b[^<>]*>/gi,
175
+ (open) => `</h${open[1]}[^<>]*>`,
176
+ (open, body) => `\n\n${'#'.repeat(Number(open[1]))} ${removeTags(body).trim()}\n\n`,
177
+ )
178
+
179
+ work = work
85
180
  .replace(/<img\b[^<>]*?alt=(?:"([^"]*)"|'([^']*)')[^<>]*>/gi, (_whole, dq?: string, sq?: string) => dq ?? sq ?? '')
86
181
  .replace(/<li\b[^<>]*>/gi, '\n- ')
87
182
  .replace(/<blockquote\b[^<>]*>/gi, '\n\n> ')
@@ -86,7 +86,11 @@ function notifyWindows(title: string, body: string): void {
86
86
  }
87
87
 
88
88
  function notifyDesktop(title: string, body: string): void {
89
- if (process.env.WT_SESSION) {
89
+ // Windows Terminal sets WT_SESSION for a WSL session it hosts too, but WSL is a Linux
90
+ // process: notifyWindows's fixed C:\Windows\... path is a Windows path and cannot
91
+ // resolve there, so PowerShell never actually launched and no toast ever fired. The
92
+ // escape-sequence fallback below travels over the same pty either way.
93
+ if (process.env.WT_SESSION && process.platform === 'win32') {
90
94
  notifyWindows(title, body)
91
95
  } else if (process.env.KITTY_WINDOW_ID) {
92
96
  notifyOSC99(title, body)
@@ -100,25 +104,44 @@ export default function notifyExtension(pi: ExtensionAPI) {
100
104
  // When the user last submitted a prompt, so a turn's duration can stand in for
101
105
  // Claude's "appear to be away" check. Undefined until the first prompt this session.
102
106
  let lastInputAt: number | undefined
107
+ // Set by agent_end, consumed and cleared by agent_settled. agent_end alone cannot
108
+ // tell a genuine "done, waiting for you" end from one an automatic retry, a /goal
109
+ // continuation, or a compaction is about to follow with no user involved: each of
110
+ // those fires its own agent_end too, with nobody ever actually waiting until the
111
+ // last one. agent_settled ("no automatic retry, compaction, or queued continuation
112
+ // will run") is that signal, but only firing there would delay the common, single-
113
+ // turn case behind a peer extension's agent_end handler blocking on a UI dialog
114
+ // (plan mode); capturing state at agent_end and only acting on it once agent_settled
115
+ // confirms this was the final step keeps both properties.
116
+ let pending = false
117
+ // Whether the run's own last assistant message ended with stopReason: 'aborted', i.e.
118
+ // the user pressed Esc: they are at the keyboard by definition, whatever isAway's
119
+ // timer-based guess would otherwise say.
120
+ let lastAborted = false
103
121
 
104
122
  pi.on('session_start', async (_event, _ctx) => {
105
123
  channel = resolveNotifChannel(readPreferredNotifChannel(os.homedir()))
106
124
  lastInputAt = undefined
125
+ pending = false
107
126
  })
108
127
 
109
- pi.on('input', async () => {
128
+ pi.on('input', async (event) => {
129
+ // A goal continuation or a subagent's own prompt is not the user; only their own
130
+ // input is evidence they are at the keyboard (mirroring goal.ts's own check).
131
+ if (event.source === 'extension') return
110
132
  lastInputAt = Date.now()
111
133
  })
112
134
 
113
- // Fires on agent_end rather than agent_settled deliberately: agent_settled is only
114
- // emitted after every agent_end handler returns, and a peer extension (plan mode)
115
- // blocks its agent_end handler on a UI dialog, which would starve this notification
116
- // exactly when the user has stepped away. agent_end can fire slightly early before a
117
- // rare automatic retry or compaction, which is a better failure than never notifying.
118
- pi.on('agent_end', async () => {
119
- if (channel === 'off') return
120
- // Piped or headless stdout (pi -p, CI) must not receive raw escape bytes.
121
- if (!process.stdout.isTTY) return
135
+ pi.on('agent_end', async (event) => {
136
+ const last = [...event.messages].reverse().find((message) => message.role === 'assistant')
137
+ lastAborted = last?.stopReason === 'aborted'
138
+ pending = channel !== 'off' && process.stdout.isTTY === true
139
+ })
140
+
141
+ pi.on('agent_settled', async () => {
142
+ if (!pending) return
143
+ pending = false
144
+ if (lastAborted) return
122
145
  if (!isAway(lastInputAt, Date.now(), AWAY_AFTER_MS)) return
123
146
  if (channel === 'bell') {
124
147
  process.stdout.write('\x07')
@@ -426,7 +426,8 @@ After completing a step, include a [DONE:n] tag in your response.`,
426
426
 
427
427
  // Restore state on session start/resume
428
428
  pi.on('session_start', async (_event, ctx) => {
429
- // One extension instance serves every session, so clear prior state first: a fresh
429
+ // pi's CLI builds a fresh extension instance per session replacement; RPC mode can reuse
430
+ // one across sessions, so clear prior state first there too: a fresh
430
431
  // session (/new, no plan entry) must not inherit the last session's plan or execution.
431
432
  planModeEnabled = false
432
433
  executionMode = false
@@ -8,10 +8,12 @@
8
8
  * (pi changelog); a separate ctx.ui.setTitle call would only duplicate that, so there is none.
9
9
  *
10
10
  * It runs in every mode, not just the TUI: naming a session is cheap and harmless, and a
11
- * headless run that persists its session still benefits from a readable name later. Titling
12
- * is best-effort throughout: a session that already has a name, a run with no user text (a
13
- * slash-command-only turn), a headless run with no model, or any provider error leaves the
14
- * session untitled and never throws.
11
+ * headless run that persists its session still benefits from a readable name later. The one
12
+ * exception is a subagent child (PI_CODE_SUBAGENT=1): its session is never browsed by name,
13
+ * so the call is skipped outright rather than spending a model round trip nobody sees.
14
+ * Titling is best-effort throughout: a session that already has a name, a run with no user
15
+ * text (a slash-command-only turn), a headless run with no model, or any provider error
16
+ * leaves the session untitled and never throws.
15
17
  *
16
18
  * Cost: one model call per session at most. The guard is claimed before the completion so
17
19
  * repeated settles cannot each fire a call, and a failed attempt is not retried until a
@@ -98,6 +100,10 @@ export default function sessionTitleExtension(pi: ExtensionAPI) {
98
100
  // small/fast-model request that generates the session title." setSessionName is pi's
99
101
  // only title sink, so skipping the call here skips both effects at once.
100
102
  if (process.env.CLAUDE_CODE_DISABLE_TERMINAL_TITLE === '1') return
103
+ // A subagent child's session (--no-session, or its own --session-dir once it persists
104
+ // one for a resumable follow-up) is never browsed by name in a session picker: the
105
+ // model call would only add latency and cost to how soon the child can exit.
106
+ if (process.env.PI_CODE_SUBAGENT === '1') return
101
107
  if (titled) return
102
108
  // Never clobber an existing name: a user-chosen or resumed name wins.
103
109
  if (pi.getSessionName?.()) return
@@ -56,7 +56,8 @@ export default function thinkingExtension(pi: ExtensionAPI) {
56
56
  })
57
57
 
58
58
  pi.on('session_start', () => {
59
- // One extension instance serves every session. A mid-turn /new fires session_start on
59
+ // pi's CLI builds a fresh extension instance per session replacement; only RPC mode can
60
+ // reuse one across sessions. A mid-turn /new there fires session_start on
60
61
  // the same instance while an escalation is still pending (its agent_settled never came),
61
62
  // and that stale restore must be dropped rather than fired into the next session, whose
62
63
  // level the new session owns. Drop only: do NOT setThinkingLevel here.
package/extensions/web.ts CHANGED
@@ -178,11 +178,28 @@ async function resolveAndPin(url: URL): Promise<LookupFunction> {
178
178
 
179
179
  const MAX_REDIRECTS = 5
180
180
 
181
- /** Read a response body up to MAX_RAW_CHARS, then stop the download. Bounds memory and parsing cost. */
181
+ /** The charset a content-type header declares, or 'utf-8' when it names none. */
182
+ function declaredCharset(contentType: string): string {
183
+ const match = /charset=(?:"([^"]*)"|'([^']*)'|([^;\s]*))/i.exec(contentType)
184
+ return (match?.[1] ?? match?.[2] ?? match?.[3] ?? '').trim() || 'utf-8'
185
+ }
186
+
187
+ /** A decoder for `contentType`'s declared charset, or the platform default (UTF-8) for a
188
+ * label TextDecoder does not recognize: a bad or made-up charset must not fail the fetch. */
189
+ function decoderFor(contentType: string): TextDecoder {
190
+ try {
191
+ return new TextDecoder(declaredCharset(contentType))
192
+ } catch {
193
+ return new TextDecoder()
194
+ }
195
+ }
196
+
197
+ /** Read a response body up to MAX_RAW_CHARS, decoded as the content-type header's charset
198
+ * (UTF-8 when it names none), then stop the download. Bounds memory and parsing cost. */
182
199
  async function readCapped(response: Response): Promise<string> {
200
+ const decoder = decoderFor(response.headers.get('content-type') ?? '')
183
201
  const reader = response.body?.getReader()
184
- if (!reader) return (await response.text()).slice(0, MAX_RAW_CHARS)
185
- const decoder = new TextDecoder()
202
+ if (!reader) return decoder.decode(await response.arrayBuffer()).slice(0, MAX_RAW_CHARS)
186
203
  let text = ''
187
204
  while (text.length < MAX_RAW_CHARS) {
188
205
  const { done, value } = await reader.read()
@@ -234,13 +251,23 @@ function redirectTarget(response: Response, url: URL, rawUrl: string, crossHost:
234
251
  return { kind: 'next', next }
235
252
  }
236
253
 
237
- async function fetchText(rawUrl: string, crossHost: CrossHost, transport = httpFetch): Promise<FetchOutcome> {
254
+ /** The per-hop timeout, combined with the caller's own signal (the tool call's, fired on
255
+ * Esc) when there is one: cancelling must not lose the ceiling that keeps a silently
256
+ * hanging host from holding the turn forever, but Esc must not have to wait for it either. */
257
+ function hopSignal(signal: AbortSignal | undefined): AbortSignal {
258
+ const timeout = AbortSignal.timeout(FETCH_TIMEOUT_MS)
259
+ return signal ? AbortSignal.any([signal, timeout]) : timeout
260
+ }
261
+
262
+ async function fetchText(rawUrl: string, crossHost: CrossHost, signal?: AbortSignal, transport = httpFetch): Promise<FetchOutcome> {
238
263
  let url = new URL(rawUrl)
239
264
  for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
265
+ // Cancelled between hops (a redirect chain), not just mid-request.
266
+ signal?.throwIfAborted()
240
267
  // Resolve, validate and pin per hop: a redirect target gets the same guarantee.
241
268
  const lookup = await resolveAndPin(url)
242
269
  const response = await transport(url, {
243
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
270
+ signal: hopSignal(signal),
244
271
  lookup,
245
272
  userAgent: USER_AGENT,
246
273
  })
@@ -317,9 +344,9 @@ export default function webExtension(pi: ExtensionAPI) {
317
344
  allowed_domains: Type.Optional(Type.Array(Type.String(), { description: 'Only include results from these domains' })),
318
345
  blocked_domains: Type.Optional(Type.Array(Type.String(), { description: 'Exclude results from these domains' })),
319
346
  }),
320
- async execute(_id, params) {
347
+ async execute(_id, params, signal) {
321
348
  // Claude documents allowed/blocked domains as mutually exclusive; allowed wins.
322
- const outcome = await fetchText(SEARCH_ENDPOINT + encodeURIComponent(params.query), 'follow')
349
+ const outcome = await fetchText(SEARCH_ENDPOINT + encodeURIComponent(params.query), 'follow', signal)
323
350
  const text = outcome.kind === 'body' ? outcome.text : ''
324
351
  const limit = Math.min(params.count ?? 5, 10)
325
352
  const results = filterByDomain(parseSearchResults(text, 10), params.allowed_domains, params.blocked_domains).slice(0, limit)
@@ -356,7 +383,7 @@ export default function webExtension(pi: ExtensionAPI) {
356
383
  if (cached && cached.expires > now) {
357
384
  body = cached.body
358
385
  } else {
359
- const outcome = await fetchText(target, 'report')
386
+ const outcome = await fetchText(target, 'report', signal)
360
387
  // A cross-host redirect has no body to cache or summarize: the naming result is
361
388
  // the answer, and Claude fetches the target with a second call if it wants it.
362
389
  if (outcome.kind === 'redirect') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.75",
3
+ "version": "1.0.76",
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",
@@ -57,9 +57,9 @@
57
57
  "typebox": "^1.3.6"
58
58
  },
59
59
  "peerDependencies": {
60
- "@earendil-works/pi-ai": ">=0.79.1",
61
- "@earendil-works/pi-coding-agent": ">=0.79.1",
62
- "@earendil-works/pi-tui": ">=0.79.1"
60
+ "@earendil-works/pi-ai": ">=0.80.4",
61
+ "@earendil-works/pi-coding-agent": ">=0.80.4",
62
+ "@earendil-works/pi-tui": ">=0.80.4"
63
63
  },
64
64
  "devDependencies": {
65
65
  "@biomejs/biome": "^2.5.4",