pi-code 1.0.4 → 1.0.5

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 (35) hide show
  1. package/README.md +25 -13
  2. package/extensions/claude-rules.ts +158 -54
  3. package/extensions/commands.ts +179 -21
  4. package/extensions/context-imports.ts +353 -39
  5. package/extensions/hooks.ts +351 -63
  6. package/extensions/init.ts +81 -0
  7. package/extensions/internal/agent-run.ts +42 -0
  8. package/extensions/internal/bash-rules.ts +27 -0
  9. package/extensions/internal/command-file.ts +373 -59
  10. package/extensions/internal/html-markdown.ts +61 -0
  11. package/extensions/internal/instruction-events.ts +70 -0
  12. package/extensions/internal/managed-settings.ts +38 -0
  13. package/extensions/internal/mcp-call.ts +28 -0
  14. package/extensions/internal/mcp-oauth.ts +171 -0
  15. package/extensions/internal/model-complete.ts +68 -0
  16. package/extensions/internal/path-rules.ts +80 -0
  17. package/extensions/internal/plugins.ts +125 -0
  18. package/extensions/internal/project-approval.ts +2 -3
  19. package/extensions/internal/project-root.ts +78 -0
  20. package/extensions/internal/shell-split.ts +65 -0
  21. package/extensions/internal/strip-comments.ts +77 -0
  22. package/extensions/internal/web-transport.ts +3 -1
  23. package/extensions/mcp.ts +272 -28
  24. package/extensions/memory.ts +129 -16
  25. package/extensions/notify.ts +77 -4
  26. package/extensions/output-styles.ts +34 -6
  27. package/extensions/plan-mode/utils.ts +3 -57
  28. package/extensions/question.ts +2 -2
  29. package/extensions/skills.ts +11 -1
  30. package/extensions/status-line.ts +93 -3
  31. package/extensions/subagent/agents.ts +72 -61
  32. package/extensions/subagent/background.ts +25 -6
  33. package/extensions/subagent/index.ts +194 -29
  34. package/extensions/web.ts +80 -15
  35. package/package.json +1 -1
@@ -15,6 +15,8 @@ import { StringEnum } from '@earendil-works/pi-ai'
15
15
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
16
16
  import { Type } from 'typebox'
17
17
  import { capForContext } from './internal/output-guard.js'
18
+ import { isProjectApprovedSilently } from './internal/project-approval.js'
19
+ import { findNearestFile, repoRoot } from './internal/project-root.js'
18
20
 
19
21
  const INDEX_FILE = 'MEMORY.md'
20
22
 
@@ -45,21 +47,79 @@ function legacySlug(cwd: string): string {
45
47
  .replace(/^-+/, '-')
46
48
  }
47
49
 
50
+ /** The project a memory store belongs to: the repository root, so subdirectory
51
+ * sessions share one store, matching Claude ("derived from the git repository, so
52
+ * all worktrees and subdirectories within the same repo share one auto memory
53
+ * directory. Outside a git repo, the project root is used instead."). Falls back
54
+ * to cwd when there is no project marker. */
55
+ function memoryProject(cwd: string): string {
56
+ return repoRoot(cwd) ?? cwd
57
+ }
58
+
48
59
  export function memoryDir(cwd: string): string {
49
- return path.join(os.homedir(), '.pi', 'agent', 'memory', projectSlug(cwd))
60
+ return path.join(os.homedir(), '.pi', 'agent', 'memory', projectSlug(memoryProject(cwd)))
61
+ }
62
+
63
+ /** The store location, honoring an `autoMemoryDirectory` override. Claude requires
64
+ * it to be absolute or start with `~/`; a relative value is ignored, falling back
65
+ * to the default per-project directory. */
66
+ export function resolveMemoryDir(cwd: string, override?: string): string {
67
+ const trimmed = override?.trim()
68
+ if (trimmed?.startsWith('~/')) return path.join(os.homedir(), trimmed.slice(2))
69
+ if (trimmed && path.isAbsolute(trimmed)) return trimmed
70
+ return memoryDir(cwd)
71
+ }
72
+
73
+ /** Whether auto memory runs: on by default, off when `CLAUDE_CODE_DISABLE_AUTO_MEMORY`
74
+ * is `1`/`true` or a settings scope sets `autoMemoryEnabled: false`. */
75
+ export function autoMemoryEnabled(setting: unknown, env: NodeJS.ProcessEnv): boolean {
76
+ const disable = (env.CLAUDE_CODE_DISABLE_AUTO_MEMORY ?? '').trim().toLowerCase()
77
+ if (disable === '1' || disable === 'true') return false
78
+ return setting !== false
50
79
  }
