pi-code 1.0.66 → 1.0.67

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.
@@ -263,9 +263,11 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
263
263
  * matches a pathspec through this, so its removal is staged instead of the stale index
264
264
  * entry silently riding along into the next checkpoint. */
265
265
  async function committedPaths(): Promise<Set<string>> {
266
- const listed = await gitShadow(['ls-tree', '-r', '--name-only', 'HEAD'])
266
+ // -z: without it git quotes and octal-escapes a non-ASCII name, and the quoted form
267
+ // is a pathspec that matches nothing, which fails every later add of the session.
268
+ const listed = await gitShadow(['ls-tree', '-r', '--name-only', '-z', 'HEAD'])
267
269
  if (listed.code !== 0) return new Set()
268
- return new Set(listed.stdout.split('\n').filter(Boolean))
270
+ return new Set(listed.stdout.split('\0').filter(Boolean))
269
271
  }
270
272
 
271
273
  /** git rejects the whole pathspec when one entry is ignored, so the ignored paths are
@@ -163,7 +163,16 @@ export const runHookCommand: HookCommandRunner = (command, payload, timeoutMs, {
163
163
  }
164
164
  // On Windows `detached` means DETACHED_PROCESS, which gives a console child its own
165
165
  // window; windowsHide keeps every hook invisible (a no-op elsewhere).
166
- const child = spawn(target.file, target.spawnArgs, { stdio: ['pipe', 'pipe', 'pipe'], detached: true, windowsHide: true, env })
166
+ // node defers only a few spawn errors to the 'error' event; the rest (E2BIG from a
167
+ // payload past the argv limit) throw here, inside the promise executor, which would
168
+ // reject the runner and lose every sibling hook's verdict for the event.
169
+ let child: ReturnType<typeof spawn>
170
+ try {
171
+ child = spawn(target.file, target.spawnArgs, { stdio: ['pipe', 'pipe', 'pipe'], detached: true, windowsHide: true, env })
172
+ } catch (error) {
173
+ resolve({ code: 0, stdout: '', stderr: errorMessage(error), timedOut: false, spawnFailed: true })
174
+ return
175
+ }
167
176
  onChild?.(() => killTree(child))
168
177
  let stdout = ''
169
178
  let stderr = ''
@@ -15,10 +15,8 @@
15
15
 
16
16
  import * as fs from 'node:fs'
17
17
  import * as path from 'node:path'
18
-
19
- import { parseFrontmatter } from '@earendil-works/pi-coding-agent'
20
-
21
18
  import { CLAUDE_TOOL_MAP } from './claude-tool-names.js'
19
+ import { parseClaudeFrontmatter } from './frontmatter.js'
22
20
  import { escapeRegExp } from './values.js'
23
21
 
24
22
  /** The pi file tools a Claude path rule can govern. */
