pi-code 1.0.3 → 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 (37) hide show
  1. package/README.md +25 -13
  2. package/extensions/claude-rules.ts +158 -54
  3. package/extensions/commands.ts +185 -27
  4. package/extensions/context-imports.ts +358 -41
  5. package/extensions/git-checkpoint.ts +22 -1
  6. package/extensions/hooks.ts +397 -79
  7. package/extensions/init.ts +81 -0
  8. package/extensions/internal/agent-run.ts +42 -0
  9. package/extensions/internal/bash-rules.ts +27 -0
  10. package/extensions/internal/command-file.ts +377 -53
  11. package/extensions/internal/html-markdown.ts +61 -0
  12. package/extensions/internal/instruction-events.ts +70 -0
  13. package/extensions/internal/managed-settings.ts +38 -0
  14. package/extensions/internal/mcp-call.ts +28 -0
  15. package/extensions/internal/mcp-oauth.ts +171 -0
  16. package/extensions/internal/model-complete.ts +68 -0
  17. package/extensions/internal/path-rules.ts +80 -0
  18. package/extensions/internal/plugins.ts +125 -0
  19. package/extensions/internal/project-approval.ts +2 -3
  20. package/extensions/internal/project-root.ts +78 -0
  21. package/extensions/internal/shell-split.ts +65 -0
  22. package/extensions/internal/strip-comments.ts +77 -0
  23. package/extensions/internal/web-transport.ts +3 -1
  24. package/extensions/mcp.ts +290 -31
  25. package/extensions/memory.ts +168 -23
  26. package/extensions/notify.ts +78 -5
  27. package/extensions/output-styles.ts +34 -6
  28. package/extensions/plan-mode/index.ts +55 -9
  29. package/extensions/plan-mode/utils.ts +3 -57
  30. package/extensions/question.ts +2 -2
  31. package/extensions/skills.ts +11 -1
  32. package/extensions/status-line.ts +97 -4
  33. package/extensions/subagent/agents.ts +72 -61
  34. package/extensions/subagent/background.ts +114 -25
  35. package/extensions/subagent/index.ts +227 -44
  36. package/extensions/web.ts +87 -16
  37. 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)))
50
61
  }
51
62
 
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. */
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
79
+ }
80
+
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)
96
- fs.writeFileSync(indexPath, upsertIndexLine(index, name, description))
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))
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
 
