pi-code 1.0.74 → 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
 
@@ -289,6 +289,9 @@ export default function claudeRulesExtension(pi: ExtensionAPI) {
289
289
  // Scoped rules still awaiting a matching touch. An attached rule leaves the
290
290
  // list, so each attaches at most once and the per-tool-result scan shrinks.
291
291
  let attachTargets: AttachTarget[] = []
292
+ // Every scoped rule of the session, the list attachTargets is rebuilt from when what was
293
+ // attached leaves the context (see rearm below).
294
+ let scopedTargets: AttachTarget[] = []
292
295
 
293
296
  pi.on('session_start', async (_event, ctx) => {
294
297
  // Project rules are repository text landing in the system prompt, so they load
@@ -318,6 +321,7 @@ export default function claudeRulesExtension(pi: ExtensionAPI) {
318
321
  ...globalRules.scoped.map((rule) => ({ globs: rule.paths, compiled: compileGlobs(rule.paths), body: rule.body, root: realpathOr(ctx.cwd), file: path.join(globalRulesDir, rule.rel), memoryType: 'User' as const })),
319
322
  ...projectRules.scoped.map((rule) => ({ globs: rule.paths, compiled: compileGlobs(rule.paths), body: rule.body, root: realpathOr(projectRoot), file: path.join(projectRulesDir ?? path.join(ctx.cwd, '.claude', 'rules'), rule.rel), memoryType: 'Project' as const })),
320
323
  ]
324
+ scopedTargets = attachTargets
321
325
  pendingScopedRules = attachTargets.length
322
326
  // Relative to cwd, which the read tool resolves: an ancestor dir yields a
323
327
  // `../…/.claude/rules` the model can follow, where a bare '.claude/rules'
@@ -340,6 +344,16 @@ export default function claudeRulesExtension(pi: ExtensionAPI) {
340
344
  return { systemPrompt: event.systemPrompt + addition }
341
345
  })
342
346
 
347
+ // A rule's body sits in the tool result that attached it. Compaction folds that result into
348
+ // a summary and /tree moves to a branch that never had it, and Claude reloads the rule "as
349
+ // Claude reads files they apply to", so every rule is armed again.
350
+ const rearm = (): void => {
351
+ attachTargets = scopedTargets
352
+ pendingScopedRules = attachTargets.length
353
+ }
354
+ pi.on('session_compact', rearm)
355
+ pi.on('session_tree', rearm)
356
+
343
357
  // Lazy attach: when a file tool touches a path a scoped rule covers, append the
344
358
  // rule body to that tool's result so it enters context, once per rule per session.
345
359
  // This mirrors Claude Code, which attaches a scoped rule when a matching file is
@@ -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:
@@ -145,12 +145,15 @@ export interface ImportBudget {
145
145
  files: number
146
146
  bytes: number
147
147
  dropped: number
148
- /** Existing files an @import named that resolve outside the importer's allowed
149
- * roots. Collected so the refusal can be reported rather than left silent. */
148
+ /** Every @import target that resolves outside the importer's allowed roots, whether or not
149
+ * it exists: the notice to the model must not reveal which of a repo's guesses do. */
150
150
  refused: Set<string>
151
+ /** The subset of `refused` that is an existing file, which is what an approval would
152
+ * load and so what the user's dialog is about. */
153
+ refusedPresent: Set<string>
151
154
  }
152
155
 
153
- export const createImportBudget = (): ImportBudget => ({ files: MAX_IMPORT_FILES, bytes: MAX_IMPORT_BYTES, dropped: 0, refused: new Set() })
156
+ export const createImportBudget = (): ImportBudget => ({ files: MAX_IMPORT_FILES, bytes: MAX_IMPORT_BYTES, dropped: 0, refused: new Set(), refusedPresent: new Set() })
154
157
 
155
158
  /** The `@path` targets of a context file, in document order. Claude Code evaluates
156
159
  * imports neither in fenced code blocks (backtick or tilde) nor in inline spans. */
@@ -171,16 +174,25 @@ function importTargets(content: string): string[] {
171
174
  return targets
172
175
  }
173
176
 
177
+ function isRegularFile(target: string): boolean {
178
+ try {
179
+ return fs.statSync(target).isFile()
180
+ } catch {
181
+ return false
182
+ }
183
+ }
184
+
174
185
  /** Read one `@path` target, or null when it is unresolvable, already seen, outside `allowedRoots`, excluded, or unreadable. */
175
- function readImport(target: string, fromDir: string, home: string, allowedRoots: string[], seen: Set<string>, isExcluded: ((realPath: string) => boolean) | undefined, refused: Set<string>): { real: string; body: string } | null {
186
+ function readImport(target: string, fromDir: string, home: string, allowedRoots: string[], seen: Set<string>, isExcluded: ((realPath: string) => boolean) | undefined, refusals: Pick<ImportBudget, 'refused' | 'refusedPresent'>): { real: string; body: string } | null {
176
187
  const resolved = path.resolve(fromDir, expandHome(target, home))
177
188
  // Always the path the importing file named, never where a symlink pointed: the
178
189
  // notice would otherwise hand a repo the real name of whatever the link reaches,
179
190
  // which is the disclosure the refusal exists to prevent. An excluded file is not
180
191
  // named either, since exclusion removes it from every other surface too.
181
- const refuse = (): null => {
182
- if (isExcluded?.(resolved) !== true) refused.add(resolved)
183
- return null
192
+ const refuse = (isFile: boolean): void => {
193
+ if (isExcluded?.(resolved) === true) return
194
+ refusals.refused.add(resolved)
195
+ if (isFile) refusals.refusedPresent.add(resolved)
184
196
  }
185
197
  let real: string
186
198
  try {
@@ -190,10 +202,14 @@ function readImport(target: string, fromDir: string, home: string, allowedRoots:
190
202
  // reported never depends on whether it exists: a notice that named only the
191
203
  // existing ones would enumerate the filesystem for any repo-controlled file
192
204
  // willing to write one @line per guess.
193
- return isUnder(resolved, allowedRoots) ? null : refuse()
205
+ if (!isUnder(resolved, allowedRoots)) refuse(false)
206
+ return null
194
207
  }
195
208
  if (seen.has(real)) return null
196
- if (!isUnder(real, allowedRoots)) return refuse()
209
+ if (!isUnder(real, allowedRoots)) {
210
+ refuse(isRegularFile(real))
211
+ return null
212
+ }
197
213
  // Checked before the read so an excluded file contributes nothing: no body, no
198
214
  // transitive imports, no budget spend, no announce. A post-collection filter
199
215
  // would drop the file itself but keep its children.
@@ -206,7 +222,8 @@ function readImport(target: string, fromDir: string, home: string, allowedRoots:
206
222
  seen.add(real)
207
223
  // A file another importer already refused is in context after all; the notice
208
224
  // must not claim otherwise.
209
- refused.delete(resolved)
225
+ refusals.refused.delete(resolved)
226
+ refusals.refusedPresent.delete(resolved)
210
227
  return { real, body }
211
228
  } catch {
212
229
  return null
@@ -243,7 +260,7 @@ function collectFrom(scan: ImportScan, content: string, fromDir: string, depth:
243
260
  scan.budget.dropped += 1
244
261
  continue
245
262
  }
246
- const file = readImport(target, fromDir, scan.home, scan.allowedRoots, scan.seen, scan.isExcluded, scan.budget.refused)
263
+ const file = readImport(target, fromDir, scan.home, scan.allowedRoots, scan.seen, scan.isExcluded, scan.budget)
247
264
  if (!file) continue
248
265
  scan.budget.files -= 1
249
266
  // The budget is bytes: a string slice counts UTF-16 units and lets CJK text through
@@ -272,16 +289,21 @@ export function collectImports(content: string, fromDir: string, home: string, a
272
289
  /**
273
290
  * Roots an importing file may pull from.
274
291
  *
275
- * A context file under the user's own config may reach the whole config; a project
276
- * file may not. `~/.claude` holds `.credentials.json`, global settings and every
277
- * project's transcripts, so granting those roots to a cloned repo's `CLAUDE.md`
278
- * would let it read them into the system prompt.
292
+ * A file under the user's own config may import from anywhere: Claude's user-scope memory
293
+ * files "are files you wrote yourself", and it loads their imports without the dialog and
294
+ * trusts them like the rest of the user's configuration. A project file may not, so a cloned
295
+ * repo's `CLAUDE.md` cannot read `~/.claude` (`.credentials.json`, global settings, every
296
+ * project's transcripts) into the system prompt.
297
+ *
298
+ * "Under the user's config" is judged by the path pi reported as well as its realpath: a
299
+ * dotfiles manager such as GNU stow links `~/.claude/CLAUDE.md` to a repository, so the
300
+ * real path is outside `~/.claude` although the user put the link there.
279
301
  */
280
302
  export function rootsForImporter(importer: string, home: string, cwd: string, externalApproved = false): string[] {
281
- const userRoots = realRoots([claudeConfigDir(home), path.join(home, '.pi')])
303
+ const userConfig = [claudeConfigDir(home), path.join(home, '.pi')]
282
304
  const [real] = realRoots([importer])
283
- const fromUserConfig = real !== undefined && isUnder(real, userRoots)
284
- if (fromUserConfig) return realRoots([cwd, ...userRoots])
305
+ const fromUserConfig = isUnder(path.resolve(importer), userConfig) || (real !== undefined && isUnder(real, realRoots(userConfig)))
306
+ if (fromUserConfig) return [ANY_ROOT]
285
307
  // The project was asked about its external imports and allowed them, so a project
286
308
  // file may reach outside, as Claude's dialog grants. The widening is deliberately
287
309
  // only for project files: a user-scope file's roots are its own config, and an
@@ -552,7 +574,9 @@ export const EXTERNAL_IMPORT_PROMPT_TITLE = 'Load imports from outside this proj
552
574
  *
553
575
  * The list is the refusals the enforcing path produced, not a second enumeration of
554
576
  * what it might refuse: same files, same depth, same resolution, same exclusions. That
555
- * is the only way the dialog can promise it names everything the approval lets in.
577
+ * is the only way the dialog can promise it names everything the approval lets in. Only
578
+ * files that exist are listed, as in Claude, which asks about the external files it read:
579
+ * a prose `@/components/ui` names nothing an approval could load.
556
580
  */
557
581
  async function askExternalImports(ctx: ExtensionContext, root: string, refused: ReadonlySet<string>): Promise<boolean> {
558
582
  const listed = [...refused]
@@ -853,8 +877,15 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
853
877
  // @import cannot pay for a body that is already there.
854
878
  let launchLoadedPaths: string[] = []
855
879
  // Nested CLAUDE.md/CLAUDE.local.md files already attached this session, so a second
856
- // read in the same subtree does not repeat them.
880
+ // read in the same subtree does not repeat them. Emptied when compaction or /tree takes
881
+ // the tool results that carried them out of context: Claude reloads them "as Claude
882
+ // reads files they apply to".
857
883
  const nestedLoaded = new Set<string>()
884
+ const forgetNested = (): void => {
885
+ nestedLoaded.clear()
886
+ }
887
+ pi.on('session_compact', forgetNested)
888
+ pi.on('session_tree', forgetNested)
858
889
  // Instruction loads already announced on the shared bus, keyed reason:path.
859
890
  // before_agent_start fires every turn, so without this a configured
860
891
  // InstructionsLoaded hook would fire once per file per turn.
@@ -1023,8 +1054,8 @@ export default function contextImportsExtension(pi: ExtensionAPI) {
1023
1054
  const key = externalImportKey(cwd)
1024
1055
  const decided = externalImportDecision(key)
1025
1056
  const result = expandWith(decided === true)
1026
- if (decided !== null || result.budget.refused.size === 0 || ctx?.hasUI !== true) return result
1027
- const approved = await askExternalImports(ctx, key, result.budget.refused)
1057
+ if (decided !== null || result.budget.refusedPresent.size === 0 || ctx?.hasUI !== true) return result
1058
+ const approved = await askExternalImports(ctx, key, result.budget.refusedPresent)
1028
1059
  rememberExternalImportDecision(key, approved)
1029
1060
  return approved ? expandWith(true) : result
1030
1061
  }
@@ -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> ')
@@ -6,8 +6,10 @@
6
6
  * active only when `enabledPlugins` in the settings chain says true, under the
7
7
  * bare name or the marketplace-qualified `name@marketplace`. Only an explicit
8
8
  * true enables: Claude writes the entry on install, so a cached plugin with no
9
- * entry is not one the user turned on. With no version index on disk, the
10
- * newest version directory wins, matching the update-then-grace-period layout.
9
+ * entry is not one the user turned on. The version that loads is the one Claude's
10
+ * installed_plugins.json records; without a usable record, the newest directory
11
+ * not marked `.orphaned_at`, since an update leaves the old version in the cache
12
+ * for a grace period and some directories are named by commit sha.
11
13
  * The persistent data directory (${CLAUDE_PLUGIN_DATA}) survives updates at
12
14
  * ~/.claude/plugins/data/<id>, id being the qualified name folded to dashes.
13
15
  */
@@ -59,6 +61,12 @@ function listDirs(dir: string): string[] {
59
61
  }
60
62
  }
61
63
 
64
+ /** The plugin directories of a marketplace. A dot-directory is never one: Claude clones a
65
+ * marketplace into `temp_git_*` first, and its `.git` was loaded as a plugin. */
66
+ function listPluginDirs(marketplaceDir: string): string[] {
67
+ return listDirs(marketplaceDir).filter((name) => !name.startsWith('.'))
68
+ }
69
+
62
70
  /** One version string split for comparison: optional v prefix dropped, numeric
63
71
  * base segments, and whatever follows a dash as the prerelease tag. */
64
72
  function parseVersion(version: string): { base: number[]; pre: string | undefined } {
@@ -88,6 +96,39 @@ function newestVersion(versions: string[]): string | undefined {
88
96
  return [...versions].sort(compareVersions).at(-1)
89
97
  }
90
98
 
99
+ /** Claude's record of what is installed, `plugins/installed_plugins.json`: per plugin id a
100
+ * list of installs (an object in the older layout), each with its scope and `installPath`.
101
+ * Maps each id to its recorded directories, user scope first: enablement here is the
102
+ * user's own, and a project or local install is another project's copy. Undefined when the
103
+ * file is absent or unusable, which leaves the directory walk to decide. */
104
+ function readInstallIndex(home: string): Map<string, string[]> | undefined {
105
+ const file = path.join(claudeConfigDir(home), 'plugins', 'installed_plugins.json')
106
+ const plugins = readJson(file).plugins
107
+ if (plugins === null || typeof plugins !== 'object') return undefined
108
+ const installs = new Map<string, string[]>()
109
+ for (const [id, entry] of Object.entries(plugins)) {
110
+ const recorded = (Array.isArray(entry) ? entry : [entry]).filter((one): one is { scope?: unknown; installPath: string } => typeof one?.installPath === 'string')
111
+ recorded.sort((a, b) => Number(b.scope === 'user') - Number(a.scope === 'user'))
112
+ installs.set(
113
+ id,
114
+ recorded.map((one) => one.installPath),
115
+ )
116
+ }
117
+ return installs
118
+ }
119
+
120
+ /** The version directory of one cached plugin that loads: a recorded install that still
121
+ * exists and sits directly under the plugin's cache directory (the record is a file, and a
122
+ * path into another plugin's tree would load that plugin's code under this one's name),
123
+ * else the newest version not marked orphaned. */
124
+ function versionDir(pluginPath: string, recorded: string[] | undefined): string | undefined {
125
+ const installed = recorded?.find((dir) => path.dirname(path.resolve(dir)) === pluginPath && listDirs(pluginPath).includes(path.basename(dir)))
126
+ if (installed) return installed
127
+ const live = listDirs(pluginPath).filter((version) => !fs.existsSync(path.join(pluginPath, version, '.orphaned_at')))
128
+ const newest = newestVersion(live)
129
+ return newest === undefined ? undefined : path.join(pluginPath, newest)
130
+ }
131
+
91
132
  /** The enablement map, later files winning per key, as settings scopes merge. */
92
133
  function enabledMap(settingsFiles: string[]): Record<string, boolean> {
93
134
  const merged: Record<string, boolean> = {}
@@ -159,26 +200,26 @@ function contentToken(target: string): string {
159
200
  /**
160
201
  * A cheap change signature for one home's plugin config: the settings files'
161
202
  * content hashes plus the cache tree's directory names and mtimes down through each plugin's
162
- * version directories, and the stat token of the resolved (newest) version's manifest
203
+ * version directories, and the stat token of the resolved version's manifest (which moves when Claude's install record selects another)
163
204
  * so an in-place edit of it invalidates the cache. Costs a few stats where the full
164
205
  * walk reads and parses the settings and every manifest.
165
206
  */
166
- function pluginFingerprint(cacheDir: string, settingsFiles: string[]): string {
207
+ function pluginFingerprint(cacheDir: string, settingsFiles: string[], index: Map<string, string[]> | undefined): string {
167
208
  const parts = settingsFiles.map(contentToken)
168
209
  for (const marketplace of listDirs(cacheDir)) {
169
210
  const marketplaceDir = path.join(cacheDir, marketplace)
170
211
  parts.push(`${marketplace}:${statToken(marketplaceDir)}`)
171
- for (const pluginDir of listDirs(marketplaceDir)) {
212
+ for (const pluginDir of listPluginDirs(marketplaceDir)) {
172
213
  const pluginPath = path.join(marketplaceDir, pluginDir)
173
214
  parts.push(`${marketplace}/${pluginDir}:${statToken(pluginPath)}`)
174
215
  const versions = listDirs(pluginPath)
175
216
  for (const version of versions) {
176
217
  parts.push(`${marketplace}/${pluginDir}/${version}:${statToken(path.join(pluginPath, version))}`)
177
218
  }
178
- // resolvePlugin reads only the newest version's manifest, so its stat token is
219
+ // resolvePlugin reads only the resolved version's manifest, so its stat token is
179
220
  // what an in-place edit (no directory entry changing) must move.
180
- const newest = newestVersion(versions)
181
- if (newest) parts.push(`${marketplace}/${pluginDir}/${newest}/manifest:${statToken(path.join(pluginPath, newest, '.claude-plugin', 'plugin.json'))}`)
221
+ const resolved = versionDir(pluginPath, index?.get(`${pluginDir}@${marketplace}`))
222
+ if (resolved) parts.push(`${marketplace}/${pluginDir}/${path.basename(resolved)}/manifest:${statToken(path.join(resolved, '.claude-plugin', 'plugin.json'))}`)
182
223
  }
183
224
  }
184
225
  return parts.join('\n')
@@ -199,15 +240,16 @@ export function installedPlugins(home: string, extraSettingsFiles: string[] = []
199
240
  const cacheDir = path.join(claudeConfigDir(home), 'plugins', 'cache')
200
241
  const settingsFiles = [path.join(claudeConfigDir(home), 'settings.json'), ...extraSettingsFiles]
201
242
  const key = [home, ...extraSettingsFiles].join('\n')
202
- const fingerprint = pluginFingerprint(cacheDir, settingsFiles)
243
+ const index = readInstallIndex(home)
244
+ const fingerprint = pluginFingerprint(cacheDir, settingsFiles, index)
203
245
  const cached = pluginCache.get(key)
204
246
  if (cached?.fingerprint === fingerprint) return cached.plugins
205
247
  const enabled = enabledMap(settingsFiles)
206
248
  const configs = pluginConfigsMap(settingsFiles)
207
249
  const plugins: InstalledPlugin[] = []
208
250
  for (const marketplace of listDirs(cacheDir)) {
209
- for (const pluginDir of listDirs(path.join(cacheDir, marketplace))) {
210
- const plugin = resolvePlugin(home, cacheDir, marketplace, pluginDir, enabled, configs)
251
+ for (const pluginDir of listPluginDirs(path.join(cacheDir, marketplace))) {
252
+ const plugin = resolvePlugin(home, cacheDir, marketplace, pluginDir, enabled, configs, index)
211
253
  if (plugin) plugins.push(plugin)
212
254
  }
213
255
  }
@@ -243,11 +285,10 @@ function pluginEnabled(qualified: string, pluginDir: string, enabled: Record<str
243
285
 
244
286
  /** Resolve one cached plugin directory into an enabled InstalledPlugin, or null to skip
245
287
  * it: turned off by managed/user settings or defaultEnabled, or no version yet. */
246
- function resolvePlugin(home: string, cacheDir: string, marketplace: string, pluginDir: string, enabled: Record<string, boolean>, configs: Record<string, Record<string, string>>): InstalledPlugin | null {
288
+ function resolvePlugin(home: string, cacheDir: string, marketplace: string, pluginDir: string, enabled: Record<string, boolean>, configs: Record<string, Record<string, string>>, installs: Map<string, string[]> | undefined): InstalledPlugin | null {
247
289
  const qualified = `${pluginDir}@${marketplace}`
248
- const version = newestVersion(listDirs(path.join(cacheDir, marketplace, pluginDir)))
249
- if (!version) return null
250
- const root = path.join(cacheDir, marketplace, pluginDir, version)
290
+ const root = versionDir(path.join(cacheDir, marketplace, pluginDir), installs?.get(qualified))
291
+ if (!root) return null
251
292
  const manifest = readJson(path.join(root, '.claude-plugin', 'plugin.json'))
252
293
  if (!pluginEnabled(qualified, pluginDir, enabled, manifest)) return null
253
294
  const name = typeof manifest.name === 'string' && manifest.name.length > 0 ? manifest.name : pluginDir
@@ -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
@@ -29,6 +29,9 @@ export interface BackgroundRun {
29
29
  partial?: boolean
30
30
  /** Temp dir holding a prompt file rebuilt for a resume; removed with the run. */
31
31
  rebuiltPromptDir?: string
32
+ /** Temp dir the child persists its session in, apart from the project's session dir;
33
+ * removed with the run. */
34
+ sessionDir?: string
32
35
  /** Set while running so the run can be cancelled; cleared on completion. */
33
36
  kill?: () => void
34
37
  /** True until the child process actually closes: a cancelled child that ignores
@@ -90,7 +93,7 @@ export function activeBackgroundRuns(): number {
90
93
  /** Test seam: the registry is module state, so tests reset it between cases to
91
94
  * stay order-independent. */
92
95
  export function resetBackgroundRuns(): void {
93
- for (const run of runs.values()) removeRebuiltPrompt(run)
96
+ for (const run of runs.values()) removeRunFiles(run)
94
97
  runs.clear()
95
98
  state.finishSequence = 0
96
99
  }
@@ -99,7 +102,7 @@ function evictFinishedRuns(): void {
99
102
  const finished = [...runs.values()].filter((run) => !run.live && run.state !== 'running')
100
103
  finished.sort((a, b) => (a.finishedAt ?? 0) - (b.finishedAt ?? 0))
101
104
  for (const stale of finished.slice(0, Math.max(0, finished.length - MAX_FINISHED_RUNS))) {
102
- removeRebuiltPrompt(stale)
105
+ removeRunFiles(stale)
103
106
  runs.delete(stale.id)
104
107
  }
105
108
  }
@@ -178,7 +181,7 @@ export function cancelAllBackgroundRuns(): number {
178
181
  if (cancelBackgroundRun(id) === 'cancelled') count++
179
182
  }
180
183
  // Called at quit: nothing in this registry is resumable once pi exits.
181
- for (const run of runs.values()) removeRebuiltPrompt(run)
184
+ for (const run of runs.values()) removeRunFiles(run)
182
185
  return count
183
186
  }
184
187
 
@@ -259,12 +262,14 @@ function withRebuiltPrompt(spawnSpec: BackgroundSpawn, agent: string): { args: s
259
262
  }
260
263
  }
261
264
 
262
- /** The rebuilt prompt lives as long as its run can be resumed, so it goes when the run
263
- * leaves the registry: eviction, quit, or the test reset. */
264
- function removeRebuiltPrompt(run: BackgroundRun): void {
265
- if (!run.rebuiltPromptDir) return
266
- fs.rmSync(run.rebuiltPromptDir, { recursive: true, force: true })
265
+ /** A run's temp files (the rebuilt prompt, its session) live as long as the run can be
266
+ * resumed, so they go when it leaves the registry: eviction, quit, or the test reset. */
267
+ function removeRunFiles(run: BackgroundRun): void {
268
+ for (const dir of [run.rebuiltPromptDir, run.sessionDir]) {
269
+ if (dir) fs.rmSync(dir, { recursive: true, force: true })
270
+ }
267
271
  run.rebuiltPromptDir = undefined
272
+ run.sessionDir = undefined
268
273
  }
269
274
 
270
275
  export function startBackgroundRun(agent: string, task: string, invocation: BackgroundSpawn, onComplete: (run: BackgroundRun) => void, presetId?: string): string | null {
@@ -276,12 +281,15 @@ export function startBackgroundRun(agent: string, task: string, invocation: Back
276
281
  // id the run will carry.
277
282
  const id = presetId ?? `bg-${randomUUID().slice(0, 8)}`
278
283
  // A stable session id per run: the child persists its session, so a follow-up can
279
- // resume it instead of starting cold.
284
+ // resume it instead of starting cold. It persists into a directory of its own: in the
285
+ // project's session dir it would be the newest session there, which `pi -c` and /resume
286
+ // offer as the user's previous one. The same args serve the resume, so it finds the file.
280
287
  const sessionId = `pi-code-${id}-${randomUUID().slice(0, 8)}`
288
+ const sessionDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-code-bg-session-'))
281
289
  const args = invocation.args.map((arg) => (arg === '--no-session' ? '--session-id' : arg))
282
- const withSession = args.includes('--session-id') ? args.flatMap((arg) => (arg === '--session-id' ? ['--session-id', sessionId] : [arg])) : args
290
+ const withSession = args.includes('--session-id') ? args.flatMap((arg) => (arg === '--session-id' ? ['--session-id', sessionId, '--session-dir', sessionDir] : [arg])) : args
283
291
  const spawnSpec: BackgroundSpawn = { ...invocation, args: withSession }
284
- const run: BackgroundRun = { id, agent, task, state: 'running', turns: 0, sessionId, spawn: spawnSpec }
292
+ const run: BackgroundRun = { id, agent, task, state: 'running', turns: 0, sessionId, sessionDir, spawn: spawnSpec }
285
293
  runs.set(id, run)
286
294
  driveRun(run, spawnSpec, onComplete)
287
295
  return id
@@ -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.74",
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",