51
80
 
52
- /** Move a store written under the pre-digest slug to the current one, once. Without
53
- * this the slug change would silently orphan every memory a user already has. */
81
+ /** Set or replace the ISO 8601 `modified:` field inside a memory's YAML frontmatter.
82
+ * Files without frontmatter are returned untouched: Claude never adds frontmatter to
83
+ * a file that has none. */
84
+ export function stampModified(content: string, iso: string): string {
85
+ const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content)
86
+ if (!match) return content
87
+ const inner = match[1]
88
+ const rest = content.slice(match[0].length)
89
+ const withoutModified = inner
90
+ .split('\n')
91
+ .filter((line) => !/^\s*modified\s*:/.test(line))
92
+ .join('\n')
93
+ const body = withoutModified.length > 0 ? `${withoutModified}\n` : ''
94
+ return `---\n${body}modified: ${iso}\n---${rest}`
95
+ }
96
+
97
+ /** The index content that actually loads: YAML frontmatter and block-level HTML
98
+ * comments are stripped, so they neither show in the prompt nor count toward the
99
+ * 200-line / 25KB read limits, matching Claude Code. */
100
+ export function stripNonLoaded(text: string): string {
101
+ return text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '').replace(/<!--[\s\S]*?-->\r?\n?/g, '')
102
+ }
103
+
104
+ /** Move a store written under an older slug to the current one, once. Two earlier
105
+ * formats can orphan a user's memories on upgrade: the released digest-of-cwd slug
106
+ * (before the store was anchored on the repository root, so a subdirectory session
107
+ * resolved to a different dir), and the pre-digest slug. Newest format first. */
54
108
  export function migrateLegacyStore(cwd: string): void {
55
109
  const current = memoryDir(cwd)
56
110
  if (fs.existsSync(current)) return
57
- const legacy = path.join(os.homedir(), '.pi', 'agent', 'memory', legacySlug(cwd))
58
- if (!fs.existsSync(legacy)) return
59
- try {
60
- fs.renameSync(legacy, current)
61
- } catch {
62
- // A failed migration must not take down session start; the store stays legacy.
111
+ const base = path.join(os.homedir(), '.pi', 'agent', 'memory')
112
+ // projectSlug(cwd) differs from current only for a subdirectory session (current is
113
+ // keyed on the repo root); for a repo-root session it equals current and is skipped.
114
+ const candidates = [path.join(base, projectSlug(cwd)), path.join(base, legacySlug(cwd))]
115
+ for (const legacy of candidates) {
116
+ if (legacy === current || !fs.existsSync(legacy)) continue
117
+ try {
118
+ fs.renameSync(legacy, current)
119
+ } catch {
120
+ // A failed migration must not take down session start; the store stays put.
121
+ }
122
+ return
63
123
  }
64
124
  }
65
125
 
@@ -73,12 +133,13 @@ export function indexWouldOverflow(index: string, name: string, description: str
73
133
  // since the injected index is capped at read time.
74
134
  const isUpdate = index.split('\n').some((entry) => entry.startsWith(entryPrefix(name)))
75
135
  if (isUpdate) return false
76
- const next = upsertIndexLine(index, name, description)
136
+ // Only the loaded content counts: frontmatter and comments are stripped first.
137
+ const next = stripNonLoaded(upsertIndexLine(index, name, description))
77
138
  return next.split('\n').length > INDEX_MAX_LINES || Buffer.byteLength(next, 'utf-8') > INDEX_MAX_BYTES
78
139
  }
79
140
 
80
141
  /** Write a memory and its index line, or say why it cannot be written. */
81
- export function saveMemory(dir: string, indexPath: string, name: string | undefined, description: string | undefined, content: string | undefined): { content: Array<{ type: 'text'; text: string }>; details: Record<string, never> } {
142
+ export function saveMemory(dir: string, indexPath: string, name: string | undefined, description: string | undefined, content: string | undefined, now: string = new Date().toISOString()): { content: Array<{ type: 'text'; text: string }>; details: Record<string, never> } {
82
143
  if (!name || !description || !content) {
83
144
  return { content: [{ type: 'text', text: 'save requires name, description, and content.' }], details: {} }
84
145
  }
@@ -92,22 +153,24 @@ export function saveMemory(dir: string, indexPath: string, name: string | undefi
92
153
  }
93
154
  }
94
155
  fs.mkdirSync(dir, { recursive: true })
95
- fs.writeFileSync(path.join(dir, `${name}.md`), content)
156
+ // A memory with frontmatter records its write time; one without is left as-is.
157
+ fs.writeFileSync(path.join(dir, `${name}.md`), stampModified(content, now))
96
158
  writeIndex(indexPath, upsertIndexLine(index, name, description))
97
159
  return { content: [{ type: 'text', text: `Saved memory ${name}.` }], details: {} }
98
160
  }
99
161
 
100
162
  /** The index as injected into the prompt, bounded like Claude's startup load. */
101
163
  export function capIndexForPrompt(index: string): string {
102
- const withinLines = index.split('\n').slice(0, INDEX_MAX_LINES)
103
- let dropped = index.split('\n').length - withinLines.length
164
+ const loaded = stripNonLoaded(index)
165
+ const withinLines = loaded.split('\n').slice(0, INDEX_MAX_LINES)
166
+ let dropped = loaded.split('\n').length - withinLines.length
104
167
  let text = withinLines.join('\n')
105
168
  while (Buffer.byteLength(text, 'utf-8') > INDEX_MAX_BYTES && withinLines.length > 1) {
106
169
  withinLines.pop()
107
170
  dropped++
108
171
  text = withinLines.join('\n')
109
172
  }
110
- if (dropped <= 0) return index
173
+ if (dropped <= 0) return loaded
111
174
  return `${text}\n(${dropped} more memories not shown; use the memory tool with action "list")`
112
175
  }
113
176
 
@@ -175,12 +238,55 @@ function writeIndex(indexPath: string, content: string): void {
175
238
  fs.renameSync(tmp, indexPath)
176
239
  }
177
240
 
241
+ /** The settings chain that decides `autoMemoryEnabled` and `autoMemoryDirectory`:
242
+ * user settings always, then project settings (nearest at or above cwd) only when
243
+ * approved, since a project's `autoMemoryDirectory` is honored under the same trust
244
+ * rule as hooks in settings files. Later files win. */
245
+ export function memorySettingsFiles(cwd: string, home: string, approved: boolean): string[] {
246
+ const files = [path.join(home, '.claude', 'settings.json')]
247
+ if (!approved) return files
248
+ for (const name of ['settings.json', 'settings.local.json']) {
249
+ files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
250
+ }
251
+ return files
252
+ }
253
+
254
+ /** Merge the two memory settings across the chain, later files winning per key. */
255
+ export function readMemorySettings(files: string[]): { autoMemoryEnabled?: unknown; autoMemoryDirectory?: unknown } {
256
+ const merged: { autoMemoryEnabled?: unknown; autoMemoryDirectory?: unknown } = {}
257
+ for (const file of files) {
258
+ try {
259
+ const settings = JSON.parse(fs.readFileSync(file, 'utf-8'))
260
+ if (settings === null || typeof settings !== 'object') continue
261
+ if ('autoMemoryEnabled' in settings) merged.autoMemoryEnabled = settings.autoMemoryEnabled
262
+ if ('autoMemoryDirectory' in settings) merged.autoMemoryDirectory = settings.autoMemoryDirectory
263
+ } catch {
264
+ // missing or invalid settings file: skip
265
+ }
266
+ }
267
+ return merged
268
+ }
269
+
178
270
  export default function memoryExtension(pi: ExtensionAPI) {
179
271
  let dir = memoryDir(process.cwd())
272
+ let enabled = true
273
+
274
+ // These extensions also load inside spawned subagent processes, which carry the
275
+ // PI_CODE_SUBAGENT marker. Claude does not load the main conversation's auto memory
276
+ // into subagents (they get their own store through the agent `memory:` field), so
277
+ // everything here no-ops there: no index injection, no notify, and the tool never
278
+ // touches the parent store. Read per call so tests can flip the env var.
279
+ const inSubagent = (): boolean => Boolean(process.env.PI_CODE_SUBAGENT)
180
280
 
181
281
  pi.on('session_start', async (_event, ctx) => {
282
+ if (inSubagent()) return
182
283
  migrateLegacyStore(ctx.cwd)
183
- dir = memoryDir(ctx.cwd)
284
+ const approved = isProjectApprovedSilently(ctx)
285
+ const settings = readMemorySettings(memorySettingsFiles(ctx.cwd, os.homedir(), approved))
286
+ enabled = autoMemoryEnabled(settings.autoMemoryEnabled, process.env)
287
+ const override = typeof settings.autoMemoryDirectory === 'string' ? settings.autoMemoryDirectory : undefined
288
+ dir = enabled ? resolveMemoryDir(ctx.cwd, override) : memoryDir(ctx.cwd)
289
+ if (!enabled) return
184
290
  const count = readIndexQuietly(dir)
185
291
  .split('\n')
186
292
  .filter((l) => l.startsWith('- ')).length
@@ -188,6 +294,7 @@ export default function memoryExtension(pi: ExtensionAPI) {
188
294
  })
189
295
 
190
296
  pi.on('before_agent_start', async (event) => {
297
+ if (inSubagent() || !enabled) return
191
298
  const index = readIndexQuietly(dir)
192
299
  if (!index.trim()) return
193
300
  return {
@@ -201,6 +308,12 @@ export default function memoryExtension(pi: ExtensionAPI) {
201
308
  description: 'Persistent memory across sessions. Save durable facts, user preferences, corrections, and project decisions that are not derivable from the code. Actions: save (name + description + content), read (name), delete (name), list.',
202
309
  parameters: MemoryParams,
203
310
  async execute(_id, params) {
311
+ if (inSubagent()) {
312
+ return { content: [{ type: 'text' as const, text: 'The memory tool is unavailable in a subagent; auto memory belongs to the main conversation. Use your agent memory directory instead if one was provided.' }], details: {} }
313
+ }
314
+ if (!enabled) {
315
+ return { content: [{ type: 'text' as const, text: 'Auto memory is disabled (autoMemoryEnabled is false or CLAUDE_CODE_DISABLE_AUTO_MEMORY is set). No memory was read or written.' }], details: {} }
316
+ }
204
317
  const name = params.name ? slugifyName(params.name) : undefined
205
318
  const indexPath = path.join(dir, INDEX_FILE)
206
319
 
@@ -6,11 +6,62 @@
6
6
  * - OSC 777: Ghostty, iTerm2, WezTerm, rxvt-unicode
7
7
  * - OSC 99: Kitty
8
8
  * - Windows toast: Windows Terminal (WSL)
9
+ *
10
+ * Honors Claude Code's `preferredNotifChannel` (user settings): `terminal_bell`
11
+ * rings the bell, `notifications_disabled` stays silent, `iterm2_with_bell` does
12
+ * both, anything else sends the desktop notification. Like Claude, a notification
13
+ * fires only when you "appear to be away": pi exposes no terminal-focus signal, so
14
+ * a turn is treated as away when it ran at least AWAY_AFTER_MS, or when no prompt
15
+ * was submitted since session start.
9
16
  */
10
17
 
11
18
  import { execFile } from 'node:child_process'
19
+ import * as fs from 'node:fs'
20
+ import * as os from 'node:os'
21
+ import * as path from 'node:path'
12
22
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
13
23
 
24
+ /** How a finished turn is announced, from Claude's `preferredNotifChannel`. */
25
+ export type NotifChannel = 'desktop' | 'bell' | 'both' | 'off'
26
+
27
+ /** Map Claude's `preferredNotifChannel` to what we emit. Unknown or unset means the
28
+ * default desktop notification; `iterm2_with_bell` both notifies and rings. */
29
+ export function resolveNotifChannel(setting: unknown): NotifChannel {
30
+ switch (typeof setting === 'string' ? setting : '') {
31
+ case 'notifications_disabled':
32
+ return 'off'
33
+ case 'terminal_bell':
34
+ return 'bell'
35
+ case 'iterm2_with_bell':
36
+ return 'both'
37
+ default:
38
+ return 'desktop'
39
+ }
40
+ }
41
+
42
+ /** How long a turn must run before its end is worth a notification. Claude only
43
+ * notifies when you "appear to be away"; pi exposes no terminal-focus signal, so a
44
+ * turn that ran at least this long is the best available proxy for having stepped
45
+ * away. A turn with no recorded start (none since session start) always notifies. */
46
+ export const AWAY_AFTER_MS = 30_000
47
+
48
+ export function isAway(lastInputAt: number | undefined, now: number, thresholdMs: number): boolean {
49
+ if (lastInputAt === undefined) return true
50
+ return now - lastInputAt >= thresholdMs
51
+ }
52
+
53
+ /** The `preferredNotifChannel` from the user's settings. This is a personal terminal
54
+ * preference, so only user scope is read; a checked-out repo does not get to silence
55
+ * or change your notifications. */
56
+ function readPreferredNotifChannel(home: string): unknown {
57
+ try {
58
+ const settings = JSON.parse(fs.readFileSync(path.join(home, '.claude', 'settings.json'), 'utf-8'))
59
+ return settings?.preferredNotifChannel
60
+ } catch {
61
+ return undefined
62
+ }
63
+ }
64
+
14
65
  function windowsToastScript(title: string, body: string): string {
15
66
  const type = 'Windows.UI.Notifications'
16
67
  const mgr = `[${type}.ToastNotificationManager, ${type}, ContentType = WindowsRuntime]`
@@ -37,9 +88,7 @@ function notifyWindows(title: string, body: string): void {
37
88
  execFile(powershell, ['-NoProfile', '-Command', windowsToastScript(title, body)], () => {})
38
89
  }
39
90
 
40
- function notify(title: string, body: string): void {
41
- // Piped or headless stdout (pi -p, CI) must not receive raw escape bytes.
42
- if (!process.stdout.isTTY) return
91
+ function notifyDesktop(title: string, body: string): void {
43
92
  if (process.env.WT_SESSION) {
44
93
  notifyWindows(title, body)
45
94
  } else if (process.env.KITTY_WINDOW_ID) {
@@ -50,7 +99,31 @@ function notify(title: string, body: string): void {
50
99
  }
51
100
 
52
101
  export default function notifyExtension(pi: ExtensionAPI) {
102
+ let channel: NotifChannel = 'desktop'
103
+ // When the user last submitted a prompt, so a turn's duration can stand in for
104
+ // Claude's "appear to be away" check. Undefined until the first prompt this session.
105
+ let lastInputAt: number | undefined
106
+
107
+ pi.on('session_start', async (_event, ctx) => {
108
+ channel = resolveNotifChannel(readPreferredNotifChannel(os.homedir()))
109
+ lastInputAt = undefined
110
+ void ctx
111
+ })
112
+
113
+ pi.on('input', async () => {
114
+ lastInputAt = Date.now()
115
+ })
116
+
53
117
  pi.on('agent_end', async () => {
54
- notify('Pi', 'Ready for input')
118
+ if (channel === 'off') return
119
+ // Piped or headless stdout (pi -p, CI) must not receive raw escape bytes.
120
+ if (!process.stdout.isTTY) return
121
+ if (!isAway(lastInputAt, Date.now(), AWAY_AFTER_MS)) return
122
+ if (channel === 'bell') {
123
+ process.stdout.write('\x07')
124
+ return
125
+ }
126
+ notifyDesktop('Pi', 'Ready for input')
127
+ if (channel === 'both') process.stdout.write('\x07')
55
128
  })
56
129
  }
@@ -2,7 +2,9 @@
2
2
  * Output Styles Extension
3
3
  *
4
4
  * Bridges Claude Code's output styles into pi. It discovers `.claude/output-styles/*.md`
5
- * (user then project), honors the active style recorded as `outputStyle` in
5
+ * (user then project) plus styles shipped by enabled plugins (manifest `outputStyles`,
6
+ * default `output-styles/`, ranked below the user's and project's own), honors the
7
+ * active style recorded as `outputStyle` in
6
8
  * `.claude/settings.json` (user, project, then settings.local.json, last wins),
7
9
  * and appends that style's body to the system prompt so the agent adopts its
8
10
  * tone and role. `/output-style` lists the styles and persists a choice to the
@@ -22,7 +24,9 @@ import * as os from 'node:os'
22
24
  import * as path from 'node:path'
23
25
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
24
26
 
27
+ import { installedPlugins } from './internal/plugins.js'
25
28
  import { isProjectApproved } from './internal/project-approval.js'
29
+ import { findNearestDir, findNearestFile } from './internal/project-root.js'
26
30
 
27
31
  export interface OutputStyle {
28
32
  name: string
@@ -79,10 +83,24 @@ function isDirectory(target: string): boolean {
79
83
  */
80
84
  export function styleDirs(cwd: string, home: string, trusted: boolean): string[] {
81
85
  const dirs = [path.join(home, '.claude', 'output-styles')]
82
- if (trusted) dirs.push(path.join(cwd, '.claude', 'output-styles'))
86
+ if (trusted) dirs.push(findNearestDir(cwd, path.join('.claude', 'output-styles')) ?? path.join(cwd, '.claude', 'output-styles'))
83
87
  return dirs.filter((dir) => isDirectory(dir))
84
88
  }
85
89
 
90
+ /**
91
+ * Output-style directories of every enabled plugin: `output-styles/` unless the
92
+ * manifest's `outputStyles` points elsewhere, in which case it replaces the
93
+ * default scan (Claude Code semantics). Plugins are user-installed, so user scope
94
+ * alone decides; they rank below the user's and project's own styles.
95
+ */
96
+ export function pluginStyleDirs(home: string): string[] {
97
+ return installedPlugins(home).flatMap((plugin) => {
98
+ const declared = plugin.manifest.outputStyles
99
+ const dirs = Array.isArray(declared) ? declared : [typeof declared === 'string' ? declared : 'output-styles']
100
+ return dirs.map((dir) => path.resolve(plugin.root, String(dir)))
101
+ })
102
+ }
103
+
86
104
  /** All output styles, project entries overriding user entries of the same name. */
87
105
  export function loadStyles(dirs: string[]): OutputStyle[] {
88
106
  const byName = new Map<string, OutputStyle>()
@@ -108,10 +126,14 @@ export function loadStyles(dirs: string[]): OutputStyle[] {
108
126
  return [...byName.values()]
109
127
  }
110
128
 
111
- /** Settings files that carry `outputStyle`. Project settings apply only when trusted. */
129
+ /** Settings files that carry `outputStyle`. Project settings apply only when trusted,
130
+ * each the nearest of its name at or above cwd, as the hooks settings chain reads. */
112
131
  export function settingsFiles(cwd: string, home: string, trusted: boolean): string[] {
113
132
  const files = [path.join(home, '.claude', 'settings.json')]
114
- if (trusted) files.push(path.join(cwd, '.claude', 'settings.json'), path.join(cwd, '.claude', 'settings.local.json'))
133
+ if (!trusted) return files
134
+ for (const name of ['settings.json', 'settings.local.json']) {
135
+ files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
136
+ }
115
137
  return files
116
138
  }
117
139
 
@@ -156,8 +178,14 @@ export default function outputStylesExtension(pi: ExtensionAPI) {
156
178
  // project styles / selection once the project is approved. isProjectTrusted alone
157
179
  // is true for a repo pi never asked about; see project-approval.
158
180
  const trusted = await isProjectApproved(ctx)
159
- styles = loadStyles([BUILTIN_STYLES_DIR, ...styleDirs(ctx.cwd, home, trusted)])
160
- localSettingsPath = path.join(ctx.cwd, '.claude', 'settings.local.json')
181
+ // Precedence low to high: builtin, plugin, then the user's and project's own
182
+ // dirs, so a same-named user or project style overrides a plugin's.
183
+ styles = loadStyles([BUILTIN_STYLES_DIR, ...pluginStyleDirs(home), ...styleDirs(ctx.cwd, home, trusted)])
184
+ // Persist the choice where the read chain will find it again: the nearest local
185
+ // settings file, else inside the nearest .claude directory, else at cwd.
186
+ const nearestLocal = findNearestFile(ctx.cwd, path.join('.claude', 'settings.local.json'))
187
+ const claudeDir = findNearestDir(ctx.cwd, '.claude') ?? path.join(ctx.cwd, '.claude')
188
+ localSettingsPath = nearestLocal ?? path.join(claudeDir, 'settings.local.json')
161
189
  activeName = readActiveStyleName(settingsFiles(ctx.cwd, home, trusted))
162
190
  const active = styleForName(styles, activeName)
163
191
  if (active) ctx.ui.notify(`Output style: ${active.name}`, 'info')
@@ -3,6 +3,8 @@
3
3
  * Extracted for testability.
4
4
  */
5
5
 
6
+ import { hasSubstitution, splitSegments } from '../internal/shell-split.js'
7
+
6
8
  // Destructive commands blocked in plan mode
7
9
  const DESTRUCTIVE_PATTERNS = [
8
10
  /\brm\b/i,
@@ -90,62 +92,6 @@ const SAFE_PATTERNS = [
90
92
  /^\s*eza\b/,
91
93
  ]
92
94
 
93
- // The shell can hide an arbitrary command inside any of these, so they are refused
94
- // outright rather than parsed.
95
- const SUBSTITUTION = /\$\(|`|<\(|>\(/
96
-
97
- /**
98
- * Split on the shell separators Claude Code documents (`&&`, `||`, `;`, `|`, `|&`, `&`,
99
- * newline) so every subcommand is checked on its own, ignoring separators inside quotes:
100
- * `grep 'a|b'` is one read, not a pipe. Returns nothing on an unbalanced quote, which
101
- * fails the caller closed rather than guessing at the intended split.
102
- *
103
- * A shell AST would be exact; this is the honest approximation for a quoting-only concern.
104
- */
105
- /** Length of the separator at `i`, or 0 when there is none. */
106
- function separatorAt(command: string, i: number): number {
107
- const pair = command.slice(i, i + 2)
108
- if (pair === '&&' || pair === '||' || pair === '|&') return 2
109
- const ch = command[i]
110
- return ch === ';' || ch === '|' || ch === '&' || ch === '\n' ? 1 : 0
111
- }
112
-
113
- function splitSegments(command: string): string[] {
114
- const segments: string[] = []
115
- let current = ''
116
- let quote: "'" | '"' | undefined
117
-
118
- for (let i = 0; i < command.length; i++) {
119
- const ch = command[i]
120
- if (quote !== undefined) {
121
- current += ch
122
- if (ch === quote) quote = undefined
123
- continue
124
- }
125
- if (ch === "'" || ch === '"') {
126
- quote = ch
127
- current += ch
128
- continue
129
- }
130
- if (ch === '\\' && i + 1 < command.length) {
131
- current += ch + command[++i]
132
- continue
133
- }
134
- const separator = separatorAt(command, i)
135
- if (separator > 0) {
136
- segments.push(current)
137
- current = ''
138
- i += separator - 1
139
- continue
140
- }
141
- current += ch
142
- }
143
-
144
- if (quote !== undefined) return []
145
- segments.push(current)
146
- return segments.map((segment) => segment.trim()).filter(Boolean)
147
- }
148
-
149
95
  // find is allowlisted for traversal only; these actions run commands or delete.
150
96
  const FIND_ACTIONS = /\s-(exec|execdir|ok|okdir|delete|fls|fprint|fprintf)\b/
151
97
 
@@ -163,7 +109,7 @@ function isSafeSegment(segment: string): boolean {
163
109
  * containing a determined one. Only OS-level isolation would be a boundary.
164
110
  */
165
111
  export function isSafeCommand(command: string): boolean {
166
- if (SUBSTITUTION.test(command)) return false
112
+ if (hasSubstitution(command)) return false
167
113
  const segments = splitSegments(command)
168
114
  return segments.length > 0 && segments.every(isSafeSegment)
169
115
  }
@@ -35,7 +35,7 @@ const OptionSchema = Type.Object({
35
35
  const SingleQuestion = Type.Object({
36
36
  question: Type.String({ description: 'The question to ask the user' }),
37
37
  header: Type.Optional(Type.String({ description: 'Short label for the question, shown above it, kept to 12 characters' })),
38
- options: Type.Array(OptionSchema, { description: 'Options for the user to choose from (1-4)', minItems: 1, maxItems: 4 }),
38
+ options: Type.Array(OptionSchema, { description: 'Options for the user to choose from (2-4)', minItems: 2, maxItems: 4 }),
39
39
  multiSelect: Type.Optional(Type.Boolean({ description: 'Allow selecting several options (space toggles, enter confirms)' })),
40
40
  })
41
41
 
@@ -44,7 +44,7 @@ const SingleQuestion = Type.Object({
44
44
  * shapes gave smaller models nothing to follow, and they produced neither. */
45
45
  export const QuestionParams = Type.Object({
46
46
  question: Type.Optional(Type.String({ description: 'The question to ask. Required, unless asking several via questions.' })),
47
- options: Type.Optional(Type.Array(OptionSchema, { description: 'The 1-4 choices for this question, each {label, description?}. Required with question.', minItems: 1, maxItems: 4 })),
47
+ options: Type.Optional(Type.Array(OptionSchema, { description: 'The 2-4 choices for this question, each {label, description?}. Required with question.', minItems: 2, maxItems: 4 })),
48
48
  header: Type.Optional(Type.String({ description: 'Optional short label shown above the question, kept to 12 characters' })),
49
49
  multiSelect: Type.Optional(Type.Boolean({ description: 'Optional: allow selecting several options (space toggles, enter confirms)' })),
50
50
  questions: Type.Optional(Type.Array(SingleQuestion, { description: 'Only to ask 2-4 questions in one call: each entry takes the same fields as above. Leave unset for a single question.', minItems: 1, maxItems: 4 })),
@@ -15,7 +15,9 @@ import * as os from 'node:os'
15
15
  import * as path from 'node:path'
16
16
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
17
17
 
18
+ import { installedPlugins } from './internal/plugins.js'
18
19
  import { isProjectApprovedSilently } from './internal/project-approval.js'
20
+ import { findNearestDir } from './internal/project-root.js'
19
21
 
20
22
  function isDirectory(target: string): boolean {
21
23
  try {
@@ -32,7 +34,15 @@ function isDirectory(target: string): boolean {
32
34
  * text into the prompt without the user ever agreeing to load its config. */
33
35
  export function skillDirs(cwd: string, home: string, trusted: boolean): string[] {
34
36
  const candidates = [path.join(home, '.claude', 'skills')]
35
- if (trusted) candidates.push(path.join(cwd, '.claude', 'skills'))
37
+ // Enabled plugins contribute their skills directories. pi's loader names a
38
+ // skill by its directory, so a plugin skill registers without Claude's
39
+ // /plugin: prefix; a rename-free approximation, disclosed in the README.
40
+ for (const plugin of installedPlugins(home)) {
41
+ const declared = plugin.manifest.skills
42
+ const dirs = Array.isArray(declared) ? declared : [typeof declared === 'string' ? declared : 'skills']
43
+ candidates.push(...dirs.map((dir) => path.resolve(plugin.root, String(dir))))
44
+ }
45
+ if (trusted) candidates.push(findNearestDir(cwd, path.join('.claude', 'skills')) ?? path.join(cwd, '.claude', 'skills'))
36
46
  const dirs: string[] = []
37
47
  for (const dir of candidates) {
38
48
  if (!dirs.includes(dir) && isDirectory(dir)) dirs.push(dir)