pi-code 1.0.75 → 1.0.77

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
 
@@ -413,7 +413,9 @@ export default function commandsExtension(pi: ExtensionAPI) {
413
413
  const rules = pendingPathRules?.[event.toolName as PathRuleTool]
414
414
  if (!rules) return
415
415
  // pi's read/edit/write accept `file_path` as an alias for `path`; the shared reader
416
- // handles both, and the paired guard in hooks/matcher.ts reads both too.
416
+ // handles both and normalises the value as pi resolves it. The paired guard in
417
+ // hooks/matcher.ts judges the same paths against the same rules, so it normalises
418
+ // through the same helper rather than only aliasing the two keys.
417
419
  const filePath = fileToolTarget(event) ?? ''
418
420
  const anchors = { cwd: ctx.cwd, projectRoot: checkoutRoot(ctx.cwd), home: os.homedir() }
419
421
  if (filePath && matchesPathRules(filePath, rules, anchors)) return
@@ -554,7 +556,8 @@ export default function commandsExtension(pi: ExtensionAPI) {
554
556
  }
555
557
 
556
558
  pi.on('session_start', async (_event, ctx) => {
557
- // One extension instance serves every session. A mid-turn /new fires session_start on
559
+ // pi's CLI builds a fresh extension instance per session replacement; only RPC mode can
560
+ // reuse one across sessions. A mid-turn /new there fires session_start on
558
561
  // the same instance while a command's per-run scoping is still pending (its agent_settled
559
562
  // never came). Carrying that into the next session would restore an unrelated tool set,
560
563
  // bash/path scope, model, or effort onto it, so drop the pending state here. Drop only:
@@ -103,7 +103,8 @@ function userEnv(home: string): Record<string, string> {
103
103
  * documented drop list: variables that choose where config and files are written
104
104
  * (redirecting later home-scope reads and every subprocess), variables that export
105
105
  * session content, and variables that change how the agent starts or syncs.
106
- * PI_CODING_AGENT_DIR is pi's own config-dir analogue of CLAUDE_CONFIG_DIR. */
106
+ * PI_CODING_AGENT_DIR is pi's own config-dir analogue of CLAUDE_CONFIG_DIR, and the
107
+ * PI_CODE_ prefix covers pi-code's own control variables (see isRepoHostileEnvKey). */
107
108
  const REPO_HOSTILE_ENV_KEYS = new Set([
108
109
  'CLAUDE_CONFIG_DIR',
109
110
  'CLAUDE_CODE_TMPDIR',
@@ -122,13 +123,24 @@ const REPO_HOSTILE_ENV_KEYS = new Set([
122
123
  'PI_CODING_AGENT_DIR',
123
124
  ])
124
125
 
126
+ /** Whether a repository's settings must not set this key: Claude's documented drop list,
127
+ * the XDG_ family, and pi-code's own PI_CODE_ control variables. The last matter because
128
+ * they are read as instructions rather than data: PI_CODE_SUBAGENT makes a session believe
129
+ * it is a subagent child, which suppresses the USER's own SessionStart, UserPromptSubmit,
130
+ * Stop and SessionEnd hooks and their auto memory, and PI_CODE_AGENT_HOOKS is then parsed
131
+ * into hook definitions, which are shell commands. Approving a repository means running the
132
+ * config it ships, never silently disabling the user's own guardrails. */
133
+ function isRepoHostileEnvKey(key: string): boolean {
134
+ return REPO_HOSTILE_ENV_KEYS.has(key) || key.startsWith('XDG_') || key.startsWith('PI_CODE_')
135
+ }
136
+
125
137
  /** Drop the keys a repository's settings must not set, warning each, as Claude
126
138
  * documents ("Claude Code drops each one and logs a warning"). Set them in the
127
139
  * shell, user settings, or managed settings instead. */
128
140
  export function sanitizeProjectEnv(env: Record<string, string>, warn: (key: string) => void = (key) => console.warn(`pi-code-env: dropping ${key} from project settings env (a checked-out repository must not control it; set it in user or managed settings)`)): Record<string, string> {
129
141
  const kept: Record<string, string> = {}
130
142
  for (const [key, value] of Object.entries(env)) {
131
- if (REPO_HOSTILE_ENV_KEYS.has(key) || key.startsWith('XDG_')) warn(key)
143
+ if (isRepoHostileEnvKey(key)) warn(key)
132
144
  else kept[key] = value
133
145
  }
134
146
  return kept
@@ -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
@@ -8,6 +8,7 @@ import { matchesBashIfFilter } from '../internal/bash-rules.js'
8
8
  import { CLAUDE_TOOL_MAP } from '../internal/claude-tool-names.js'
9
9
  import { matchesPathRules, type PathAnchors } from '../internal/path-rules.js'
10
10
  import { agentNamesIn, matchesAgentRules, matchesDomainRules, matchesSkillRules } from '../internal/scope-rules.js'
11
+ import { asPiReadsIt } from '../internal/tool-target.js'
11
12
  import { errorMessage } from '../internal/values.js'
12
13
  import type { HookCommand, HookMatcher } from './config.js'
13
14
 
@@ -219,8 +220,13 @@ function matchesToolPattern(piName: string, input: Record<string, unknown> | nul
219
220
  case 'slash_command':
220
221
  return matchesSkillRules(str(input?.command), [pattern])
221
222
  default: {
222
- const filePath = str(input?.path) || str(input?.file_path)
223
- return filePath.length > 0 && matchesPathRules(filePath, [pattern], anchors)
223
+ // Normalised as pi's file tools resolve it (~, a leading @, a file:// URL), because
224
+ // the rule side expands ~ against home too: comparing the raw string let an
225
+ // `if: Edit(~/.ssh/*)` guard miss the very call it names. The value is normalised
226
+ // rather than routed through fileToolTarget, which would drop every tool outside
227
+ // read/edit/write and so stop matching rules that match today.
228
+ const named = str(input?.path) || str(input?.file_path)
229
+ return named.length > 0 && matchesPathRules(asPiReadsIt(named), [pattern], anchors)
224
230
  }
225
231
  }
226
232
  }
@@ -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> ')
@@ -34,12 +34,29 @@ export function managedSettingsFile(): string {
34
34
  return managedSettingsFileOverride ?? managedSettingsPath()
35
35
  }
36
36
 
37
+ /** Files already reported as unparsable. Managed settings are read from sixteen call
38
+ * sites, several of them once per turn, so one warning per broken file is the whole
39
+ * budget; the same warn-once shape project-approval.ts uses for its runtime notice. */
40
+ const warnedUnparsable = new Set<string>()
41
+
37
42
  function readOneSettingsFile(file: string): Record<string, unknown> {
43
+ let raw: string
44
+ try {
45
+ raw = fs.readFileSync(file, 'utf-8')
46
+ } catch {
47
+ return {} // no such file: genuinely no managed policy on this machine
48
+ }
38
49
  try {
39
- const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
40
- if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed as Record<string, unknown>
50
+ const parsed: unknown = JSON.parse(raw)
51
+ if (isRecord(parsed)) return parsed
41
52
  } catch {
42
- // No managed policy on this machine.
53
+ // Present but unparsable is an administrator's typo, not an absent policy. Silence
54
+ // there disabled every policy the file carries (exclusion lists, MCP allow and deny,
55
+ // enabledPlugins, disableAllHooks) on every session, with nothing said anywhere.
56
+ if (!warnedUnparsable.has(file)) {
57
+ warnedUnparsable.add(file)
58
+ console.warn(`pi-code: ignoring ${file}: not valid JSON; the managed policy it carries is not applied`)
59
+ }
43
60
  }
44
61
  return {}
45
62
  }
@@ -19,7 +19,8 @@ import * as fs from 'node:fs'
19
19
  import * as path from 'node:path'
20
20
  import { claudeConfigDir } from './config-dir.js'
21
21
  import { readManagedSettings } from './managed-settings.js'
22
- import { errorMessage } from './values.js'
22
+ import { statToken } from './stat-token.js'
23
+ import { errorMessage, isRecord } from './values.js'
23
24
 
24
25
  export interface InstalledPlugin {
25
26
  name: string
@@ -40,8 +41,10 @@ function readJson(file: string): Record<string, unknown> {
40
41
  return {} // no such file: nothing to read, and most callers expect that
41
42
  }
42
43
  try {
43
- const parsed = JSON.parse(raw)
44
- return parsed !== null && typeof parsed === 'object' ? parsed : {}
44
+ const parsed: unknown = JSON.parse(raw)
45
+ // isRecord, not a hand-written object check: a manifest or settings file that is a
46
+ // JSON array is not a config object, and every caller here reads it by key.
47
+ return isRecord(parsed) ? parsed : {}
45
48
  } catch (error) {
46
49
  // A manifest that does not parse leaves the plugin with no components at all, and
47
50
  // settings that do not parse drop the enablement or configuration they carried.
@@ -176,12 +179,13 @@ export function resetInstalledPluginsCache(): void {
176
179
  pluginCache.clear()
177
180
  }
178
181
 
179
- /** mtime plus size; cheap, but blind to a same-size rewrite within one
180
- * timestamp tick, so only directory-tree entries use it. */
181
- function statToken(target: string): string {
182
+ /** mtime plus size; cheap, but blind to a same-size rewrite within one timestamp tick, so
183
+ * only directory-tree entries use it. The shared token is the one that owns the format
184
+ * (internal/stat-token.ts); it throws where this caller wants a value for a path that is
185
+ * simply not there, which is the catch below rather than a second copy of the format. */
186
+ function statTokenOrMissing(target: string): string {
182
187
  try {
183
- const stat = fs.statSync(target)
184
- return `${stat.mtimeMs}:${stat.size}`
188
+ return statToken(target)
185
189
  } catch {
186
190
  return 'missing'
187
191
  }
@@ -208,18 +212,18 @@ function pluginFingerprint(cacheDir: string, settingsFiles: string[], index: Map
208
212
  const parts = settingsFiles.map(contentToken)
209
213
  for (const marketplace of listDirs(cacheDir)) {
210
214
  const marketplaceDir = path.join(cacheDir, marketplace)
211
- parts.push(`${marketplace}:${statToken(marketplaceDir)}`)
215
+ parts.push(`${marketplace}:${statTokenOrMissing(marketplaceDir)}`)
212
216
  for (const pluginDir of listPluginDirs(marketplaceDir)) {
213
217
  const pluginPath = path.join(marketplaceDir, pluginDir)
214
- parts.push(`${marketplace}/${pluginDir}:${statToken(pluginPath)}`)
218
+ parts.push(`${marketplace}/${pluginDir}:${statTokenOrMissing(pluginPath)}`)
215
219
  const versions = listDirs(pluginPath)
216
220
  for (const version of versions) {
217
- parts.push(`${marketplace}/${pluginDir}/${version}:${statToken(path.join(pluginPath, version))}`)
221
+ parts.push(`${marketplace}/${pluginDir}/${version}:${statTokenOrMissing(path.join(pluginPath, version))}`)
218
222
  }
219
223
  // resolvePlugin reads only the resolved version's manifest, so its stat token is
220
224
  // what an in-place edit (no directory entry changing) must move.
221
225
  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'))}`)
226
+ if (resolved) parts.push(`${marketplace}/${pluginDir}/${path.basename(resolved)}/manifest:${statTokenOrMissing(path.join(resolved, '.claude-plugin', 'plugin.json'))}`)
223
227
  }
224
228
  }
225
229
  return parts.join('\n')
@@ -21,8 +21,10 @@ const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g
21
21
  * some models add) stripped, ~ expanded, a file:// URL decoded, unicode spaces folded.
22
22
  * pi does this in resolveToCwd (dist/core/tools/path-utils), which the package does not
23
23
  * export. A reader that skips it judges a different file than the one pi opens: a
24
- * command scoped to `Read(*.md)` allowed `~/secret/notes.md`, read as <cwd>/~/secret. */
25
- function asPiReadsIt(target: string): string {
24
+ * command scoped to `Read(*.md)` allowed `~/secret/notes.md`, read as <cwd>/~/secret.
25
+ * Exported for the hook `if` filter, which judges the same paths against the same rules
26
+ * but reaches them through tool names this module's FILE_TOOLS set does not cover. */
27
+ export function asPiReadsIt(target: string): string {
26
28
  const folded = target.replace(UNICODE_SPACES, ' ')
27
29
  const bare = folded.startsWith('@') ? folded.slice(1) : folded
28
30
  if (bare === '~') return os.homedir()
@@ -8,7 +8,7 @@ import * as fs from 'node:fs'
8
8
  import * as path from 'node:path'
9
9
  import { managedSettingsFile } from '../internal/managed-settings.js'
10
10
  import { claudeSettingsChain } from '../internal/settings-chain.js'
11
- import { errorMessage, escapeRegExp } from '../internal/values.js'
11
+ import { errorMessage, escapeRegExp, isRecord } from '../internal/values.js'
12
12
  import { interpolateEnv, type ServerConfig } from './config.js'
13
13
 
14
14
  export interface ProjectServerPolicy {
@@ -29,10 +29,22 @@ export interface ProjectServerPolicy {
29
29
  * disabledMcpjsonServers counts from every file, including the repo's own, and wins
30
30
  * over consent: a repo may always restrict itself further, never less. */
31
31
  export function projectServerPolicy(cwd: string, home: string, projectApproved: boolean): ProjectServerPolicy {
32
+ // Tells a missing file from an unparsable one, like mcpAllowDeny below: silence for the
33
+ // second silently emptied disabledMcpjsonServers, so a server the repository had
34
+ // explicitly disabled connected. That direction fails open, against this function's own
35
+ // rule that a repo may always restrict itself further, never less.
32
36
  const read = (file: string): Record<string, unknown> => {
37
+ let raw: string
38
+ try {
39
+ raw = fs.readFileSync(file, 'utf-8')
40
+ } catch {
41
+ return {} // no such file: genuinely no policy
42
+ }
33
43
  try {
34
- return JSON.parse(fs.readFileSync(file, 'utf-8'))
44
+ const parsed: unknown = JSON.parse(raw)
45
+ return isRecord(parsed) ? parsed : {}
35
46
  } catch {
47
+ console.warn(`pi-code-mcp: ignoring ${file}: not valid JSON; its MCP server consent and disable lists are not applied`)
36
48
  return {}
37
49
  }
38
50
  }
@@ -243,18 +243,25 @@ function readMemory(dir: string, name: string): MemoryToolResult {
243
243
  }
244
244
  }
245
245
 
246
- /** The delete action: remove a memory file and its index line, queued on the index
247
- * like save (single key, no deadlock). The index is read before anything is removed,
248
- * and any failure (a bad index read, or an unreadable store the queue key cannot
249
- * realpath) leaves both the memory file and the index as they were. */
246
+ /** The delete action: remove a memory's index line and then its file, queued on the index
247
+ * like save (single key, no deadlock). The index moves first because it is what the prompt
248
+ * and /memory show: a failure before it is written leaves both the file and the index as
249
+ * they were, and a failure after it leaves an orphan file rather than an index line
250
+ * pointing at a file that is gone. Each outcome is reported as what actually happened. */
250
251
  async function deleteMemory(dir: string, indexPath: string, name: string): Promise<MemoryToolResult> {
251
252
  try {
252
253
  return await withFileMutationQueue(indexPath, async (): Promise<MemoryToolResult> => {
253
254
  const index = readIndex(dir)
254
- fs.rmSync(path.join(dir, `${name}.md`), { force: true })
255
255
  const remaining = removeIndexLine(index, name)
256
256
  if (remaining) writeIndex(indexPath, remaining)
257
257
  else fs.rmSync(indexPath, { force: true })
258
+ try {
259
+ fs.rmSync(path.join(dir, `${name}.md`), { force: true })
260
+ } catch (error) {
261
+ // Out of the index, so it is gone from every surface the user sees; saying the
262
+ // delete failed would be false, and silence would leave the stray file unexplained.
263
+ return { content: [{ type: 'text', text: `Deleted memory ${name} from the index, but its file could not be removed: ${errorMessage(error)}.` }], details: {} }
264
+ }
258
265
  return { content: [{ type: 'text', text: `Deleted memory ${name}.` }], details: {} }
259
266
  })
260
267
  } catch (error) {
@@ -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
@@ -250,11 +250,12 @@ export default function subagentExtension(pi: ExtensionAPI) {
250
250
  if (params.tasks?.length) return runParallelMode(params.tasks, mode)
251
251
  if (params.agent && params.task) return runSingleMode(params.agent, params.task, params.cwd, mode)
252
252
 
253
- const available = agents.map((a) => `${a.name} (${a.source})`).join(', ') || 'none'
254
- return {
255
- content: [{ type: 'text', text: `Invalid parameters. Available agents: ${available}` }],
256
- details: makeDetails('single')([]),
257
- }
253
+ // Unreachable: the modeCount guard above returns unless exactly one of these three
254
+ // is set, and nothing between it and here touches params. It was a second copy of
255
+ // that guard's message, which no input could ever produce, so a reader had to work
256
+ // out for themselves that it was dead. Stated as the invariant it actually is, so a
257
+ // future edit that breaks it says so instead of printing a confusing refusal.
258
+ throw new Error('subagent: exactly one mode must be set here; the mode guard should have returned')
258
259
  },
259
260
 
260
261
  renderCall(args, theme, _context) {
@@ -56,7 +56,8 @@ export default function thinkingExtension(pi: ExtensionAPI) {
56
56
  })
57
57
 
58
58
  pi.on('session_start', () => {
59
- // One extension instance serves every session. A mid-turn /new fires session_start on
59
+ // pi's CLI builds a fresh extension instance per session replacement; only RPC mode can
60
+ // reuse one across sessions. A mid-turn /new there fires session_start on
60
61
  // the same instance while an escalation is still pending (its agent_settled never came),
61
62
  // and that stale restore must be dropped rather than fired into the next session, whose
62
63
  // level the new session owns. Drop only: do NOT setThinkingLevel here.
package/extensions/web.ts CHANGED
@@ -178,11 +178,28 @@ async function resolveAndPin(url: URL): Promise<LookupFunction> {
178
178
 
179
179
  const MAX_REDIRECTS = 5
180
180
 
181
- /** Read a response body up to MAX_RAW_CHARS, then stop the download. Bounds memory and parsing cost. */
181
+ /** The charset a content-type header declares, or 'utf-8' when it names none. */
182
+ function declaredCharset(contentType: string): string {
183
+ const match = /charset=(?:"([^"]*)"|'([^']*)'|([^;\s]*))/i.exec(contentType)
184
+ return (match?.[1] ?? match?.[2] ?? match?.[3] ?? '').trim() || 'utf-8'
185
+ }
186
+
187
+ /** A decoder for `contentType`'s declared charset, or the platform default (UTF-8) for a
188
+ * label TextDecoder does not recognize: a bad or made-up charset must not fail the fetch. */
189
+ function decoderFor(contentType: string): TextDecoder {
190
+ try {
191
+ return new TextDecoder(declaredCharset(contentType))
192
+ } catch {
193
+ return new TextDecoder()
194
+ }
195
+ }
196
+
197
+ /** Read a response body up to MAX_RAW_CHARS, decoded as the content-type header's charset
198
+ * (UTF-8 when it names none), then stop the download. Bounds memory and parsing cost. */
182
199
  async function readCapped(response: Response): Promise<string> {
200
+ const decoder = decoderFor(response.headers.get('content-type') ?? '')
183
201
  const reader = response.body?.getReader()
184
- if (!reader) return (await response.text()).slice(0, MAX_RAW_CHARS)
185
- const decoder = new TextDecoder()
202
+ if (!reader) return decoder.decode(await response.arrayBuffer()).slice(0, MAX_RAW_CHARS)
186
203
  let text = ''
187
204
  while (text.length < MAX_RAW_CHARS) {
188
205
  const { done, value } = await reader.read()
@@ -234,13 +251,23 @@ function redirectTarget(response: Response, url: URL, rawUrl: string, crossHost:
234
251
  return { kind: 'next', next }
235
252
  }
236
253
 
237
- async function fetchText(rawUrl: string, crossHost: CrossHost, transport = httpFetch): Promise<FetchOutcome> {
254
+ /** The per-hop timeout, combined with the caller's own signal (the tool call's, fired on
255
+ * Esc) when there is one: cancelling must not lose the ceiling that keeps a silently
256
+ * hanging host from holding the turn forever, but Esc must not have to wait for it either. */
257
+ function hopSignal(signal: AbortSignal | undefined): AbortSignal {
258
+ const timeout = AbortSignal.timeout(FETCH_TIMEOUT_MS)
259
+ return signal ? AbortSignal.any([signal, timeout]) : timeout
260
+ }
261
+
262
+ async function fetchText(rawUrl: string, crossHost: CrossHost, signal?: AbortSignal, transport = httpFetch): Promise<FetchOutcome> {
238
263
  let url = new URL(rawUrl)
239
264
  for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
265
+ // Cancelled between hops (a redirect chain), not just mid-request.
266
+ signal?.throwIfAborted()
240
267
  // Resolve, validate and pin per hop: a redirect target gets the same guarantee.
241
268
  const lookup = await resolveAndPin(url)
242
269
  const response = await transport(url, {
243
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
270
+ signal: hopSignal(signal),
244
271
  lookup,
245
272
  userAgent: USER_AGENT,
246
273
  })
@@ -317,9 +344,9 @@ export default function webExtension(pi: ExtensionAPI) {
317
344
  allowed_domains: Type.Optional(Type.Array(Type.String(), { description: 'Only include results from these domains' })),
318
345
  blocked_domains: Type.Optional(Type.Array(Type.String(), { description: 'Exclude results from these domains' })),
319
346
  }),
320
- async execute(_id, params) {
347
+ async execute(_id, params, signal) {
321
348
  // Claude documents allowed/blocked domains as mutually exclusive; allowed wins.
322
- const outcome = await fetchText(SEARCH_ENDPOINT + encodeURIComponent(params.query), 'follow')
349
+ const outcome = await fetchText(SEARCH_ENDPOINT + encodeURIComponent(params.query), 'follow', signal)
323
350
  const text = outcome.kind === 'body' ? outcome.text : ''
324
351
  const limit = Math.min(params.count ?? 5, 10)
325
352
  const results = filterByDomain(parseSearchResults(text, 10), params.allowed_domains, params.blocked_domains).slice(0, limit)
@@ -356,7 +383,7 @@ export default function webExtension(pi: ExtensionAPI) {
356
383
  if (cached && cached.expires > now) {
357
384
  body = cached.body
358
385
  } else {
359
- const outcome = await fetchText(target, 'report')
386
+ const outcome = await fetchText(target, 'report', signal)
360
387
  // A cross-host redirect has no body to cache or summarize: the naming result is
361
388
  // the answer, and Claude fetches the target with a second call if it wants it.
362
389
  if (outcome.kind === 'redirect') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.75",
3
+ "version": "1.0.77",
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",