@@ -150,25 +213,89 @@ const MemoryParams = Type.Object({
150
213
  function readIndex(dir: string): string {
151
214
  try {
152
215
  return fs.readFileSync(path.join(dir, INDEX_FILE), 'utf-8')
216
+ } catch (error) {
217
+ // Only a missing file means an empty index. Treating any other failure as empty
218
+ // lets the next read-modify-write clobber every existing entry.
219
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return ''
220
+ throw error
221
+ }
222
+ }
223
+
224
+ /** For display paths, where a transiently unreadable index should not break the
225
+ * session; the mutating paths go through readIndex and refuse instead. */
226
+ function readIndexQuietly(dir: string): string {
227
+ try {
228
+ return readIndex(dir)
153
229
  } catch {
154
230
  return ''
155
231
  }
156
232
  }
157
233
 
234
+ /** Replace the index through a rename so a crash mid-write cannot truncate it. */
235
+ function writeIndex(indexPath: string, content: string): void {
236
+ const tmp = `${indexPath}.${process.pid}.tmp`
237
+ fs.writeFileSync(tmp, content)
238
+ fs.renameSync(tmp, indexPath)
239
+ }
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
+
158
270
  export default function memoryExtension(pi: ExtensionAPI) {
159
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)
160
280
 
161
281
  pi.on('session_start', async (_event, ctx) => {
282
+ if (inSubagent()) return
162
283
  migrateLegacyStore(ctx.cwd)
163
- dir = memoryDir(ctx.cwd)
164
- const count = readIndex(dir)
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
290
+ const count = readIndexQuietly(dir)
165
291
  .split('\n')
166
292
  .filter((l) => l.startsWith('- ')).length
167
293
  if (count > 0) ctx.ui.notify(`Memory: ${count} memories loaded`, 'info')
168
294
  })
169
295
 
170
296
  pi.on('before_agent_start', async (event) => {
171
- const index = readIndex(dir)
297
+ if (inSubagent() || !enabled) return
298
+ const index = readIndexQuietly(dir)
172
299
  if (!index.trim()) return
173
300
  return {
174
301
  systemPrompt: `${event.systemPrompt}\n\n## Memory\n\nPersistent memories from earlier sessions (index):\n\n${capIndexForPrompt(index)}\nUse the memory tool with action "read" to load a memory's full content when relevant.`,
@@ -181,11 +308,21 @@ export default function memoryExtension(pi: ExtensionAPI) {
181
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.',
182
309
  parameters: MemoryParams,
183
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
+ }
184
317
  const name = params.name ? slugifyName(params.name) : undefined
185
318
  const indexPath = path.join(dir, INDEX_FILE)
186
319
 
187
320
  if (params.action === 'save') {
188
- return saveMemory(dir, indexPath, name, params.description, params.content)
321
+ try {
322
+ return saveMemory(dir, indexPath, name, params.description, params.content)
323
+ } catch (error) {
324
+ return { content: [{ type: 'text' as const, text: `Memory save failed: ${error instanceof Error ? error.message : String(error)}. The index was left untouched.` }], details: {} }
325
+ }
189
326
  }
190
327
 
191
328
  if (params.action === 'read') {
@@ -200,14 +337,22 @@ export default function memoryExtension(pi: ExtensionAPI) {
200
337
 
201
338
  if (params.action === 'delete') {
202
339
  if (!name) return { content: [{ type: 'text' as const, text: 'delete requires name.' }], details: {} }
340
+ // The index is read before anything is removed: refusing on a failed read
341
+ // must leave both the memory file and the index as they were.
342
+ let index: string
343
+ try {
344
+ index = readIndex(dir)
345
+ } catch (error) {
346
+ return { content: [{ type: 'text' as const, text: `Memory delete failed: ${error instanceof Error ? error.message : String(error)}. Nothing was deleted.` }], details: {} }
347
+ }
203
348
  fs.rmSync(path.join(dir, `${name}.md`), { force: true })
204
- const remaining = removeIndexLine(readIndex(dir), name)
205
- if (remaining) fs.writeFileSync(indexPath, remaining)
349
+ const remaining = removeIndexLine(index, name)
350
+ if (remaining) writeIndex(indexPath, remaining)
206
351
  else fs.rmSync(indexPath, { force: true })
207
352
  return { content: [{ type: 'text' as const, text: `Deleted memory ${name}.` }], details: {} }
208
353
  }
209
354
 
210
- const index = readIndex(dir)
355
+ const index = readIndexQuietly(dir)
211
356
  return { content: [{ type: 'text' as const, text: index.trim() || 'No memories saved for this project yet.' }], details: {} }
212
357
  },
213
358
  })
@@ -6,10 +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
 
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'
11
22
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
12
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
+
13
65
  function windowsToastScript(title: string, body: string): string {
14
66
  const type = 'Windows.UI.Notifications'
15
67
  const mgr = `[${type}.ToastNotificationManager, ${type}, ContentType = WindowsRuntime]`
@@ -29,7 +81,6 @@ function notifyOSC99(title: string, body: string): void {
29
81
  }
30
82
 
31
83
  function notifyWindows(title: string, body: string): void {
32
- const { execFile } = require('node:child_process')
33
84
  // Resolve powershell from a fixed system path rather than through PATH, and let the callback
34
85
  // capture a spawn failure instead of an unhandled 'error' event crashing the host.
35
86
  const root = process.env.SystemRoot ?? String.raw`C:\Windows`
@@ -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')
@@ -24,6 +24,10 @@ import { extractTodoItems, isSafeCommand, markCompletedSteps, planToTodos, type
24
24
  // Tools
25
25
  const PLAN_MODE_TOOLS = ['read', 'bash', 'grep', 'find', 'ls', 'question', 'plan_mode_complete']
26
26
 
27
+ /** Agent runs in execution mode with no [DONE:n] progress before execution ends on
28
+ * its own. Kept small: each stalled run re-injects the stale plan into the turn. */
29
+ const STALLED_RUN_LIMIT = 2
30
+
27
31
  // Type guard for assistant messages
28
32
  function isAssistantMessage(m: AgentMessage): m is AssistantMessage {
29
33
  return m.role === 'assistant' && Array.isArray(m.content)
@@ -60,6 +64,8 @@ export default function planModeExtension(pi: ExtensionAPI): void {
60
64
  let todoItems: TodoItem[] = []
61
65
  let planFromTool = false
62
66
  let savedTools: string[] = []
67
+ let stalledRuns = 0
68
+ let runProgress = false
63
69
 
64
70
  function enterPlanTools(): void {
65
71
  savedTools = pi.getActiveTools()
@@ -113,6 +119,8 @@ export default function planModeExtension(pi: ExtensionAPI): void {
113
119
  executionMode = false
114
120
  todoItems = []
115
121
  planFromTool = false
122
+ stalledRuns = 0
123
+ runProgress = false
116
124
 
117
125
  if (planModeEnabled) {
118
126
  enterPlanTools()
@@ -140,11 +148,8 @@ export default function planModeExtension(pi: ExtensionAPI): void {
140
148
  })
141
149
  }
142
150
 
143
- // Announce completion and reset once every step is done
144
- function finalizeCompletedExecution(ctx: ExtensionContext): void {
145
- if (!todoItems.every((t) => t.completed)) return
146
- const completedList = todoItems.map((t) => `~~${t.text}~~`).join('\n')
147
- pi.sendMessage({ customType: 'plan-complete', content: `**Plan Complete!** ✓\n\n${completedList}`, display: true }, { triggerTurn: false })
151
+ function endExecution(ctx: ExtensionContext, content: string): void {
152
+ pi.sendMessage({ customType: 'plan-complete', content, display: true }, { triggerTurn: false })
148
153
  executionMode = false
149
154
  todoItems = []
150
155
  restoreTools()
@@ -152,6 +157,23 @@ export default function planModeExtension(pi: ExtensionAPI): void {
152
157
  persistState() // Save cleared state so resume doesn't restore old execution mode
153
158
  }
154
159
 
160
+ // Announce completion and reset once every step is done
161
+ function finalizeCompletedExecution(ctx: ExtensionContext): void {
162
+ if (!todoItems.every((t) => t.completed)) return
163
+ const completedList = todoItems.map((t) => `~~${t.text}~~`).join('\n')
164
+ endExecution(ctx, `**Plan Complete!** ✓\n\n${completedList}`)
165
+ }
166
+
167
+ /** Models regularly drop or renumber a [DONE:n] marker; without a bounded exit the
168
+ * stale plan would be injected into every later turn until the user finds /plan. */
169
+ function endStalledExecution(ctx: ExtensionContext): void {
170
+ const remaining = todoItems
171
+ .filter((t) => !t.completed)
172
+ .map((t) => `${t.step}. ${t.text}`)
173
+ .join('\n')
174
+ endExecution(ctx, `**Plan execution ended** after ${STALLED_RUN_LIMIT} turns without step progress. Unfinished steps:\n\n${remaining}`)
175
+ }
176
+
155
177
  // Fall back to extracting a plan from the last assistant message's prose
156
178
  function deriveTodosFromProse(messages: AgentMessage[]): void {
157
179
  const lastAssistant = [...messages].reverse().find(isAssistantMessage)
@@ -170,6 +192,8 @@ export default function planModeExtension(pi: ExtensionAPI): void {
170
192
  planModeEnabled = false
171
193
  executionMode = todoItems.length > 0
172
194
  planFromTool = false
195
+ stalledRuns = 0
196
+ runProgress = false
173
197
  restoreTools()
174
198
  publishPlanState()
175
199
  updateStatus(ctx)
@@ -241,9 +265,19 @@ export default function planModeExtension(pi: ExtensionAPI): void {
241
265
  handler: async (ctx) => togglePlanMode(ctx),
242
266
  })
243
267
 
244
- // Block destructive bash commands in plan mode
268
+ // Enforce plan mode at call time, not only through the active-tool set: pi
269
+ // activates tools registered after the restriction was applied (an MCP server
270
+ // connecting during session_start, or a mid-session list_changed refresh), so the
271
+ // set alone leaks write-capable tools into plan mode.
245
272
  pi.on('tool_call', async (event) => {
246
- if (!planModeEnabled || event.toolName !== 'bash') return
273
+ if (!planModeEnabled) return
274
+ if (!PLAN_MODE_TOOLS.includes(event.toolName)) {
275
+ return {
276
+ block: true,
277
+ reason: `Plan mode: tool blocked (read-only mode). Use /plan to disable plan mode first.\nTool: ${event.toolName}`,
278
+ }
279
+ }
280
+ if (event.toolName !== 'bash') return
247
281
 
248
282
  const command = event.input.command as string
249
283
  if (!isSafeCommand(command)) {
@@ -332,6 +366,7 @@ After completing a step, include a [DONE:n] tag in your response.`,
332
366
 
333
367
  const text = getTextContent(event.message)
334
368
  if (markCompletedSteps(text, todoItems) > 0) {
369
+ runProgress = true
335
370
  updateStatus(ctx)
336
371
  }
337
372
  persistState()
@@ -339,9 +374,18 @@ After completing a step, include a [DONE:n] tag in your response.`,
339
374
 
340
375
  // Handle plan completion and plan mode UI
341
376
  pi.on('agent_end', async (event, ctx) => {
342
- // Check if execution is complete
377
+ // Check if execution is complete, or has stalled without marker progress
343
378
  if (executionMode && todoItems.length > 0) {
344
- finalizeCompletedExecution(ctx)
379
+ if (todoItems.every((t) => t.completed)) {
380
+ finalizeCompletedExecution(ctx)
381
+ stalledRuns = 0
382
+ } else if (runProgress) {
383
+ stalledRuns = 0
384
+ } else {
385
+ stalledRuns++
386
+ if (stalledRuns >= STALLED_RUN_LIMIT) endStalledExecution(ctx)
387
+ }
388
+ runProgress = false
345
389
  return
346
390
  }
347
391
 
@@ -376,6 +420,8 @@ After completing a step, include a [DONE:n] tag in your response.`,
376
420
  executionMode = false
377
421
  todoItems = []
378
422
  planFromTool = false
423
+ stalledRuns = 0
424
+ runProgress = false
379
425
 
380
426
  if (pi.getFlag('plan') === true) {
381
427
  planModeEnabled = true