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 +1 -1
- package/extensions/claude-rules.ts +14 -0
- package/extensions/commands.ts +2 -1
- package/extensions/context-imports.ts +53 -22
- package/extensions/git-checkpoint.ts +17 -1
- package/extensions/goal.ts +2 -1
- package/extensions/hooks/index.ts +2 -1
- package/extensions/internal/html-markdown.ts +112 -17
- package/extensions/internal/plugins.ts +56 -15
- package/extensions/notify.ts +34 -11
- package/extensions/plan-mode/index.ts +2 -1
- package/extensions/session-title.ts +10 -4
- package/extensions/subagent/background.ts +19 -11
- package/extensions/thinking.ts +2 -1
- package/extensions/web.ts +35 -8
- package/package.json +4 -4
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.
|
|
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
|
package/extensions/commands.ts
CHANGED
|
@@ -554,7 +554,8 @@ export default function commandsExtension(pi: ExtensionAPI) {
|
|
|
554
554
|
}
|
|
555
555
|
|
|
556
556
|
pi.on('session_start', async (_event, ctx) => {
|
|
557
|
-
//
|
|
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
|
-
/**
|
|
149
|
-
*
|
|
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,
|
|
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 = ():
|
|
182
|
-
if (isExcluded?.(resolved)
|
|
183
|
-
|
|
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
|
-
|
|
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))
|
|
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
|
|
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
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
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
|
|
303
|
+
const userConfig = [claudeConfigDir(home), path.join(home, '.pi')]
|
|
282
304
|
const [real] = realRoots([importer])
|
|
283
|
-
const fromUserConfig = real !== undefined && isUnder(real,
|
|
284
|
-
if (fromUserConfig) return
|
|
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.
|
|
1027
|
-
const approved = await askExternalImports(ctx, key, result.budget.
|
|
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
|
-
//
|
|
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
|
package/extensions/goal.ts
CHANGED
|
@@ -446,7 +446,8 @@ export default function goalExtension(pi: ExtensionAPI) {
|
|
|
446
446
|
})
|
|
447
447
|
|
|
448
448
|
pi.on('session_start', (_event, ctx) => {
|
|
449
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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 =
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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.
|
|
10
|
-
*
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
181
|
-
if (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
249
|
-
if (!
|
|
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
|
package/extensions/notify.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
//
|
|
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.
|
|
12
|
-
* is
|
|
13
|
-
*
|
|
14
|
-
* session
|
|
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())
|
|
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
|
-
|
|
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())
|
|
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
|
-
/**
|
|
263
|
-
* leaves the registry: eviction, quit, or the test reset. */
|
|
264
|
-
function
|
|
265
|
-
|
|
266
|
-
|
|
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
|
package/extensions/thinking.ts
CHANGED
|
@@ -56,7 +56,8 @@ export default function thinkingExtension(pi: ExtensionAPI) {
|
|
|
56
56
|
})
|
|
57
57
|
|
|
58
58
|
pi.on('session_start', () => {
|
|
59
|
-
//
|
|
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
|
-
/**
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
61
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
62
|
-
"@earendil-works/pi-tui": ">=0.
|
|
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",
|