pi-code 1.0.76 → 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.
@@ -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
@@ -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
@@ -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
  }
@@ -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) {
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.76",
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",