pi-code 1.0.4 → 1.0.6
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.
- package/README.md +25 -13
- package/extensions/claude-rules.ts +158 -54
- package/extensions/commands.ts +417 -41
- package/extensions/context-imports.ts +446 -61
- package/extensions/hooks.ts +473 -73
- package/extensions/init.ts +81 -0
- package/extensions/internal/agent-run.ts +42 -0
- package/extensions/internal/bash-rules.ts +27 -0
- package/extensions/internal/command-file.ts +423 -66
- package/extensions/internal/html-markdown.ts +71 -0
- package/extensions/internal/instruction-events.ts +70 -0
- package/extensions/internal/managed-settings.ts +38 -0
- package/extensions/internal/mcp-call.ts +28 -0
- package/extensions/internal/mcp-oauth.ts +177 -0
- package/extensions/internal/model-complete.ts +68 -0
- package/extensions/internal/path-rules.ts +80 -0
- package/extensions/internal/plugins.ts +138 -0
- package/extensions/internal/project-approval.ts +2 -3
- package/extensions/internal/project-root.ts +78 -0
- package/extensions/internal/shell-split.ts +65 -0
- package/extensions/internal/strip-comments.ts +100 -0
- package/extensions/internal/web-transport.ts +3 -1
- package/extensions/mcp.ts +579 -30
- package/extensions/memory.ts +158 -35
- package/extensions/notify.ts +76 -4
- package/extensions/output-styles.ts +34 -6
- package/extensions/plan-mode/utils.ts +3 -57
- package/extensions/question.ts +2 -2
- package/extensions/skills.ts +11 -1
- package/extensions/status-line.ts +100 -5
- package/extensions/subagent/agents.ts +72 -61
- package/extensions/subagent/background.ts +25 -6
- package/extensions/subagent/index.ts +310 -31
- package/extensions/web.ts +93 -15
- package/package.json +1 -1
package/extensions/memory.ts
CHANGED
|
@@ -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
|
|
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, '')
|
|
50
102
|
}
|
|
51
103
|
|
|
52
|
-
/** Move a store written under
|
|
53
|
-
*
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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,51 @@ export function saveMemory(dir: string, indexPath: string, name: string | undefi
|
|
|
92
153
|
}
|
|
93
154
|
}
|
|
94
155
|
fs.mkdirSync(dir, { recursive: true })
|
|
95
|
-
|
|
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
|
|
|
162
|
+
/** The read action: a memory's body, capped for context, or a not-found message. */
|
|
163
|
+
function readMemory(dir: string, name: string): { content: Array<{ type: 'text'; text: string }>; details: Record<string, never> } {
|
|
164
|
+
try {
|
|
165
|
+
const body = fs.readFileSync(path.join(dir, `${name}.md`), 'utf-8')
|
|
166
|
+
return { content: [{ type: 'text', text: capForContext(body) }], details: {} }
|
|
167
|
+
} catch {
|
|
168
|
+
return { content: [{ type: 'text', text: `No memory named ${name}.` }], details: {} }
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The delete action: remove a memory file and its index line. The index is read
|
|
173
|
+
* before anything is removed: refusing on a failed read must leave both the memory
|
|
174
|
+
* file and the index as they were. */
|
|
175
|
+
function deleteMemory(dir: string, indexPath: string, name: string): { content: Array<{ type: 'text'; text: string }>; details: Record<string, never> } {
|
|
176
|
+
let index: string
|
|
177
|
+
try {
|
|
178
|
+
index = readIndex(dir)
|
|
179
|
+
} catch (error) {
|
|
180
|
+
return { content: [{ type: 'text', text: `Memory delete failed: ${error instanceof Error ? error.message : String(error)}. Nothing was deleted.` }], details: {} }
|
|
181
|
+
}
|
|
182
|
+
fs.rmSync(path.join(dir, `${name}.md`), { force: true })
|
|
183
|
+
const remaining = removeIndexLine(index, name)
|
|
184
|
+
if (remaining) writeIndex(indexPath, remaining)
|
|
185
|
+
else fs.rmSync(indexPath, { force: true })
|
|
186
|
+
return { content: [{ type: 'text', text: `Deleted memory ${name}.` }], details: {} }
|
|
187
|
+
}
|
|
188
|
+
|
|
100
189
|
/** The index as injected into the prompt, bounded like Claude's startup load. */
|
|
101
190
|
export function capIndexForPrompt(index: string): string {
|
|
102
|
-
const
|
|
103
|
-
|
|
191
|
+
const loaded = stripNonLoaded(index)
|
|
192
|
+
const withinLines = loaded.split('\n').slice(0, INDEX_MAX_LINES)
|
|
193
|
+
let dropped = loaded.split('\n').length - withinLines.length
|
|
104
194
|
let text = withinLines.join('\n')
|
|
105
195
|
while (Buffer.byteLength(text, 'utf-8') > INDEX_MAX_BYTES && withinLines.length > 1) {
|
|
106
196
|
withinLines.pop()
|
|
107
197
|
dropped++
|
|
108
198
|
text = withinLines.join('\n')
|
|
109
199
|
}
|
|
110
|
-
if (dropped <= 0) return
|
|
200
|
+
if (dropped <= 0) return loaded
|
|
111
201
|
return `${text}\n(${dropped} more memories not shown; use the memory tool with action "list")`
|
|
112
202
|
}
|
|
113
203
|
|
|
@@ -175,12 +265,55 @@ function writeIndex(indexPath: string, content: string): void {
|
|
|
175
265
|
fs.renameSync(tmp, indexPath)
|
|
176
266
|
}
|
|
177
267
|
|
|
268
|
+
/** The settings chain that decides `autoMemoryEnabled` and `autoMemoryDirectory`:
|
|
269
|
+
* user settings always, then project settings (nearest at or above cwd) only when
|
|
270
|
+
* approved, since a project's `autoMemoryDirectory` is honored under the same trust
|
|
271
|
+
* rule as hooks in settings files. Later files win. */
|
|
272
|
+
export function memorySettingsFiles(cwd: string, home: string, approved: boolean): string[] {
|
|
273
|
+
const files = [path.join(home, '.claude', 'settings.json')]
|
|
274
|
+
if (!approved) return files
|
|
275
|
+
for (const name of ['settings.json', 'settings.local.json']) {
|
|
276
|
+
files.push(findNearestFile(cwd, path.join('.claude', name)) ?? path.join(cwd, '.claude', name))
|
|
277
|
+
}
|
|
278
|
+
return files
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Merge the two memory settings across the chain, later files winning per key. */
|
|
282
|
+
export function readMemorySettings(files: string[]): { autoMemoryEnabled?: unknown; autoMemoryDirectory?: unknown } {
|
|
283
|
+
const merged: { autoMemoryEnabled?: unknown; autoMemoryDirectory?: unknown } = {}
|
|
284
|
+
for (const file of files) {
|
|
285
|
+
try {
|
|
286
|
+
const settings = JSON.parse(fs.readFileSync(file, 'utf-8'))
|
|
287
|
+
if (settings === null || typeof settings !== 'object') continue
|
|
288
|
+
if ('autoMemoryEnabled' in settings) merged.autoMemoryEnabled = settings.autoMemoryEnabled
|
|
289
|
+
if ('autoMemoryDirectory' in settings) merged.autoMemoryDirectory = settings.autoMemoryDirectory
|
|
290
|
+
} catch {
|
|
291
|
+
// missing or invalid settings file: skip
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return merged
|
|
295
|
+
}
|
|
296
|
+
|
|
178
297
|
export default function memoryExtension(pi: ExtensionAPI) {
|
|
179
298
|
let dir = memoryDir(process.cwd())
|
|
299
|
+
let enabled = true
|
|
300
|
+
|
|
301
|
+
// These extensions also load inside spawned subagent processes, which carry the
|
|
302
|
+
// PI_CODE_SUBAGENT marker. Claude does not load the main conversation's auto memory
|
|
303
|
+
// into subagents (they get their own store through the agent `memory:` field), so
|
|
304
|
+
// everything here no-ops there: no index injection, no notify, and the tool never
|
|
305
|
+
// touches the parent store. Read per call so tests can flip the env var.
|
|
306
|
+
const inSubagent = (): boolean => Boolean(process.env.PI_CODE_SUBAGENT)
|
|
180
307
|
|
|
181
308
|
pi.on('session_start', async (_event, ctx) => {
|
|
309
|
+
if (inSubagent()) return
|
|
182
310
|
migrateLegacyStore(ctx.cwd)
|
|
183
|
-
|
|
311
|
+
const approved = isProjectApprovedSilently(ctx)
|
|
312
|
+
const settings = readMemorySettings(memorySettingsFiles(ctx.cwd, os.homedir(), approved))
|
|
313
|
+
enabled = autoMemoryEnabled(settings.autoMemoryEnabled, process.env)
|
|
314
|
+
const override = typeof settings.autoMemoryDirectory === 'string' ? settings.autoMemoryDirectory : undefined
|
|
315
|
+
dir = enabled ? resolveMemoryDir(ctx.cwd, override) : memoryDir(ctx.cwd)
|
|
316
|
+
if (!enabled) return
|
|
184
317
|
const count = readIndexQuietly(dir)
|
|
185
318
|
.split('\n')
|
|
186
319
|
.filter((l) => l.startsWith('- ')).length
|
|
@@ -188,6 +321,7 @@ export default function memoryExtension(pi: ExtensionAPI) {
|
|
|
188
321
|
})
|
|
189
322
|
|
|
190
323
|
pi.on('before_agent_start', async (event) => {
|
|
324
|
+
if (inSubagent() || !enabled) return
|
|
191
325
|
const index = readIndexQuietly(dir)
|
|
192
326
|
if (!index.trim()) return
|
|
193
327
|
return {
|
|
@@ -201,6 +335,12 @@ export default function memoryExtension(pi: ExtensionAPI) {
|
|
|
201
335
|
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
336
|
parameters: MemoryParams,
|
|
203
337
|
async execute(_id, params) {
|
|
338
|
+
if (inSubagent()) {
|
|
339
|
+
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: {} }
|
|
340
|
+
}
|
|
341
|
+
if (!enabled) {
|
|
342
|
+
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: {} }
|
|
343
|
+
}
|
|
204
344
|
const name = params.name ? slugifyName(params.name) : undefined
|
|
205
345
|
const indexPath = path.join(dir, INDEX_FILE)
|
|
206
346
|
|
|
@@ -214,29 +354,12 @@ export default function memoryExtension(pi: ExtensionAPI) {
|
|
|
214
354
|
|
|
215
355
|
if (params.action === 'read') {
|
|
216
356
|
if (!name) return { content: [{ type: 'text' as const, text: 'read requires name.' }], details: {} }
|
|
217
|
-
|
|
218
|
-
const body = fs.readFileSync(path.join(dir, `${name}.md`), 'utf-8')
|
|
219
|
-
return { content: [{ type: 'text' as const, text: capForContext(body) }], details: {} }
|
|
220
|
-
} catch {
|
|
221
|
-
return { content: [{ type: 'text' as const, text: `No memory named ${name}.` }], details: {} }
|
|
222
|
-
}
|
|
357
|
+
return readMemory(dir, name)
|
|
223
358
|
}
|
|
224
359
|
|
|
225
360
|
if (params.action === 'delete') {
|
|
226
361
|
if (!name) return { content: [{ type: 'text' as const, text: 'delete requires name.' }], details: {} }
|
|
227
|
-
|
|
228
|
-
// must leave both the memory file and the index as they were.
|
|
229
|
-
let index: string
|
|
230
|
-
try {
|
|
231
|
-
index = readIndex(dir)
|
|
232
|
-
} catch (error) {
|
|
233
|
-
return { content: [{ type: 'text' as const, text: `Memory delete failed: ${error instanceof Error ? error.message : String(error)}. Nothing was deleted.` }], details: {} }
|
|
234
|
-
}
|
|
235
|
-
fs.rmSync(path.join(dir, `${name}.md`), { force: true })
|
|
236
|
-
const remaining = removeIndexLine(index, name)
|
|
237
|
-
if (remaining) writeIndex(indexPath, remaining)
|
|
238
|
-
else fs.rmSync(indexPath, { force: true })
|
|
239
|
-
return { content: [{ type: 'text' as const, text: `Deleted memory ${name}.` }], details: {} }
|
|
362
|
+
return deleteMemory(dir, indexPath, name)
|
|
240
363
|
}
|
|
241
364
|
|
|
242
365
|
const index = readIndexQuietly(dir)
|
package/extensions/notify.ts
CHANGED
|
@@ -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
|
|
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,30 @@ 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
|
+
})
|
|
111
|
+
|
|
112
|
+
pi.on('input', async () => {
|
|
113
|
+
lastInputAt = Date.now()
|
|
114
|
+
})
|
|
115
|
+
|
|
53
116
|
pi.on('agent_end', async () => {
|
|
54
|
-
|
|
117
|
+
if (channel === 'off') return
|
|
118
|
+
// Piped or headless stdout (pi -p, CI) must not receive raw escape bytes.
|
|
119
|
+
if (!process.stdout.isTTY) return
|
|
120
|
+
if (!isAway(lastInputAt, Date.now(), AWAY_AFTER_MS)) return
|
|
121
|
+
if (channel === 'bell') {
|
|
122
|
+
process.stdout.write('\x07')
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
notifyDesktop('Pi', 'Ready for input')
|
|
126
|
+
if (channel === 'both') process.stdout.write('\x07')
|
|
55
127
|
})
|
|
56
128
|
}
|
|
@@ -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)
|
|
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
|
|
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
|
-
|
|
160
|
-
|
|
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 (
|
|
112
|
+
if (hasSubstitution(command)) return false
|
|
167
113
|
const segments = splitSegments(command)
|
|
168
114
|
return segments.length > 0 && segments.every(isSafeSegment)
|
|
169
115
|
}
|
package/extensions/question.ts
CHANGED
|
@@ -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 (
|
|
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
|
|
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 })),
|
package/extensions/skills.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|