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.
- package/README.md +25 -13
- package/extensions/claude-rules.ts +158 -54
- package/extensions/commands.ts +185 -27
- package/extensions/context-imports.ts +358 -41
- package/extensions/git-checkpoint.ts +22 -1
- package/extensions/hooks.ts +397 -79
- 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 +377 -53
- package/extensions/internal/html-markdown.ts +61 -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 +171 -0
- package/extensions/internal/model-complete.ts +68 -0
- package/extensions/internal/path-rules.ts +80 -0
- package/extensions/internal/plugins.ts +125 -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 +77 -0
- package/extensions/internal/web-transport.ts +3 -1
- package/extensions/mcp.ts +290 -31
- package/extensions/memory.ts +168 -23
- package/extensions/notify.ts +78 -5
- package/extensions/output-styles.ts +34 -6
- package/extensions/plan-mode/index.ts +55 -9
- 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 +97 -4
- package/extensions/subagent/agents.ts +72 -61
- package/extensions/subagent/background.ts +114 -25
- package/extensions/subagent/index.ts +227 -44
- package/extensions/web.ts +87 -16
- 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)))
|
|
50
61
|
}
|
|
51
62
|
|
|
52
|
-
/**
|
|
53
|
-
*
|
|
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
|
|
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,24 @@ export function saveMemory(dir: string, indexPath: string, name: string | undefi
|
|
|
92
153
|
}
|
|
93
154
|
}
|
|
94
155
|
fs.mkdirSync(dir, { recursive: true })
|
|
95
|
-
|
|
96
|
-
fs.writeFileSync(
|
|
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
|
|
103
|
-
|
|
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
|
|
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
|
-
|
|
164
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
205
|
-
if (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 =
|
|
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
|
})
|
package/extensions/notify.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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)
|
|
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')
|
|
@@ -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
|
-
|
|
144
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|