pi-code 1.0.63 → 1.0.65

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.
Files changed (50) hide show
  1. package/README.md +1 -1
  2. package/extensions/commands.ts +9 -6
  3. package/extensions/context-imports.ts +3 -3
  4. package/extensions/env-settings.ts +5 -2
  5. package/extensions/git-checkpoint.ts +137 -21
  6. package/extensions/hooks/config.ts +16 -10
  7. package/extensions/hooks/decisions.ts +9 -9
  8. package/extensions/hooks/index.ts +22 -11
  9. package/extensions/hooks/matcher.ts +12 -7
  10. package/extensions/hooks/runners.ts +7 -28
  11. package/extensions/internal/agent-run.ts +1 -1
  12. package/extensions/internal/bash-rules.ts +1 -2
  13. package/extensions/internal/claude-tool-names.ts +4 -7
  14. package/extensions/internal/command-file.ts +2 -3
  15. package/extensions/internal/external-imports.ts +4 -5
  16. package/extensions/internal/goal-evaluator.ts +4 -3
  17. package/extensions/internal/instruction-events.ts +5 -5
  18. package/extensions/internal/managed-settings.ts +1 -1
  19. package/extensions/internal/mcp-oauth.ts +13 -3
  20. package/extensions/internal/model-complete.ts +7 -1
  21. package/extensions/internal/model-lookup.ts +10 -3
  22. package/extensions/internal/path-rules.ts +10 -13
  23. package/extensions/internal/plugins.ts +5 -5
  24. package/extensions/internal/process-tree.ts +39 -0
  25. package/extensions/internal/project-approval.ts +7 -7
  26. package/extensions/internal/project-root.ts +13 -13
  27. package/extensions/internal/settings-chain.ts +19 -0
  28. package/extensions/internal/settings-watch.ts +3 -1
  29. package/extensions/internal/shell-resolve.ts +3 -1
  30. package/extensions/internal/tool-target.ts +4 -5
  31. package/extensions/internal/values.ts +20 -4
  32. package/extensions/mcp/config.ts +11 -4
  33. package/extensions/mcp/index.ts +40 -4
  34. package/extensions/mcp/policy.ts +14 -12
  35. package/extensions/mcp/transport.ts +2 -8
  36. package/extensions/memory.ts +4 -4
  37. package/extensions/notify.ts +2 -7
  38. package/extensions/output-styles.ts +22 -17
  39. package/extensions/question.ts +2 -7
  40. package/extensions/skills.ts +5 -5
  41. package/extensions/status-line.ts +4 -0
  42. package/extensions/subagent/agents.ts +4 -4
  43. package/extensions/subagent/background.ts +35 -41
  44. package/extensions/subagent/child.ts +2 -2
  45. package/extensions/subagent/modes.ts +18 -13
  46. package/extensions/subagent/params.ts +1 -2
  47. package/extensions/subagent/run.ts +3 -14
  48. package/extensions/thinking.ts +3 -3
  49. package/extensions/web.ts +3 -4
  50. package/package.json +7 -7
@@ -6,10 +6,12 @@
6
6
  import * as fs from 'node:fs'
7
7
  import * as os from 'node:os'
8
8
  import * as path from 'node:path'
9
+ import { getAgentDir } from '@earendil-works/pi-coding-agent'
9
10
  import { claudeConfigDir } from '../internal/config-dir.js'
10
11
  import type { OAuthServerConfig } from '../internal/mcp-oauth.js'
11
12
  import { type InstalledPlugin, pluginComponentPath } from '../internal/plugins.js'
12
13
  import { findNearestFile } from '../internal/project-root.js'
14
+ import { errorMessage } from '../internal/values.js'
13
15
 
