pi-code 1.0.65 → 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.
@@ -38,7 +38,7 @@ import * as path from 'node:path'
38
38
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
39
39
  import { claudeConfigDir } from './internal/config-dir.js'
40
40
  import { readManagedSettings } from './internal/managed-settings.js'
41
- import { isProjectApprovedSilently } from './internal/project-approval.js'
41
+ import { approvalRecheck, isProjectApprovedSilently } from './internal/project-approval.js'
42
42
  import { claudeSettingsChain } from './internal/settings-chain.js'
43
43
  import { watchSettingsFiles } from './internal/settings-watch.js'
44
44
  import { isRecord } from './internal/values.js'
@@ -163,7 +163,9 @@ export default function envSettingsExtension(pi: ExtensionAPI) {
163
163
  // The watcher's reapply closes over the cwd value, never ctx: the poll has no awaiter,
164
164
  // and every getter of a replaced session's ctx throws, which would exit pi.
165
165
  const cwd = ctx.cwd
166
- const reapply = (): void => apply(home, approved ? projectEnv(cwd, home) : {})
166
+ // A reload asks again rather than reusing `approved`: see approvalRecheck.
167
+ const stillApproved = approvalRecheck(ctx)
168
+ const reapply = (): void => apply(home, approved && stillApproved() ? projectEnv(cwd, home) : {})
167
169
  reapply()
168
170
  // Claude: "Claude Code watches your settings files and reloads them when they change,
169
171
  // so it applies most edits to the running session without a restart." `env` is not
@@ -177,5 +179,9 @@ export default function envSettingsExtension(pi: ExtensionAPI) {
177
179
  pi.on('session_shutdown', async () => {
178
180
  disposeWatch()
179
181
  disposeWatch = () => {}
182
+ // pi's CLI loads a fresh extension instance for every session replacement, and its
183
+ // empty `owned` cannot restore what this one set: everything is handed back here, and
184
+ // the next instance re-applies managed and user env at factory time, as at startup.
185
+ applyEnvSettings({}, process.env, owned)
180
186
  })
181
187
  }
@@ -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
@@ -251,9 +251,12 @@ function withoutUserConfigShellCommands(raw: string, source: string): string {
251
251
  for (const matchers of Object.values(parsed?.hooks ?? {})) {
252
252
  if (!Array.isArray(matchers)) continue
253
253
  for (const entry of matchers) {
254
- const record = entry as { hooks?: unknown }
255
- if (!Array.isArray(record.hooks)) continue
256
- const kept = record.hooks.filter((hook) => {
254
+ // A malformed entry is left for mergeHooksJson to report by name; this pass must
255
+ // not throw on it, because the inline-manifest caller has no catch.
256
+ if (!isRecord(entry) || !Array.isArray(entry.hooks)) continue
257
+ const record = entry
258
+ const kept = (record.hooks as unknown[]).filter((hook) => {
259
+ if (!isRecord(hook)) return true
257
260
  const candidate = hook as { command?: unknown; args?: unknown }
258
261
  if (Array.isArray(candidate.args) || typeof candidate.command !== 'string' || !USER_CONFIG_REF.test(candidate.command)) return true
259
262
  console.warn(`pi-code-hooks: ignoring a hook in ${source}: a shell-form command cannot reference \${user_config.*}; use exec form with "args", or read CLAUDE_PLUGIN_OPTION_<KEY> from the environment`)
@@ -103,7 +103,7 @@ import { isMcpToolAliases, MCP_TOOLS_CHANNEL } from '../internal/mcp-alias.js'
103
103
  import { resolveModelOverride } from '../internal/model-lookup.js'
104
104
  import { isPlanModeState, PLAN_MODE_CHANNEL } from '../internal/plan-mode-state.js'
105
105
  import { installedPlugins, managedForceEnabled } from '../internal/plugins.js'
106
- import { isProjectApproved } from '../internal/project-approval.js'
106
+ import { approvalRecheck, isProjectApproved } from '../internal/project-approval.js'
107
107
  import { repoRoot } from '../internal/project-root.js'
108
108
  import { watchSettingsFiles } from '../internal/settings-watch.js'
109
109
  import { isSkillHooksEvent, SKILL_HOOKS_CHANNEL } from '../internal/skill-hooks.js'
@@ -457,8 +457,10 @@ export default function hooksExtension(pi: ExtensionAPI) {
457
457
  // closes over the cwd value, never ctx: the poll has no awaiter, and every getter of
458
458
  // a replaced session's ctx throws, which would exit pi as an uncaughtException.
459
459
  const watchCwd = ctx.cwd
460
+ // The reload asks again rather than reusing `trusted`: see approvalRecheck.
461
+ const stillApproved = approvalRecheck(ctx)
460
462
  disposeSettingsWatch()
461
- disposeSettingsWatch = watchSettingsFiles(hookFiles(watchCwd, os.homedir(), trusted), () => resolveConfig(watchCwd, trusted))
463
+ disposeSettingsWatch = watchSettingsFiles(hookFiles(watchCwd, os.homedir(), trusted), () => resolveConfig(watchCwd, trusted && stillApproved()))
462
464
  // A disabled or managed-only resolution leaves config empty (or managed-only),
463
465
  // so the SessionStart run below fires exactly what remains active.
464
466
  pendingSessionContext = []
@@ -135,9 +135,11 @@ function collectCommands(matchers: HookMatcher[] | undefined, applies: (entry: H
135
135
  if (raw.spent === true) continue
136
136
  const hook = withCommand(raw)
137
137
  // Claude runs a handler defined in more than one settings file once; a
138
- // plugin's or skill's copy of the same handler stays separate, and http
139
- // handlers with the same URL but different headers are distinct.
140
- const key = `${hook.origin ?? 'settings'}\n${hook.command}\n${hook.headers ? JSON.stringify(hook.headers) : ''}`
138
+ // plugin's or skill's copy of the same handler stays separate. Handlers that
139
+ // share a command but differ in what they run or when (http headers, exec-form
140
+ // args, an `if` filter, an mcp_tool input) are different handlers: `if` is applied
141
+ // after this, so collapsing two filters left the other tool call with no guard.
142
+ const key = `${hook.origin ?? 'settings'}\n${hook.command}\n${JSON.stringify([hook.headers, hook.args, hook.if, hook.input])}`
141
143
  if (seen.has(key)) continue
142
144
  seen.add(key)
143
145
  result.push(hook)
@@ -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 = ''
@@ -9,6 +9,8 @@
9
9
  * are skipped rather than failing the event.
10
10
  */
11
11
 
12
+ import { sharedSlot } from './shared-slot.js'
13
+
12
14
  export interface AgentRunRequest {
13
15
  /** The agent's task prompt, with `$ARGUMENTS` already substituted. */
14
16
  prompt: string
@@ -29,20 +31,21 @@ export interface AgentRunRequest {
29
31
  /** Run a subagent to completion and return its final assistant text. */
30
32
  export type AgentRunner = (request: AgentRunRequest) => Promise<string>
31
33
 
32
- let runner: AgentRunner | undefined
34
+ const slot = sharedSlot<AgentRunner>('agent-runner')
33
35
 
34
36
  /** The subagent extension registers its runner here; pass undefined to clear it. */
35
37
  export function setAgentRunner(fn: AgentRunner | undefined): void {
36
- runner = fn
38
+ slot.set(fn)
37
39
  }
38
40
 
39
41
  /** Test seam: whether a runner is registered. */
40
42
  export function hasAgentRunner(): boolean {
41
- return runner !== undefined
43
+ return slot.get() !== undefined
42
44
  }
43
45
 
44
46
  /** Run a subagent for an agent hook. Rejects when no runner is registered. */
45
47
  export function runAgent(request: AgentRunRequest): Promise<string> {
48
+ const runner = slot.get()
46
49
  if (!runner) return Promise.reject(new Error('no subagent runner registered for agent hooks'))
47
50
  return runner(request)
48
51
  }
@@ -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
+ }
@@ -7,6 +7,8 @@
7
7
  * callMcpTool. It is the direct-call analogue of the MCP_TOOLS_CHANNEL alias bus.
8
8
  */
9
9
 
10
+ import { sharedSlot } from './shared-slot.js'
11
+
10
12
  export interface McpToolResult {
11
13
  text: string
12
14
  isError: boolean
@@ -14,15 +16,16 @@ export interface McpToolResult {
14
16
 
15
17
  export type McpToolCaller = (server: string, tool: string, input: Record<string, unknown>) => Promise<McpToolResult>
16
18
 
17
- let caller: McpToolCaller | undefined
19
+ const slot = sharedSlot<McpToolCaller>('mcp-tool-caller')
18
20
 
19
21
  /** The mcp extension registers its caller here; pass undefined to clear it. */
20
22
  export function setMcpToolCaller(fn: McpToolCaller | undefined): void {
21
- caller = fn
23
+ slot.set(fn)
22
24
  }
23
25
 
24
26
  /** Invoke a tool on a connected MCP server. Throws when no server is connected. */
25
27
  export function callMcpTool(server: string, tool: string, input: Record<string, unknown>): Promise<McpToolResult> {
28
+ const caller = slot.get()
26
29
  if (!caller) return Promise.reject(new Error(`no MCP server connected to serve ${server}/${tool}`))
27
30
  return caller(server, tool, input)
28
31
  }
@@ -72,5 +72,8 @@ export async function completeText(model: Model<Api>, prompt: string, options: C
72
72
  messages: [{ role: 'user', content: prompt, timestamp: Date.now() }],
73
73
  }
74
74
  const message = await complete(model, context, { maxTokens: options.maxTokens ?? 1024, signal: options.signal })
75
+ // pi-ai reports a provider failure or a fired signal by resolving, never rejecting: the
76
+ // message then has no text, and returned as an answer it is indistinguishable from one.
77
+ if (message.stopReason === 'error' || message.stopReason === 'aborted') throw new Error(message.errorMessage ?? `completion ${message.stopReason}`)
75
78
  return { text: assistantText(message), usage: message.usage }
76
79
  }
@@ -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.
@@ -129,6 +136,22 @@ export function isProjectApprovedSilently(ctx: Pick<ApprovalContext, 'cwd' | 'is
129
136
  return deps.savedDecision(ctx.cwd) === true
130
137
  }
131
138
 
139
+ /**
140
+ * A re-check of the approval decision for code that outlives session_start, such as a
141
+ * settings watcher's reload. The answer given at session_start cannot be reused: a
142
+ * repository with nothing Claude-shaped reads as approved without a question, so config
143
+ * that appears later (a branch checkout, an unpacked archive) would inherit an answer
144
+ * nobody gave. ctx is read once, here: the returned function holds values only, because
145
+ * a poll outlives the session and every getter of a replaced session's ctx throws. It
146
+ * never prompts, so a project that turned Claude-shaped mid-session stays out until the
147
+ * next session asks.
148
+ */
149
+ export function approvalRecheck(ctx: Pick<ApprovalContext, 'cwd' | 'isProjectTrusted'>, deps: ApprovalDeps = defaultDeps): () => boolean {
150
+ const cwd = ctx.cwd
151
+ const piTrusted = ctx.isProjectTrusted?.() === true
152
+ return () => isProjectApprovedSilently({ cwd, isProjectTrusted: () => piTrusted }, deps)
153
+ }
154
+
132
155
  /**
133
156
  * The approval decision for a file that is itself the thing to gate.
134
157
  *
@@ -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)
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import * as fs from 'node:fs'
9
- import { parseNumericEnv } from './values.js'
9
+ import { errorMessage, parseNumericEnv } from './values.js'
10
10
 
11
11
  // Captured at module load: the poll must run on real time even under a test's
12
12
  // fake timers (the stat watcher it replaced lived in libuv and was immune too);
@@ -37,7 +37,13 @@ export function watchSettingsFiles(files: string[], reload: () => void): () => v
37
37
  const next = files.map(snapshot)
38
38
  if (next.some((content, index) => content !== last[index])) {
39
39
  last = next
40
- reload()
40
+ // The poll has no awaiter: a throw here is an uncaughtException, and pi exits on
41
+ // one. A reload that cannot cope with what it read costs that reload, not the session.
42
+ try {
43
+ reload()
44
+ } catch (error) {
45
+ console.warn(`pi-code: settings reload failed: ${errorMessage(error)}`)
46
+ }
41
47
  }
42
48
  }, interval)
43
49
  // A watcher alone must never keep a one-shot run alive.
@@ -0,0 +1,23 @@
1
+ /**
2
+ * A process-wide slot for a function one extension registers and another calls.
3
+ *
4
+ * pi loads every extension entry through its own module graph, so a module-level `let`
5
+ * in a file two extensions import is a separate variable in each: the setter and the
6
+ * caller never meet, and the seam reads as "nothing registered" for the life of the
7
+ * process. `globalThis` is the one object the graphs share, and a registered symbol
8
+ * resolves to the same key from every copy of this module. pi's event bus carries the
9
+ * cross-extension data (see mcp-alias); a slot is for a call that returns a result.
10
+ *
11
+ * Each session replacement loads fresh extension instances, which register again and
12
+ * replace the previous instance's function.
13
+ */
14
+ export function sharedSlot<T>(name: string): { get: () => T | undefined; set: (value: T | undefined) => void } {
15
+ const key = Symbol.for(`pi-code.${name}`)
16
+ const holder = globalThis as Record<symbol, unknown>
17
+ return {
18
+ get: () => holder[key] as T | undefined,
19
+ set: (value) => {
20
+ holder[key] = value
21
+ },
22
+ }
23
+ }
@@ -6,17 +6,20 @@
6
6
  * pattern as mcp-call.
7
7
  */
8
8
 
9
+ import { sharedSlot } from './shared-slot.js'
10
+
9
11
  export type SubagentStartHookRunner = (agentType: string, agentId: string) => Promise<string[]>
10
12
 
11
- let runner: SubagentStartHookRunner | undefined
13
+ const slot = sharedSlot<SubagentStartHookRunner>('subagent-start-hook-runner')
12
14
 
13
15
  export function setSubagentStartHookRunner(fn: SubagentStartHookRunner | undefined): void {
14
- runner = fn
16
+ slot.set(fn)
15
17
  }
16
18
 
17
19
  /** Context strings SubagentStart hooks contribute; empty when no runner is
18
20
  * registered or the runner fails (hooks must never block a spawn). */
19
21
  export async function runSubagentStartHooks(agentType: string, agentId: string): Promise<string[]> {
22
+ const runner = slot.get()
20
23
  if (!runner) return []
21
24
  try {
22
25
  return await runner(agentType, agentId)
@@ -11,7 +11,7 @@ import { claudeConfigDir } from '../internal/config-dir.js'
11
11
  import type { OAuthServerConfig } from '../internal/mcp-oauth.js'
12
12
  import { type InstalledPlugin, pluginComponentPath } from '../internal/plugins.js'
13
13
  import { findNearestFile } from '../internal/project-root.js'
14
- import { errorMessage } from '../internal/values.js'
14
+ import { errorMessage, isRecord } from '../internal/values.js'
15
15
 
16
16
  export interface StdioServerConfig {
17
17
  type?: 'stdio'
@@ -105,12 +105,26 @@ export function projectConfigPaths(cwd: string): string[] {
105
105
  return ['.mcp.json', path.join('.pi', 'mcp.json')].map((rel) => findNearestFile(cwd, rel) ?? path.join(cwd, rel))
106
106
  }
107
107
 
108
+ /** The object entries of a raw `mcpServers` map. A string or null entry (a JSON "comment"
109
+ * key, a nulled-out server) would throw in the connect batch outside its per-server catch
110
+ * and sink every server with it, the project trust prompt included, so it is dropped here
111
+ * by name. */
112
+ function serverEntries(raw: unknown, source: string): Record<string, ServerConfig> {
113
+ const servers: Record<string, ServerConfig> = {}
114
+ for (const [name, entry] of Object.entries(isRecord(raw) ? raw : {})) {
115
+ // Shape only: the fields are validated where a transport is chosen.
116
+ if (isRecord(entry)) servers[name] = entry as unknown as ServerConfig
117
+ else console.warn(`pi-code-mcp: ignoring mcpServers entry "${name}" in ${source}: not an object`)
118
+ }
119
+ return servers
120
+ }
121
+
108
122
  export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
109
123
  const servers: Record<string, ServerConfig> = {}
110
124
  for (const file of files) {
111
125
  try {
112
126
  const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
113
- Object.assign(servers, parsed.mcpServers ?? {})
127
+ Object.assign(servers, serverEntries(parsed?.mcpServers, file))
114
128
  } catch (error) {
115
129
  // A missing file is the normal case. A present file that does not parse is
116
130
  // not: one trailing comma silently disabled every server in it.
@@ -128,7 +142,7 @@ export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
128
142
  */
129
143
  export function loadUserScope(home: string, cwd: string): Record<string, ServerConfig> {
130
144
  const servers = loadConfigFrom(userConfigPaths(home))
131
- Object.assign(servers, projectRecord(home, cwd).mcpServers ?? {})
145
+ Object.assign(servers, serverEntries(projectRecord(home, cwd).mcpServers, claudeJsonPath(home)))
132
146
  return servers
133
147
  }
134
148
 
@@ -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,16 +22,17 @@
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'
32
33
  import { ancestorDirs } from './internal/project-root.js'
33
34
  import { claudeSettingsChain, localSettingsFile, readSettingsChain } from './internal/settings-chain.js'
34
- import { isDirectory } from './internal/values.js'
35
+ import { errorMessage, isDirectory } from './internal/values.js'
35
36
 
36
37
  export interface OutputStyle {
37
38
  name: string
@@ -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,6 +122,25 @@ export function pluginStyleDirs(home: string): string[] {
121
122
  })
122
123
  }
123
124
 
125
+ /** One style file, or undefined when it cannot be used. A directory named *.md or an
126
+ * unreadable file is skipped silently. pi's frontmatter parser is strict YAML and throws:
127
+ * one unparseable file must cost that style, not every style of the session. */
128
+ function readStyleFile(dir: string, entry: string): OutputStyle | undefined {
129
+ const file = path.join(dir, entry)
130
+ let content: string
131
+ try {
132
+ content = fs.readFileSync(file, 'utf-8')
133
+ } catch {
134
+ return undefined
135
+ }
136
+ try {
137
+ return parseStyle(content, entry.replace(/\.md$/, ''))
138
+ } catch (error) {
139
+ console.warn(`pi-code-output-styles: skipping ${file}: ${errorMessage(error).split('\n')[0]}`)
140
+ return undefined
141
+ }
142
+ }
143
+
124
144
  /** All output styles, project entries overriding user entries of the same name. */
125
145
  export function loadStyles(dirs: string[]): OutputStyle[] {
126
146
  const byName = new Map<string, OutputStyle>()
@@ -131,16 +151,9 @@ export function loadStyles(dirs: string[]): OutputStyle[] {
131
151
  } catch {
132
152
  continue
133
153
  }
134
- for (const entry of entries) {
135
- if (!entry.endsWith('.md')) continue
136
- let content: string
137
- try {
138
- content = fs.readFileSync(path.join(dir, entry), 'utf-8')
139
- } catch {
140
- continue // a directory named *.md or an unreadable file must not take down session start
141
- }
142
- const style = parseStyle(content, entry.replace(/\.md$/, ''))
143
- byName.set(style.name, style)
154
+ for (const entry of entries.filter((name) => name.endsWith('.md'))) {
155
+ const style = readStyleFile(dir, entry)
156
+ if (style) byName.set(style.name, style)
144
157
  }
145
158
  }
146
159
  return [...byName.values()]
@@ -190,6 +190,9 @@ export default function question(pi: ExtensionAPI) {
190
190
  description:
191
191
  'Ask the user a question and let them pick from options. Use when you need user input to proceed. Pass question and options, for example {"question": "Which one?", "options": [{"label": "alpha"}, {"label": "beta"}]}. To ask 2-4 questions at once, pass questions instead, with the same fields per entry.',
192
192
  parameters: QuestionParams,
193
+ // The host shows a custom dialog in one editor slot, so two questions running in
194
+ // parallel leave the first unanswerable and the run unable to finish.
195
+ executionMode: 'sequential',
193
196
 
194
197
  async execute(_toolCallId, rawParams, _signal, _onUpdate, ctx) {
195
198
  const specs = questionList(rawParams as Partial<QuestionSpec> & { questions?: QuestionSpec[] })
@@ -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 })
@@ -35,7 +35,7 @@ import { hookFiles, readDisableAllHooks, runHookCommand } from './hooks/index.js
35
35
  import { claudeEffortLevel } from './internal/effort.js'
36
36
  import { readManagedSettings } from './internal/managed-settings.js'
37
37
  import { isPlanModeState, PLAN_MODE_CHANNEL } from './internal/plan-mode-state.js'
38
- import { isProjectApprovedSilently } from './internal/project-approval.js'
38
+ import { approvalRecheck, isProjectApprovedSilently } from './internal/project-approval.js'
39
39
  import { gitRoot, repoRoot } from './internal/project-root.js'
40
40
  import { readSettingsChain } from './internal/settings-chain.js'
41
41
  import { watchSettingsFiles } from './internal/settings-watch.js'
@@ -503,10 +503,15 @@ export default function statusLine(pi: ExtensionAPI) {
503
503
  armRefresh()
504
504
  // Claude re-runs the script when the statusLine settings change mid-session; a
505
505
  // command change re-resolves and re-runs.
506
+ // The reload asks again rather than reusing `trusted` (see approvalRecheck), and reads
507
+ // the chain that answer allows: a statusLine command is executed, not just displayed.
508
+ const watchCwd = ctx.cwd
509
+ const stillApproved = approvalRecheck(ctx)
506
510
  disposeSettingsWatch()
507
511
  disposeSettingsWatch = watchSettingsFiles(files, () => {
508
512
  const previousCommand = config?.command
509
- config = readDisableAllHooks(files) ? undefined : readStatusLineConfig(files)
513
+ const liveFiles = hookFiles(watchCwd, os.homedir(), trusted && stillApproved())
514
+ config = readDisableAllHooks(liveFiles) ? undefined : readStatusLineConfig(liveFiles)
510
515
  armRefresh()
511
516
  // The debounce batches rapid triggers, but a command the user just edited has
512
517
  // nothing to batch with: run it now so the result of the edit is immediate.
@@ -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.
@@ -133,16 +133,22 @@ function appendPartialNote(result: SingleResult): void {
133
133
  * try/catch around an inline spawn() call loses. */
134
134
  export function spawnChild(command: string, args: string[], options: { cwd: string; env: NodeJS.ProcessEnv }): { proc: ChildProcessByStdio<null, Readable, Readable> } | { error: Error } {
135
135
  try {
136
- return {
137
- proc: spawn(command, args, {
138
- ...options,
139
- shell: false,
140
- stdio: ['ignore', 'pipe', 'pipe'],
141
- // Its own group, so an abort reaches grandchildren too: killing only the
142
- // direct child orphans a build or dev server the agent started.
143
- detached: true,
144
- }),
136
+ const proc = spawn(command, args, {
137
+ ...options,
138
+ shell: false,
139
+ stdio: ['ignore', 'pipe', 'pipe'],
140
+ // Its own group, so an abort reaches grandchildren too: killing only the
141
+ // direct child orphans a build or dev server the agent started.
142
+ detached: true,
143
+ })
144
+ // A third failure shape: at the descriptor limit (EMFILE, ENFILE) node returns a child
145
+ // with no stdio and emits 'error' on the next tick. The callers wire stdout first, so
146
+ // the deferred event would find no listener, and an unhandled 'error' exits pi.
147
+ if (!proc.stdout) {
148
+ proc.on('error', () => {})
149
+ return { error: new Error('spawn returned no stdio: the file descriptor limit is likely exhausted (EMFILE)') }
145
150
  }
151
+ return { proc }
146
152
  } catch (error) {
147
153
  return { error: error as Error }
148
154
  }
@@ -338,7 +344,9 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
338
344
  proc.on('close', (code) => {
339
345
  cleanup()
340
346
  if (buffer.trim()) processLine(buffer)
341
- resolve(code ?? 0)
347
+ // A capped run was stopped by this module's own SIGTERM, which pi's print mode
348
+ // answers with exit 143: that is not the agent failing, as background.ts also holds.
349
+ resolve(currentResult.partial ? 0 : (code ?? 0))
342
350
  })
343
351
 
344
352
  proc.on('error', (error: Error) => {
@@ -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.65",
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",