@@ -291,7 +289,7 @@ export function parseCommandFile(content: string): ParsedCommand {
291
289
  // pi's own parser, rather than a hand-rolled one: it reads the YAML shapes Claude
292
290
  // command files actually use (flow sequences, block lists, quoted and multi-line
293
291
  // values), and a value this misreads is a restriction silently not applied.
294
- const { frontmatter, body: raw } = parseFrontmatter(content)
292
+ const { frontmatter, body: raw } = parseClaudeFrontmatter(content)
295
293
  const body = raw.trim()
296
294
  const firstLine = body.split('\n').find((line) => line.trim().length > 0) ?? ''
297
295
  const disable = frontmatter['disable-model-invocation']
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Frontmatter of the markdown files Claude Code defines: agents, commands, skills and
3
+ * output styles.
4
+ *
5
+ * pi's parser is strict YAML. Claude's is not: its own /agents generator and Anthropic's
6
+ * plugins write `description: Use this agent when ... Examples: <example>Context: ...`,
7
+ * and the command reference documents `argument-hint: [pr-number] [priority]`. Strict
8
+ * YAML throws on ": " inside a plain scalar and on a second flow sequence, and a throw
9
+ * costs the whole file, so files that work in Claude Code did not exist here.
10
+ *
11
+ * The strict parse is tried first and kept whenever it succeeds. Only when it throws are
12
+ * the free-text fields quoted and the parse retried. The restriction fields
13
+ * (allowed-tools, tools, model, ...) are never rewritten: a value misread there is a
14
+ * restriction silently not applied, so a file whose restrictions do not parse stays
15
+ * rejected.
16
+ */
17
+
18
+ import { parseFrontmatter } from '@earendil-works/pi-coding-agent'
19
+
20
+ /** Top-level keys whose value is prose. `name` is an identifier and stays strict. */
21
+ const FREE_TEXT_KEYS = new Set(['description', 'argument-hint', 'when_to_use'])
22
+
23
+ /** A plain scalar, as opposed to a quoted one or a block scalar indicator. */
24
+ const isPlainScalar = (value: string): boolean => value !== '' && !'"\'|>'.includes(value[0])
25
+
26
+ /** `line` with its free-text plain scalar quoted. A JSON string is a YAML double-quoted
27
+ * scalar, and its escaping keeps a literal backslash literal, as a plain scalar does. */
28
+ function quoteFreeText(line: string): string {
29
+ const colon = line.indexOf(':')
30
+ if (colon === -1 || !FREE_TEXT_KEYS.has(line.slice(0, colon))) return line
31
+ const value = line.slice(colon + 1).trim()
32
+ return isPlainScalar(value) ? `${line.slice(0, colon)}: ${JSON.stringify(value)}` : line
33
+ }
34
+
35
+ /** `content` with the free-text fields of its frontmatter block quoted, or undefined
36
+ * when there is no block or nothing in it changed. */
37
+ function withQuotedFreeText(content: string): string | undefined {
38
+ if (!content.startsWith('---')) return undefined
39
+ const start = content.indexOf('\n') + 1
40
+ const end = content.indexOf('\n---', start)
41
+ if (start === 0 || end === -1) return undefined
42
+ const block = content.slice(start, end)
43
+ const quoted = block.split('\n').map(quoteFreeText).join('\n')
44
+ return quoted === block ? undefined : content.slice(0, start) + quoted + content.slice(end)
45
+ }
46
+
47
+ export function parseClaudeFrontmatter<T extends Record<string, unknown>>(content: string): { frontmatter: T; body: string } {
48
+ try {
49
+ return parseFrontmatter<T>(content)
50
+ } catch (error) {
51
+ const retry = withQuotedFreeText(content)
52
+ if (retry === undefined) throw error
53
+ try {
54
+ return parseFrontmatter<T>(retry)
55
+ } catch {
56
+ throw error
57
+ }
58
+ }
59
+ }
@@ -23,6 +23,7 @@ import * as path from 'node:path'
23
23
  import { getAgentDir, hasTrustRequiringProjectResources, ProjectTrustStore } from '@earendil-works/pi-coding-agent'
24
24
 
25
25
  import { ROOT_MARKERS } from './project-root.js'
26
+ import { localSettingsFile } from './settings-chain.js'
26
27
 
27
28
  /** Project files pi-code acts on that pi's own trust check does not look for. */
28
29
  const CLAUDE_SHAPED = [
@@ -52,6 +53,12 @@ const CLAUDE_SHAPED = [
52
53
  * home that is in no repository would otherwise walk up into it and report the user's
53
54
  * own settings as a project waiting to be approved. */
54
55
  export function hasClaudeShapedConfig(cwd: string, home: string = os.homedir()): boolean {
56
+ // settings.local.json is read from the main checkout, which a worktree's .git file
57
+ // names and which is a sibling of cwd, never on the walk below. An archive can carry
58
+ // both ends of that pointer, so the file the chain will read is checked where it is.
59
+ // At home the file is the user's own, as the walk also holds.
60
+ const relocated = localSettingsFile(cwd, home)
61
+ if (path.dirname(path.dirname(relocated)) !== home && fs.existsSync(relocated)) return true
55
62
  let currentDir = cwd
56
63
  while (true) {
57
64
  // The home check comes first: at home itself the .claude found is the user's own.
@@ -77,9 +77,26 @@ function mainCheckout(root: string): string {
77
77
  const marker = `${path.sep}.git${path.sep}worktrees${path.sep}`
78
78
  const cut = worktreeDir.lastIndexOf(marker)
79
79
  if (cut === -1) return root
80
+ // The .git file is attacker-writable: an unpacked archive can ship one naming any
81
+ // directory, and following it as text moved the repository root, and with it the
82
+ // CLAUDE.md import boundary, to wherever it pointed.
83
+ if (!pointsBackAt(worktreeDir, dotGit)) return root
80
84
  return worktreeDir.slice(0, cut)
81
85
  }
82
86
 
87
+ /** Whether a worktree's admin directory names `dotGit` as its worktree. git writes
88
+ * `<main>/.git/worktrees/<name>/gitdir` holding the path of the worktree's `.git` file
89
+ * (relative to that directory since git 2.48), and nothing outside an archive can be made
90
+ * to. Compared by realpath: git records the real path, a session cwd may be a symlink. */
91
+ function pointsBackAt(worktreeDir: string, dotGit: string): boolean {
92
+ try {
93
+ const [line = ''] = fs.readFileSync(path.join(worktreeDir, 'gitdir'), 'utf-8').split('\n')
94
+ return fs.realpathSync(path.resolve(worktreeDir, line.trim())) === fs.realpathSync(dotGit)
95
+ } catch {
96
+ return false
97
+ }
98
+ }
99
+
83
100
  function statOf(target: string): fs.Stats | null {
84
101
  try {
85
102
  return fs.statSync(target)
@@ -277,14 +277,22 @@ export function capIndexForPrompt(index: string): string {
277
277
  return `${text}\n(${dropped} more memories not shown; use the memory tool with action "list")`
278
278
  }
279
279
 
280
+ /** A memory's file name. Letters and digits of any script survive: an ASCII-only slug
281
+ * reduced every non-Latin name to one fallback, so distinct memories overwrote each
282
+ * other. `memory` is never produced, because on a case-insensitive filesystem memory.md
283
+ * is the MEMORY.md index, and writing one destroys the other. Cut by code point, so a
284
+ * character outside the basic plane is never split. */
280
285
  export function slugifyName(name: string): string {
281
- return (
282
- name
286
+ const slug = [
287
+ ...name
283
288
  .toLowerCase()
284
- .replaceAll(/[^a-z0-9]+/g, '-')
285
- .replaceAll(/^-|-$/g, '')
286
- .slice(0, 64) || 'memory'
287
- )
289
+ .replaceAll(/[^\p{L}\p{N}]+/gu, '-')
290
+ .replaceAll(/^-|-$/g, ''),
291
+ ]
292
+ .slice(0, 64)
293
+ .join('')
294
+ if (slug === '') return 'untitled'
295
+ return slug === 'memory' ? 'memory-note' : slug
288
296
  }
289
297
 
290
298
  /** The exact key prefix of a memory's index line; matching on a substring would also
@@ -22,10 +22,11 @@
22
22
  import * as fs from 'node:fs'
23
23
  import * as os from 'node:os'
24
24
  import * as path from 'node:path'
25
- import { type ExtensionAPI, parseFrontmatter } from '@earendil-works/pi-coding-agent'
25
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
26
26
  import { atomicWriteFile } from './internal/atomic-write.js'
27
27
  import { isFlagEnabled } from './internal/command-file.js'
28
28
  import { claudeConfigDir } from './internal/config-dir.js'
29
+ import { parseClaudeFrontmatter } from './internal/frontmatter.js'
29
30
  import { readManagedSettings } from './internal/managed-settings.js'
30
31
  import { installedPlugins, pluginComponentPath } from './internal/plugins.js'
31
32
  import { isProjectApproved } from './internal/project-approval.js'
@@ -54,7 +55,7 @@ function field(frontmatter: Record<string, unknown>, key: string): string {
54
55
  * own YAML frontmatter parser, as the command loader uses: a line regex captured to
55
56
  * end of line, so `"Terse" # short` came back as `Terse" # short`. */
56
57
  export function parseStyle(content: string, fallbackName: string): OutputStyle {
57
- const { frontmatter, body } = parseFrontmatter(content)
58
+ const { frontmatter, body } = parseClaudeFrontmatter(content)
58
59
  return {
59
60
  name: field(frontmatter, 'name') || fallbackName,
60
61
  description: field(frontmatter, 'description'),
@@ -121,12 +121,14 @@ export default function sessionTitleExtension(pi: ExtensionAPI) {
121
121
  return // no model, provider error: leave the session untitled (best-effort)
122
122
  }
123
123
  if (!title) return
124
- // A /new during the await moved us to a different session, or a name has since been set;
125
- // applying this title now would rename the wrong session, so drop it.
126
- if (generation !== startedGeneration || pi.getSessionName?.()) return
127
124
  // Post-await ctx getters throw once the session is disposed, and an escaping rejection
128
- // from this un-awaited settle can exit pi; apply the title best-effort.
125
+ // from this un-awaited settle can exit pi; apply the title best-effort. The name read
126
+ // belongs inside the guard too: pi's CLI never delivers the next session_start to this
127
+ // instance, so only that throw reveals a session replaced during the call.
129
128
  try {
129
+ // A /new during the await moved us to a different session, or a name has since been
130
+ // set; applying this title now would rename the wrong session, so drop it.
131
+ if (generation !== startedGeneration || pi.getSessionName?.()) return
130
132
  // pi.setSessionName refreshes the terminal/tab title natively (pi changelog), so a
131
133
  // separate ctx.ui.setTitle call would only duplicate that. The guard stays: a
132
134
  // disposed session throws from setSessionName post-await, and an escaping rejection
@@ -22,11 +22,12 @@
22
22
  import * as fs from 'node:fs'
23
23
  import * as os from 'node:os'
24
24
  import * as path from 'node:path'
25
- import { type ExtensionAPI, type ExtensionContext, parseFrontmatter } from '@earendil-works/pi-coding-agent'
25
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
26
26
  import { expandCommand, shellExecutionDisabled } from './commands.js'
27
27
  import { runAgent } from './internal/agent-run.js'
28
28
  import { parseCommandFile } from './internal/command-file.js'
29
29
  import { claudeConfigDir } from './internal/config-dir.js'
30
+ import { parseClaudeFrontmatter } from './internal/frontmatter.js'
30
31
  import { managedSettingsFile } from './internal/managed-settings.js'
31
32
  import { installedPlugins, pluginComponentPath } from './internal/plugins.js'
32
33
  import { isProjectApprovedSilently } from './internal/project-approval.js'
@@ -103,7 +104,7 @@ function skillAt(root: string, dirName: string): { name: string; filePath: strin
103
104
  }
104
105
  let name = dirName
105
106
  try {
106
- const declared = parseFrontmatter<Record<string, unknown>>(content).frontmatter.name
107
+ const declared = parseClaudeFrontmatter<Record<string, unknown>>(content).frontmatter.name
107
108
  if (typeof declared === 'string' && declared.trim()) name = declared.trim()
108
109
  } catch {
109
110
  // Malformed frontmatter: pi's loader falls back to the directory name too.
@@ -210,7 +211,7 @@ async function expandSkillInvocation(pi: ExtensionAPI, rawText: string, ctx: Ext
210
211
  // Claude registers hooks a skill's frontmatter declares when the skill is
211
212
  // invoked, for the rest of the session; the hooks extension owns running them,
212
213
  // so the declaration is announced over the shared bus.
213
- const frontmatter = parseFrontmatter<Record<string, unknown>>(content).frontmatter
214
+ const frontmatter = parseClaudeFrontmatter<Record<string, unknown>>(content).frontmatter
214
215
  const declaredHooks = frontmatter.hooks
215
216
  if (declaredHooks !== null && typeof declaredHooks === 'object' && !Array.isArray(declaredHooks)) {
216
217
  pi.events?.emit(SKILL_HOOKS_CHANNEL, { skillName: name, hooks: declaredHooks })
@@ -5,12 +5,13 @@
5
5
  import * as fs from 'node:fs'
6
6
  import * as os from 'node:os'
7
7
  import * as path from 'node:path'
8
- import { getAgentDir, parseFrontmatter, stripFrontmatter } from '@earendil-works/pi-coding-agent'
8
+ import { getAgentDir, stripFrontmatter } from '@earendil-works/pi-coding-agent'
9
9
  // The same mapping a command's `allowed-tools` gets: an agent's `tools:` is the same
10
10
  // Claude field, and `--tools` is an exact-name allowlist, so a name pi has no tool for
11
11
  // is not merely ignored, it narrows the child's registry.
12
12
  import { parseToolGrants } from '../internal/command-file.js'
13
13
  import { claudeConfigDir } from '../internal/config-dir.js'
14
+ import { parseClaudeFrontmatter } from '../internal/frontmatter.js'
14
15
  import { findModel } from '../internal/model-lookup.js'
15
16
  import { installedPlugins, pluginComponentPath } from '../internal/plugins.js'
16
17
  import { ancestorDirs, findNearestDir } from '../internal/project-root.js'
@@ -176,7 +177,7 @@ function agentName(frontmatter: Record<string, unknown>, filePath: string, plugi
176
177
  function parseAgentFile(content: string, source: AgentSource, filePath: string, pluginName?: string): AgentConfig | null {
177
178
  let parsed: { frontmatter: Record<string, unknown>; body: string }
178
179
  try {
179
- parsed = parseFrontmatter<Record<string, unknown>>(content)
180
+ parsed = parseClaudeFrontmatter<Record<string, unknown>>(content)
180
181
  } catch (error) {
181
182
  // Malformed YAML must not abort discovery for the whole directory, but a silent drop
182
183
  // reads as "that agent does not exist", so it is named like the other rejections here.
@@ -83,8 +83,10 @@ export default function thinkingExtension(pi: ExtensionAPI) {
83
83
  const current = pi.getThinkingLevel?.() ?? ctx.thinkingLevel ?? 'off'
84
84
  // A keyword only raises reasoning: leave a level already at or above the target.
85
85
  if (thinkingRank(current) >= thinkingRank(target)) return
86
- override.arm(current, target)
86
+ // Armed with the level pi actually stored: it clamps to what the model supports, and
87
+ // an override armed with the level asked for never recognises its own escalation.
87
88
  pi.setThinkingLevel?.(target)
89
+ override.arm(current, pi.getThinkingLevel?.() ?? target)
88
90
  // Return nothing so the input is neither consumed nor transformed: Claude keeps
89
91
  // the keyword in the prompt.
90
92
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "1.0.66",
3
+ "version": "1.0.67",
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",