14
16
  export interface StdioServerConfig {
15
17
  type?: 'stdio'
@@ -91,7 +93,9 @@ function claudeJsonPath(home: string): string {
91
93
  /** User-scoped MCP config (the user's own; safe to load without project trust). The .pi
92
94
  * tree is pi's own and is not relocated by CLAUDE_CONFIG_DIR. */
93
95
  export function userConfigPaths(home: string): string[] {
94
- return [claudeJsonPath(home), path.join(home, '.pi', 'agent', 'mcp.json')]
96
+ // mcp.json lives in pi's agent directory, which PI_CODING_AGENT_DIR relocates; the
97
+ // other agent-directory readers (trust store, OAuth tokens) already follow it.
98
+ return [claudeJsonPath(home), path.join(getAgentDir(), 'mcp.json')]
95
99
  }
96
100
 
97
101
  /** Project-scoped MCP config, each file the nearest of its name at or above cwd
@@ -107,8 +111,10 @@ export function loadConfigFrom(files: string[]): Record<string, ServerConfig> {
107
111
  try {
108
112
  const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'))
109
113
  Object.assign(servers, parsed.mcpServers ?? {})
110
- } catch {
111
- // missing or invalid file: skip silently, /mcp reports what loaded
114
+ } catch (error) {
115
+ // A missing file is the normal case. A present file that does not parse is
116
+ // not: one trailing comma silently disabled every server in it.
117
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') console.warn(`pi-code-mcp: ignoring ${file}: ${errorMessage(error)}`)
112
118
  }
113
119
  }
114
120
  return servers
@@ -132,7 +138,8 @@ function projectRecord(home: string, cwd: string): { mcpServers?: Record<string,
132
138
  try {
133
139
  const claudeJson = JSON.parse(fs.readFileSync(claudeJsonPath(home), 'utf-8'))
134
140
  return claudeJson.projects?.[cwd] ?? {}
135
- } catch {
141
+ } catch (error) {
142
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') console.warn(`pi-code-mcp: ignoring ${claudeJsonPath(home)}: ${errorMessage(error)}`)
136
143
  return {}
137
144
  }
138
145
  }
@@ -53,8 +53,7 @@ import { type AuthUi, callRequestOptions, callTimeoutMs, connect, connectTimeout
53
53
 
54
54
  export { managedSettingsPath, setManagedSettingsPath } from '../internal/managed-settings.js'
55
55
  // Re-exports for consumers: the module split keeps the extension's public surface
56
- // (imported by the test suite) reachable from this entry point unchanged. The managed
57
- // settings path helpers now live in the shared internal module.
56
+ // (imported by the test suite) reachable from this entry point unchanged.
58
57
  export type { HttpServerConfig, ServerConfig, StdioServerConfig } from './config.js'
59
58
  export { expandCwd, interpolateEnv, loadConfigFrom, loadPluginServers, loadUserScope, projectConfigPaths, userConfigPaths, warnOnTypelessUrl } from './config.js'
60
59
  export type { McpToolInfo } from './listing.js'
@@ -85,6 +84,12 @@ function authUiFor(ctx: ExtensionContext): AuthUi | undefined {
85
84
 
86
85
  export default async function mcpExtension(pi: ExtensionAPI) {
87
86
  const clients = new Map<string, Client>()
87
+ // Names with a connect in flight. A name enters `clients` only once its connect
88
+ // resolves, and shutdown clears that map without awaiting anything in flight, so
89
+ // without this set a session switch during a slow connect (or a backoff sleep) let
90
+ // the next session_start connect the same name again; the first client then resolved
91
+ // into an entry the second overwrote and was never closed.
92
+ const connecting = new Set<string>()
88
93
  // The session's OAuth UI seams, captured at session_start. The reconnect paths below
89
94
  // run outside that handler, and passing undefined there made an INTERACTIVE session
90
95
  // report the headless "cannot log in" advice on a re-auth it could actually perform.
@@ -99,6 +104,11 @@ export default async function mcpExtension(pi: ExtensionAPI) {
99
104
  // Shutdown closes clients while they are still in the map; the onclose handlers
100
105
  // must not schedule reconnects for that deliberate teardown.
101
106
  let shuttingDown = false
107
+ // Bumped by every session_start and session_shutdown. A late-connecting server's
108
+ // deferred summary captured the ctx of the session that started it; pi disposes that
109
+ // ctx on /new, /resume, /fork and reload (any use of it throws), so the publish must
110
+ // check it still belongs to the live session before touching ctx.
111
+ let sessionGeneration = 0
102
112
  const callTuning = (name: string): ServerCallTuning => {
103
113
  const config = serverConfigs.get(name)
104
114
  return config ? serverCallTuning(config) : {}
@@ -189,7 +199,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
189
199
  async function reconnectWithBackoff(name: string, config: ServerConfig): Promise<void> {
190
200
  for (let attempt = 0; attempt < 5; attempt++) {
191
201
  await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** attempt))
192
- if (shuttingDown || clients.has(name)) return
202
+ if (shuttingDown || clients.has(name) || connecting.has(name)) return
193
203
  await connectServers({ [name]: config }, sessionAuthUi, true)
194
204
  if (clients.has(name)) return
195
205
  }
@@ -410,10 +420,15 @@ export default async function mcpExtension(pi: ExtensionAPI) {
410
420
  console.warn(`pi-code-mcp: skipping duplicate server name ${name}`)
411
421
  continue
412
422
  }
423
+ // The one guard for every connect path (session_start scopes, backoff, auth
424
+ // reconnect): a name still connecting from the previous session is adopted when
425
+ // that connect resolves, not connected again.
426
+ if (connecting.has(name)) continue
413
427
  // Seed in config order before connecting: parallel connects settle in completion
414
428
  // order, and /mcp plus the session summary iterate the map's insertion order.
415
429
  status.set(name, { state: 'connecting', tools: 0 })
416
430
  serverConfigs.set(name, config)
431
+ connecting.add(name)
417
432
  pending.push([name, config])
418
433
  }
419
434
  await Promise.all(
@@ -423,6 +438,14 @@ export default async function mcpExtension(pi: ExtensionAPI) {
423
438
  // First connections retry transient failures (Claude: up to three times for
424
439
  // HTTP/SSE); the backoff reconnect below carries its own schedule instead.
425
440
  const client = noRetry ? await connect(name, config, authUi, sessionDirs) : await connectWithRetries(name, config, authUi, sessionDirs)
441
+ // A session switch resets shuttingDown before this resolves and adopts the
442
+ // client; still set, pi is exiting and the shutdown handler already closed
443
+ // every client it could see, so this late one must close itself.
444
+ if (shuttingDown) {
445
+ await withTimeout(client.close(), 3000, 'close').catch(() => {})
446
+ status.delete(name)
447
+ return
448
+ }
426
449
  clients.set(name, client)
427
450
  const tools = await withTimeout(listAllTools(client), connectTimeoutMs(), `list tools ${name}`)
428
451
  registerTools(name, config, tools)
@@ -460,6 +483,8 @@ export default async function mcpExtension(pi: ExtensionAPI) {
460
483
  clients.delete(name)
461
484
  void leaked.close().catch(() => {})
462
485
  }
486
+ } finally {
487
+ connecting.delete(name)
463
488
  }
464
489
  }),
465
490
  )
@@ -587,6 +612,7 @@ export default async function mcpExtension(pi: ExtensionAPI) {
587
612
  // withdrawn tool keeps its registration and surfaces the server's own error), which
588
613
  // is why serverToolCount reads from `registered` to recover the true count here.
589
614
  status.clear()
615
+ const generation = ++sessionGeneration
590
616
  // A same-process session switch (/new, /resume) shut the last session down;
591
617
  // this one may reconnect again. projectConnected guards against connecting the project
592
618
  // scope twice within one session, so it belongs to the session that set it: leaving it
@@ -629,13 +655,23 @@ export default async function mcpExtension(pi: ExtensionAPI) {
629
655
  () => false,
630
656
  )
631
657
  publishConnectionSummary(ctx)
632
- if (!settled) void connecting.then(() => publishConnectionSummary(ctx))
658
+ if (!settled) {
659
+ // The rejection path exists for the throw a disposed ctx would raise if the
660
+ // generation check were ever wrong: from a bare `.then` it would be an unhandled
661
+ // rejection, and pi installs no handler, so Node would take the process down.
662
+ void connecting
663
+ .then(() => {
664
+ if (generation === sessionGeneration) publishConnectionSummary(ctx)
665
+ })
666
+ .catch(() => {})
667
+ }
633
668
  })
634
669
 
635
670
  pi.on('session_shutdown', async () => {
636
671
  // Closing fires each client's onclose while it is still in the map; the flag
637
672
  // stops those handlers (and any in-flight backoff loop) from reconnecting.
638
673
  shuttingDown = true
674
+ sessionGeneration++
639
675
  // Close in parallel with a per-client timeout so one hung server can't stall pi's exit.
640
676
  await Promise.all([...clients.values()].map((client) => withTimeout(client.close(), 3000, 'close').catch(() => {})))
641
677
  // Drop the closed clients and their status now rather than waiting on each client's
@@ -6,10 +6,9 @@
6
6
 
7
7
  import * as fs from 'node:fs'
8
8
  import * as path from 'node:path'
9
- import { claudeConfigDir } from '../internal/config-dir.js'
10
9
  import { managedSettingsFile } from '../internal/managed-settings.js'
11
- import { findNearestFile } from '../internal/project-root.js'
12
- import { errorMessage } from '../internal/values.js'
10
+ import { claudeSettingsChain } from '../internal/settings-chain.js'
11
+ import { errorMessage, escapeRegExp } from '../internal/values.js'
13
12
  import { interpolateEnv, type ServerConfig } from './config.js'
14
13
 
15
14
  export interface ProjectServerPolicy {
@@ -38,11 +37,17 @@ export function projectServerPolicy(cwd: string, home: string, projectApproved:
38
37
  }
39
38
  }
40
39
  const names = (value: unknown): string[] => (Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [])
41
- const userSettings = read(path.join(claudeConfigDir(home), 'settings.json'))
42
- const projectSettings = read(findNearestFile(cwd, path.join('.claude', 'settings.json')) ?? path.join(cwd, '.claude', 'settings.json'))
43
- const localSettings = read(findNearestFile(cwd, path.join('.claude', 'settings.local.json')) ?? path.join(cwd, '.claude', 'settings.local.json'))
44
- const disabled = new Set([...names(userSettings.disabledMcpjsonServers), ...names(projectSettings.disabledMcpjsonServers), ...names(localSettings.disabledMcpjsonServers)])
45
- const consentSources = projectApproved ? [userSettings, localSettings] : [userSettings]
40
+ // The files come from the one settings chain, so placement follows Claude's rules
41
+ // everywhere: the project's settings.json is read from cwd only (never an ancestor),
42
+ // and settings.local.json from the main checkout, with the chain's legacy cwd copy
43
+ // read too. Resolving them by nearest-file here gave a subdirectory session an
44
+ // ancestor's project file and a worktree session its own local file instead.
45
+ const [userFile, projectFile, ...localFiles] = claudeSettingsChain(cwd, home, true)
46
+ const userSettings = read(userFile)
47
+ const projectSettings = read(projectFile)
48
+ const localSettings = localFiles.map(read)
49
+ const disabled = new Set([...names(userSettings.disabledMcpjsonServers), ...names(projectSettings.disabledMcpjsonServers), ...localSettings.flatMap((settings) => names(settings.disabledMcpjsonServers))])
50
+ const consentSources = projectApproved ? [userSettings, ...localSettings] : [userSettings]
46
51
  const consented = new Set(consentSources.flatMap((settings) => names(settings.enabledMcpjsonServers)))
47
52
  const consentAll = consentSources.some((settings) => settings.enableAllProjectMcpServers === true)
48
53
  return { disabled, consented, consentAll }
@@ -127,10 +132,7 @@ export function mcpAllowDeny(scopeFiles: string[] = [], managedFile: string = ma
127
132
 
128
133
  /** `*` in a policy URL pattern matches any run of characters; everything else is literal. */
129
134
  function wildcardRegExp(pattern: string): RegExp {
130
- const source = pattern
131
- .split('*')
132
- .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`))
133
- .join('.*')
135
+ const source = pattern.split('*').map(escapeRegExp).join('.*')
134
136
  return new RegExp(`^${source}$`)
135
137
  }
136
138
 
@@ -18,6 +18,7 @@ import { WebSocketClientTransport } from '@modelcontextprotocol/sdk/client/webso
18
18
  import { ListRootsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
19
19
  import { FileOAuthProvider, type OAuthServerConfig } from '../internal/mcp-oauth.js'
20
20
  import { resolveShell } from '../internal/shell-resolve.js'
21
+ import { parseNumericEnv } from '../internal/values.js'
21
22
  import { expandCwd, type HttpServerConfig, interpolateEnv, type ServerConfig, type StdioServerConfig } from './config.js'
22
23
  import { runInteractiveOAuth, serializeInteractiveOAuth } from './oauth-flow.js'
23
24
 
@@ -36,19 +37,12 @@ const DEFAULT_STDIO_CALL_IDLE_TIMEOUT_MS = 1_800_000
36
37
 
37
38
  /** Claude's numeric env vars accept scientific notation and digit-separator spellings
38
39
  * (2e3 as 2000, 64_000 as 64000). A non-numeric value is undefined, not zero. */
39
- function parseNumericEnv(raw: string): number | undefined {
40
- const cleaned = raw.replaceAll('_', '')
41
- if (cleaned.trim() === '') return undefined
42
- const value = Number(cleaned)
43
- return Number.isFinite(value) ? Math.floor(value) : undefined
44
- }
45
-
46
40
  /** A positive-integer env override, or the default when unset or unparseable. */
47
41
  function envTimeout(name: string, fallback: number): number {
48
42
  const raw = process.env[name]
49
43
  if (raw === undefined) return fallback
50
44
  const value = parseNumericEnv(raw)
51
- return value !== undefined && value > 0 ? value : fallback
45
+ return value !== undefined && value > 0 ? Math.floor(value) : fallback
52
46
  }
53
47
 
54
48
  // Claude honors MCP_TIMEOUT (connect) and MCP_TOOL_TIMEOUT (per-call), both in ms.
@@ -12,7 +12,7 @@ import * as fs from 'node:fs'
12
12
  import * as os from 'node:os'
13
13
  import * as path from 'node:path'
14
14
  import { StringEnum } from '@earendil-works/pi-ai'
15
- import { type ExtensionAPI, withFileMutationQueue } from '@earendil-works/pi-coding-agent'
15
+ import { type ExtensionAPI, getAgentDir, withFileMutationQueue } from '@earendil-works/pi-coding-agent'
16
16
  import { Type } from 'typebox'
17
17
  import { atomicWriteFile } from './internal/atomic-write.js'
18
18
  import { claudeConfigDir } from './internal/config-dir.js'
@@ -63,7 +63,7 @@ function memoryProject(cwd: string): string {
63
63
  }
64
64
 
65
65
  export function memoryDir(cwd: string): string {
66
- return path.join(os.homedir(), '.pi', 'agent', 'memory', projectSlug(memoryProject(cwd)))
66
+ return path.join(getAgentDir(), 'memory', projectSlug(memoryProject(cwd)))
67
67
  }
68
68
 
69
69
  /** The store location, honoring an `autoMemoryDirectory` override. Claude requires
@@ -142,7 +142,7 @@ export function stripNonLoaded(text: string): string {
142
142
  export function migrateLegacyStore(cwd: string): void {
143
143
  const current = memoryDir(cwd)
144
144
  if (fs.existsSync(current)) return
145
- const base = path.join(os.homedir(), '.pi', 'agent', 'memory')
145
+ const base = path.join(getAgentDir(), 'memory')
146
146
  // projectSlug(cwd) differs from current only for a subdirectory session (current is
147
147
  // keyed on the repo root); for a repo-root session it equals current and is skipped.
148
148
  const candidates = [path.join(base, projectSlug(cwd)), path.join(base, legacySlug(cwd))]
@@ -177,7 +177,7 @@ export function indexWouldOverflow(index: string, name: string, description: str
177
177
  /** Where the index stands against the read limits, measured on the loaded content
178
178
  * (frontmatter and comments stripped): 'over' past either bound, 'near' within
179
179
  * 10% of one, else 'ok'. Claude reminds near a limit and errors over it. */
180
- export function indexReadState(index: string): 'ok' | 'near' | 'over' {
180
+ function indexReadState(index: string): 'ok' | 'near' | 'over' {
181
181
  const loaded = stripNonLoaded(index)
182
182
  const lines = loaded.split('\n').length
183
183
  const bytes = Buffer.byteLength(loaded, 'utf-8')
@@ -16,12 +16,12 @@
16
16
  */
17
17
 
18
18
  import { execFile } from 'node:child_process'
19
- import * as fs from 'node:fs'
20
19
  import * as os from 'node:os'
21
20
  import * as path from 'node:path'
22
21
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
23
22
 
24
23
  import { claudeConfigDir } from './internal/config-dir.js'
24
+ import { readSettingsFile } from './internal/settings-chain.js'
25
25
 
26
26
  /** How a finished turn is announced, from Claude's `preferredNotifChannel`. */
27
27
  export type NotifChannel = 'desktop' | 'bell' | 'both' | 'off'
@@ -56,12 +56,7 @@ export function isAway(lastInputAt: number | undefined, now: number, thresholdMs
56
56
  * preference, so only user scope is read; a checked-out repo does not get to silence
57
57
  * or change your notifications. */
58
58
  function readPreferredNotifChannel(home: string): unknown {
59
- try {
60
- const settings = JSON.parse(fs.readFileSync(path.join(claudeConfigDir(home), 'settings.json'), 'utf-8'))
61
- return settings?.preferredNotifChannel
62
- } catch {
63
- return undefined
64
- }
59
+ return readSettingsFile(path.join(claudeConfigDir(home), 'settings.json'))?.preferredNotifChannel
65
60
  }
66
61
 
67
62
  function windowsToastScript(title: string, body: string): string {
@@ -22,14 +22,15 @@
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 } from '@earendil-works/pi-coding-agent'
25
+ import { type ExtensionAPI, parseFrontmatter } from '@earendil-works/pi-coding-agent'
26
26
  import { atomicWriteFile } from './internal/atomic-write.js'
27
+ import { isFlagEnabled } from './internal/command-file.js'
27
28
  import { claudeConfigDir } from './internal/config-dir.js'
28
29
  import { readManagedSettings } from './internal/managed-settings.js'
29
30
  import { installedPlugins, pluginComponentPath } from './internal/plugins.js'
30
31
  import { isProjectApproved } from './internal/project-approval.js'
31
- import { ancestorDirs, findNearestDir, findNearestFile } from './internal/project-root.js'
32
- import { claudeSettingsChain, readSettingsChain } from './internal/settings-chain.js'
32
+ import { ancestorDirs } from './internal/project-root.js'
33
+ import { claudeSettingsChain, localSettingsFile, readSettingsChain } from './internal/settings-chain.js'
33
34
  import { isDirectory } from './internal/values.js'
34
35
 
35
36
  export interface OutputStyle {
@@ -41,22 +42,27 @@ export interface OutputStyle {
41
42
  forceForPlugin: boolean
42
43
  }
43
44
 
44
- function field(frontmatter: string, key: string): string {
45
- const match = new RegExp(String.raw`^\s*${key}\s*:\s*(.+)$`, 'm').exec(frontmatter)
46
- return match ? match[1].trim().replace(/^["']|["']$/g, '') : ''
45
+ /** A frontmatter field as text, or '' when absent or not scalar. */
46
+ function field(frontmatter: Record<string, unknown>, key: string): string {
47
+ const value = frontmatter[key]
48
+ if (typeof value === 'string') return value.trim()
49
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value)
50
+ return ''
47
51
  }
48
52
 
49
- /** Parse an output-style markdown file into its name, description, and body. */
53
+ /** Parse an output-style markdown file into its name, description, and body. pi's
54
+ * own YAML frontmatter parser, as the command loader uses: a line regex captured to
55
+ * end of line, so `"Terse" # short` came back as `Terse" # short`. */
50
56
  export function parseStyle(content: string, fallbackName: string): OutputStyle {
51
- const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content)
52
- const frontmatter = match ? match[1] : ''
53
- const body = match ? content.slice(match[0].length) : content
57
+ const { frontmatter, body } = parseFrontmatter(content)
54
58
  return {
55
59
  name: field(frontmatter, 'name') || fallbackName,
56
60
  description: field(frontmatter, 'description'),
57
61
  body: body.trim(),
58
- keepCodingInstructions: field(frontmatter, 'keep-coding-instructions') === 'true',
59
- forceForPlugin: field(frontmatter, 'force-for-plugin') === 'true',
62
+ // Both are YAML booleans (Claude: default `false`), so `yes`/`on`/`True` count as
63
+ // set, the same reading commands give their own frontmatter flags.
64
+ keepCodingInstructions: isFlagEnabled(field(frontmatter, 'keep-coding-instructions')),
65
+ forceForPlugin: isFlagEnabled(field(frontmatter, 'force-for-plugin')),
60
66
  }
61
67
  }
62
68
 
@@ -202,11 +208,10 @@ export default function outputStylesExtension(pi: ExtensionAPI) {
202
208
  // Precedence low to high: builtin, plugin, then the user's and project's own
203
209
  // dirs, so a same-named user or project style overrides a plugin's.
204
210
  styles = loadStyles([BUILTIN_STYLES_DIR, ...pluginStyleDirs(home), ...styleDirs(ctx.cwd, home, trusted)])
205
- // Persist the choice where the read chain will find it again: the nearest local
206
- // settings file, else inside the nearest .claude directory, else at cwd.
207
- const nearestLocal = findNearestFile(ctx.cwd, path.join('.claude', 'settings.local.json'))
208
- const claudeDir = findNearestDir(ctx.cwd, '.claude') ?? path.join(ctx.cwd, '.claude')
209
- localSettingsPath = nearestLocal ?? path.join(claudeDir, 'settings.local.json')
211
+ // Persist the choice to the file the read chain reads last, so it is read back:
212
+ // the nearest local file or .claude directory could sit at an intermediate
213
+ // ancestor the chain never consults.
214
+ localSettingsPath = localSettingsFile(ctx.cwd, home)
210
215
  // Claude's force-for-plugin: the first loaded forced plugin style applies
211
216
  // automatically, overriding the outputStyle setting.
212
217
  const forced = forcedPluginStyle(loadStyles(pluginStyleDirs(home)))
@@ -6,7 +6,6 @@
6
6
  * Multiple questions per call are not batched; ask sequentially.
7
7
  */
8
8
 
9
- import * as fs from 'node:fs'
10
9
  import * as os from 'node:os'
11
10
  import * as path from 'node:path'
12
11
  import type { ExtensionAPI, ExtensionContext, Theme } from '@earendil-works/pi-coding-agent'
@@ -14,6 +13,7 @@ import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from
14
13
  import { Type } from 'typebox'
15
14
  import { claudeConfigDir } from './internal/config-dir.js'
16
15
  import { readManagedSettings } from './internal/managed-settings.js'
16
+ import { readSettingsFile } from './internal/settings-chain.js'
17
17
 
18
18
  interface OptionWithDesc {
19
19
  label: string
@@ -96,12 +96,7 @@ export function askUserQuestionTimeoutMs(home: string = os.homedir()): number |
96
96
  const managed = readManagedSettings() as { askUserQuestionTimeout?: unknown }
97
97
  const fromManaged = parseAskUserQuestionTimeout(managed.askUserQuestionTimeout)
98
98
  if (fromManaged !== undefined) return fromManaged
99
- try {
100
- const parsed = JSON.parse(fs.readFileSync(path.join(claudeConfigDir(home), 'settings.json'), 'utf-8')) as { askUserQuestionTimeout?: unknown }
101
- return parseAskUserQuestionTimeout(parsed.askUserQuestionTimeout)
102
- } catch {
103
- return undefined
104
- }
99
+ return parseAskUserQuestionTimeout(readSettingsFile(path.join(claudeConfigDir(home), 'settings.json'))?.askUserQuestionTimeout)
105
100
  }
106
101
 
107
102
  function checkbox(checked: boolean | undefined): string {
@@ -35,10 +35,6 @@ import { claudeSettingsChain, readSettingsChain } from './internal/settings-chai
35
35
  import { SKILL_HOOKS_CHANNEL } from './internal/skill-hooks.js'
36
36
  import { errorMessage, isDirectory, isRecord } from './internal/values.js'
37
37
 
38
- /** Existing `.claude/skills` directories, user first then project. The project
39
- * directory is included only for approved projects: pi's loader surfaces every skill's
40
- * name and description to the model, so an untrusted repository would otherwise get
41
- * text into the prompt without the user ever agreeing to load its config. */
42
38
  /** The extra skill directories a manifest declares, as a list. A string is one entry,
43
39
  * a list is itself, anything else declares none. */
44
40
  function declaredSkillDirs(declared: unknown): string[] {
@@ -46,6 +42,10 @@ function declaredSkillDirs(declared: unknown): string[] {
46
42
  return typeof declared === 'string' ? [declared] : []
47
43
  }
48
44
 
45
+ /** Existing `.claude/skills` directories, user first then project. The project
46
+ * directory is included only for approved projects: pi's loader surfaces every skill's
47
+ * name and description to the model, so an untrusted repository would otherwise get
48
+ * text into the prompt without the user ever agreeing to load its config. */
49
49
  export function skillDirs(cwd: string, home: string, trusted: boolean): string[] {
50
50
  // Claude's precedence: enterprise (the skills directory beside the managed
51
51
  // settings file) overrides personal, and personal overrides project; discovery
@@ -113,7 +113,7 @@ function skillAt(root: string, dirName: string): { name: string; filePath: strin
113
113
 
114
114
  /** A Claude-contributed skill by the name pi's loader gives it. One directory
115
115
  * level, the standard layout. */
116
- export function findClaudeSkill(name: string, roots: string[]): FoundSkill | undefined {
116
+ function findClaudeSkill(name: string, roots: string[]): FoundSkill | undefined {
117
117
  for (const root of roots) {
118
118
  let entries: fs.Dirent[]
119
119
  try {
@@ -559,5 +559,9 @@ export default function statusLine(pi: ExtensionAPI) {
559
559
  pi.on('session_shutdown', async () => {
560
560
  clearInterval(refreshTimer)
561
561
  clearTimeout(debounceTimer)
562
+ // pi's CLI loads a fresh extension instance for every session replacement, so no later
563
+ // session_start reaches this watcher: left armed, it polls for the life of the process.
564
+ disposeSettingsWatch()
565
+ disposeSettingsWatch = () => {}
562
566
  })
563
567
  }
@@ -11,6 +11,7 @@ import { getAgentDir, parseFrontmatter, stripFrontmatter } from '@earendil-works
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 { findModel } from '../internal/model-lookup.js'
14
15
  import { installedPlugins, pluginComponentPath } from '../internal/plugins.js'
15
16
  import { ancestorDirs, findNearestDir } from '../internal/project-root.js'
16
17
  import { errorMessage } from '../internal/values.js'
@@ -29,7 +30,7 @@ import { errorMessage } from '../internal/values.js'
29
30
  function parseToolsField(raw: unknown, granting: boolean): string[] | undefined | null {
30
31
  if (raw === undefined) return undefined
31
32
  // Shares the command parser's splitting, so a comma inside an argument scope stays
32
- // inside it here too: `Bash(mv, write, cp)` used to hand the child pi's real `write`.
33
+ // inside it here too: split naively, `Bash(mv, write, cp)` grants the child pi's real `write`.
33
34
  if (raw !== null && !Array.isArray(raw) && typeof raw !== 'string') return null
34
35
  if (Array.isArray(raw) && raw.some((item) => typeof item !== 'string')) return null
35
36
  const grants = parseToolGrants(raw)
@@ -65,10 +66,9 @@ function parseModelAlias(raw: unknown): string | undefined {
65
66
  /** Resolve a Claude tier alias to a concrete model id the user can actually run.
66
67
  * Returning undefined leaves the child on the session model, which is what the
67
68
  * unresolvable case degraded to before and still does. */
68
- export function resolveModelAlias(alias: string | undefined, available: ReadonlyArray<{ id: string; provider?: string }>): string | undefined {
69
+ export function resolveModelAlias(alias: string | undefined, available: ReadonlyArray<{ id: string; name?: string; provider?: string }>): string | undefined {
69
70
  if (!alias || alias === 'inherit') return undefined
70
- const needle = alias.toLowerCase()
71
- return available.find((model) => model.id.toLowerCase().includes(needle))?.id
71
+ return findModel(alias, available)?.id
72
72
  }
73
73
 
74
74
  /** pi's extended thinking levels; Claude's effort values are a subset, so they map 1:1. */
@@ -6,12 +6,13 @@
6
6
  * across a same-process session switch keeps going under the new session.
7
7
  */
8
8
 
9
- import { spawn } from 'node:child_process'
10
9
  import { randomUUID } from 'node:crypto'
11
10
  import * as fs from 'node:fs'
12
11
  import * as os from 'node:os'
13
12
  import * as path from 'node:path'
13
+ import { killProcessTree } from '../internal/process-tree.js'
14
14
  import { errorMessage } from '../internal/values.js'
15
+ import { spawnChild } from './run.js'
15
16
 
16
17
  export interface BackgroundRun {
17
18
  id: string
@@ -209,8 +210,8 @@ export function resumeBackgroundRun(id: string, task: string, onComplete: (run:
209
210
  run.spawn = { ...run.spawn, args: rebuilt.args }
210
211
  if (rebuilt.dir) run.rebuiltPromptDir = rebuilt.dir
211
212
  // The task prompt is always the final argument: both spawn paths push it last and the
212
- // invocation helper only prepends. Matching a 'Task: ' prefix missed it whenever
213
- // SubagentStart hooks placed their context ahead of it, so the resume re-ran the old task.
213
+ // invocation helper only prepends. A 'Task: ' prefix match is wrong whenever
214
+ // SubagentStart hooks placed their context ahead of it.
214
215
  const args = rebuilt.args.map((arg, index) => (index === rebuilt.args.length - 1 ? `Task: ${task}` : arg))
215
216
  run.state = 'running'
216
217
  run.task = task
@@ -281,29 +282,41 @@ export function startBackgroundRun(agent: string, task: string, invocation: Back
281
282
 
282
283
  /** Spawn the child for a run and wire its lifecycle back onto the record. */
283
284
  function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (run: BackgroundRun) => void): void {
284
- const proc = spawn(invocation.command, invocation.args, {
285
- cwd: invocation.cwd,
286
- shell: false,
287
- stdio: ['ignore', 'pipe', 'pipe'],
288
- // Its own group, so cancelling reaches any grandchild the agent spawned.
289
- detached: true,
290
- // The marker lets the child's subagent tool refuse to nest further.
291
- env: { ...process.env, PI_CODE_SUBAGENT: '1', ...invocation.env },
292
- })
293
- run.live = true
294
- const killGroup = (signal: NodeJS.Signals): void => {
285
+ // Node fires both 'error' and 'close' on a spawn failure (ENOENT); complete once.
286
+ let completed = false
287
+ const complete = (): void => {
288
+ if (completed) return
289
+ completed = true
290
+ run.finishedAt = ++finishSequence
291
+ evictFinishedRuns()
292
+ // A run outlives the session that started it, and pi's loader wires assertActive()
293
+ // into every runtime call, so notifying a disposed session throws. This fires from
294
+ // the child's 'close'/'error' listener, where nothing upstream catches: an escaping
295
+ // error reaches Node as an uncaughtException and takes pi down with it. The run
296
+ // state is already recorded by this point, so there is nothing to do but drop the
297
+ // notification for a session that is no longer there to receive it.
295
298
  try {
296
- // A child that never spawned has no pid and no group; the direct kill is all there is.
297
- if (proc.pid) process.kill(-proc.pid, signal)
298
- else proc.kill(signal)
299
+ onComplete(run)
299
300
  } catch {
300
- try {
301
- proc.kill(signal)
302
- } catch {
303
- // already gone
304
- }
301
+ // the session that asked for this run is gone
305
302
  }
306
303
  }
304
+ // The marker in env lets the child's subagent tool refuse to nest further. The run is
305
+ // already registered as running by the caller, so a spawn that throws synchronously
306
+ // (see spawnChild) must settle the record here: left as it was, the phantom held a cap
307
+ // slot with no kill and no eviction for the life of the process.
308
+ const spawned = spawnChild(invocation.command, invocation.args, { cwd: invocation.cwd, env: { ...process.env, PI_CODE_SUBAGENT: '1', ...invocation.env } })
309
+ if ('error' in spawned) {
310
+ run.live = false
311
+ run.state = 'failed'
312
+ run.exitCode = 1
313
+ run.stderr = spawned.error.message
314
+ complete()
315
+ return
316
+ }
317
+ const proc = spawned.proc
318
+ run.live = true
319
+ const killGroup = (signal: NodeJS.Signals): void => killProcessTree(proc, signal)
307
320
  run.kill = () => {
308
321
  killGroup('SIGTERM')
309
322
  // A child ignoring SIGTERM would hold its cap slot and process forever.
@@ -330,25 +343,6 @@ function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (
330
343
  : undefined,
331
344
  )
332
345
  let stderrTail = ''
333
- // Node fires both 'error' and 'close' on a spawn failure (ENOENT); complete once.
334
- let completed = false
335
- const complete = (): void => {
336
- if (completed) return
337
- completed = true
338
- run.finishedAt = ++finishSequence
339
- evictFinishedRuns()
340
- // A run outlives the session that started it, and pi's loader wires assertActive()
341
- // into every runtime call, so notifying a disposed session throws. This fires from
342
- // the child's 'close'/'error' listener, where nothing upstream catches: an escaping
343
- // error reaches Node as an uncaughtException and takes pi down with it. The run
344
- // state is already recorded by this point, so there is nothing to do but drop the
345
- // notification for a session that is no longer there to receive it.
346
- try {
347
- onComplete(run)
348
- } catch {
349
- // the session that asked for this run is gone
350
- }
351
- }
352
346
  proc.stdout.on('data', (data) => parser.push(data.toString()))
353
347
  // An 'error' on a stream with no listener is rethrown by EventEmitter, and this one
354
348
  // belongs to a detached child, so a pipe read failure would exit pi the same way an
@@ -27,8 +27,6 @@ export const AGENT_HOOK_SYSTEM = [
27
27
  'Use "allow" to let the action proceed, "deny" to block it, "ask" to require the user to confirm.',
28
28
  ].join('\n')
29
29
 
30
- /** A throwaway agent config for one agent-hook run: read-only inspection tools, the
31
- * hook's model (a fast default when unset), and the decision-returning system prompt. */
32
30
  /** The agent a context: fork skill runs as when it names none: full toolset, no
33
31
  * extra system prompt (the child keeps pi's default), the skill content as the
34
32
  * task. */
@@ -43,6 +41,8 @@ export function forkAgent(request: Pick<AgentRunRequest, 'model' | 'systemPrompt
43
41
  }
44
42
  }
45
43
 
44
+ /** A throwaway agent config for one agent-hook run: read-only inspection tools, the
45
+ * hook's model (a fast default when unset), and the decision-returning system prompt. */
46
46
  export function buildHookAgent(request: Pick<AgentRunRequest, 'model' | 'systemPrompt'>): AgentConfig {
47
47
  return {
48
48
  name: 'agent-hook',