pi-code 0.1.0 → 0.2.1
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 +24 -7
- package/extensions/claude-rules.ts +7 -3
- package/extensions/context-imports.ts +56 -29
- package/extensions/git-checkpoint.ts +16 -16
- package/extensions/hooks.ts +5 -2
- package/extensions/mcp.ts +29 -14
- package/extensions/memory.ts +4 -3
- package/extensions/notify.ts +6 -4
- package/extensions/output-guard.ts +23 -0
- package/extensions/output-styles.ts +1 -1
- package/extensions/plan-mode/index.ts +83 -58
- package/extensions/plan-mode/utils.ts +118 -16
- package/extensions/project-approval.ts +73 -0
- package/extensions/question.ts +63 -49
- package/extensions/subagent/agents.ts +12 -11
- package/extensions/subagent/index.ts +612 -465
- package/extensions/todo.ts +48 -31
- package/extensions/web.ts +49 -27
- package/package.json +1 -1
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import type { AgentMessage } from '@earendil-works/pi-agent-core'
|
|
16
16
|
import type { AssistantMessage, TextContent } from '@earendil-works/pi-ai'
|
|
17
|
-
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent'
|
|
17
|
+
import type { ExtensionAPI, ExtensionContext, SessionEntry } from '@earendil-works/pi-coding-agent'
|
|
18
18
|
import { Key } from '@earendil-works/pi-tui'
|
|
19
19
|
import { Type } from 'typebox'
|
|
20
20
|
import { extractTodoItems, isSafeCommand, markCompletedSteps, planToTodos, type TodoItem } from './utils.js'
|
|
@@ -35,6 +35,23 @@ function getTextContent(message: AssistantMessage): string {
|
|
|
35
35
|
.join('\n')
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// Last element matching the predicate (the lib target predates Array.prototype.findLast)
|
|
39
|
+
function findLast<T>(items: T[], match: (item: T) => boolean): T | undefined {
|
|
40
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
41
|
+
if (match(items[i])) return items[i]
|
|
42
|
+
}
|
|
43
|
+
return undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Index of the last plan-mode-execute entry, or -1 when the current run never started one
|
|
47
|
+
function findLastExecuteIndex(entries: SessionEntry[]): number {
|
|
48
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
49
|
+
const entry = entries[i] as { customType?: string }
|
|
50
|
+
if (entry.customType === 'plan-mode-execute') return i
|
|
51
|
+
}
|
|
52
|
+
return -1
|
|
53
|
+
}
|
|
54
|
+
|
|
38
55
|
export default function planModeExtension(pi: ExtensionAPI): void {
|
|
39
56
|
let planModeEnabled = false
|
|
40
57
|
let executionMode = false
|
|
@@ -108,6 +125,62 @@ export default function planModeExtension(pi: ExtensionAPI): void {
|
|
|
108
125
|
})
|
|
109
126
|
}
|
|
110
127
|
|
|
128
|
+
// Announce completion and reset once every step is done
|
|
129
|
+
function finalizeCompletedExecution(ctx: ExtensionContext): void {
|
|
130
|
+
if (!todoItems.every((t) => t.completed)) return
|
|
131
|
+
const completedList = todoItems.map((t) => `~~${t.text}~~`).join('\n')
|
|
132
|
+
pi.sendMessage({ customType: 'plan-complete', content: `**Plan Complete!** ✓\n\n${completedList}`, display: true }, { triggerTurn: false })
|
|
133
|
+
executionMode = false
|
|
134
|
+
todoItems = []
|
|
135
|
+
restoreTools()
|
|
136
|
+
updateStatus(ctx)
|
|
137
|
+
persistState() // Save cleared state so resume doesn't restore old execution mode
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Fall back to extracting a plan from the last assistant message's prose
|
|
141
|
+
function deriveTodosFromProse(messages: AgentMessage[]): void {
|
|
142
|
+
const lastAssistant = [...messages].reverse().find(isAssistantMessage)
|
|
143
|
+
if (!lastAssistant) return
|
|
144
|
+
const extracted = extractTodoItems(getTextContent(lastAssistant))
|
|
145
|
+
if (extracted.length > 0) {
|
|
146
|
+
todoItems = extracted
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Ask the user how to proceed after a plan is ready
|
|
151
|
+
async function promptPlanNextAction(ctx: ExtensionContext): Promise<void> {
|
|
152
|
+
const choice = await ctx.ui.select('Plan mode - what next?', [todoItems.length > 0 ? 'Execute the plan (track progress)' : 'Execute the plan', 'Stay in plan mode', 'Refine the plan'])
|
|
153
|
+
|
|
154
|
+
if (choice?.startsWith('Execute')) {
|
|
155
|
+
planModeEnabled = false
|
|
156
|
+
executionMode = todoItems.length > 0
|
|
157
|
+
planFromTool = false
|
|
158
|
+
restoreTools()
|
|
159
|
+
updateStatus(ctx)
|
|
160
|
+
|
|
161
|
+
const execMessage = todoItems.length > 0 ? `Execute the plan. Start with: ${todoItems[0].text}` : 'Execute the plan you just created.'
|
|
162
|
+
pi.sendMessage({ customType: 'plan-mode-execute', content: execMessage, display: true }, { triggerTurn: true })
|
|
163
|
+
} else if (choice === 'Refine the plan') {
|
|
164
|
+
const refinement = await ctx.ui.editor('Refine the plan:', '')
|
|
165
|
+
if (refinement?.trim()) {
|
|
166
|
+
pi.sendUserMessage(refinement.trim())
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Rebuild completion state from assistant messages after the last execute marker
|
|
172
|
+
function rescanCompletion(entries: SessionEntry[]): void {
|
|
173
|
+
const executeIndex = findLastExecuteIndex(entries)
|
|
174
|
+
const messages: AssistantMessage[] = []
|
|
175
|
+
for (let i = executeIndex + 1; i < entries.length; i++) {
|
|
176
|
+
const entry = entries[i]
|
|
177
|
+
if (entry.type === 'message' && 'message' in entry && isAssistantMessage(entry.message as AgentMessage)) {
|
|
178
|
+
messages.push(entry.message as AssistantMessage)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
markCompletedSteps(messages.map(getTextContent).join('\n'), todoItems)
|
|
182
|
+
}
|
|
183
|
+
|
|
111
184
|
pi.registerCommand('plan', {
|
|
112
185
|
description: 'Toggle plan mode (read-only exploration)',
|
|
113
186
|
handler: async (_args, ctx) => togglePlanMode(ctx),
|
|
@@ -193,7 +266,8 @@ You are in plan mode - a read-only exploration mode for safe code analysis.
|
|
|
193
266
|
Restrictions:
|
|
194
267
|
- You can only use: read, bash, grep, find, ls, question
|
|
195
268
|
- You CANNOT use: edit, write (file modifications are disabled)
|
|
196
|
-
- Bash is
|
|
269
|
+
- Bash is limited to an allowlist of read-only commands, checked per subcommand. Treat it
|
|
270
|
+
as a reminder of intent, not a sandbox: do not look for ways around it
|
|
197
271
|
|
|
198
272
|
Ask clarifying questions using the question tool.
|
|
199
273
|
|
|
@@ -244,15 +318,7 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
244
318
|
pi.on('agent_end', async (event, ctx) => {
|
|
245
319
|
// Check if execution is complete
|
|
246
320
|
if (executionMode && todoItems.length > 0) {
|
|
247
|
-
|
|
248
|
-
const completedList = todoItems.map((t) => `~~${t.text}~~`).join('\n')
|
|
249
|
-
pi.sendMessage({ customType: 'plan-complete', content: `**Plan Complete!** ✓\n\n${completedList}`, display: true }, { triggerTurn: false })
|
|
250
|
-
executionMode = false
|
|
251
|
-
todoItems = []
|
|
252
|
-
restoreTools()
|
|
253
|
-
updateStatus(ctx)
|
|
254
|
-
persistState() // Save cleared state so resume doesn't restore old execution mode
|
|
255
|
-
}
|
|
321
|
+
finalizeCompletedExecution(ctx)
|
|
256
322
|
return
|
|
257
323
|
}
|
|
258
324
|
|
|
@@ -260,13 +326,7 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
260
326
|
|
|
261
327
|
// Prefer an explicitly submitted plan; fall back to extracting from prose
|
|
262
328
|
if (!planFromTool) {
|
|
263
|
-
|
|
264
|
-
if (lastAssistant) {
|
|
265
|
-
const extracted = extractTodoItems(getTextContent(lastAssistant))
|
|
266
|
-
if (extracted.length > 0) {
|
|
267
|
-
todoItems = extracted
|
|
268
|
-
}
|
|
269
|
-
}
|
|
329
|
+
deriveTodosFromProse(event.messages)
|
|
270
330
|
}
|
|
271
331
|
|
|
272
332
|
// Show plan steps and prompt for next action
|
|
@@ -282,23 +342,7 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
282
342
|
)
|
|
283
343
|
}
|
|
284
344
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
if (choice?.startsWith('Execute')) {
|
|
288
|
-
planModeEnabled = false
|
|
289
|
-
executionMode = todoItems.length > 0
|
|
290
|
-
planFromTool = false
|
|
291
|
-
restoreTools()
|
|
292
|
-
updateStatus(ctx)
|
|
293
|
-
|
|
294
|
-
const execMessage = todoItems.length > 0 ? `Execute the plan. Start with: ${todoItems[0].text}` : 'Execute the plan you just created.'
|
|
295
|
-
pi.sendMessage({ customType: 'plan-mode-execute', content: execMessage, display: true }, { triggerTurn: true })
|
|
296
|
-
} else if (choice === 'Refine the plan') {
|
|
297
|
-
const refinement = await ctx.ui.editor('Refine the plan:', '')
|
|
298
|
-
if (refinement?.trim()) {
|
|
299
|
-
pi.sendUserMessage(refinement.trim())
|
|
300
|
-
}
|
|
301
|
-
}
|
|
345
|
+
await promptPlanNextAction(ctx)
|
|
302
346
|
})
|
|
303
347
|
|
|
304
348
|
// Restore state on session start/resume
|
|
@@ -310,7 +354,7 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
310
354
|
const entries = ctx.sessionManager.getEntries()
|
|
311
355
|
|
|
312
356
|
// Restore persisted state
|
|
313
|
-
const planModeEntry = entries
|
|
357
|
+
const planModeEntry = findLast(entries, (e: { type: string; customType?: string }) => e.type === 'custom' && e.customType === 'plan-mode') as { data?: { enabled: boolean; todos?: TodoItem[]; executing?: boolean } } | undefined
|
|
314
358
|
|
|
315
359
|
if (planModeEntry?.data) {
|
|
316
360
|
planModeEnabled = planModeEntry.data.enabled ?? planModeEnabled
|
|
@@ -318,30 +362,11 @@ After completing a step, include a [DONE:n] tag in your response.`,
|
|
|
318
362
|
executionMode = planModeEntry.data.executing ?? executionMode
|
|
319
363
|
}
|
|
320
364
|
|
|
321
|
-
// On resume: re-scan messages to rebuild
|
|
322
|
-
//
|
|
365
|
+
// On resume: re-scan messages after the last "plan-mode-execute" to rebuild
|
|
366
|
+
// completion state without picking up [DONE:n] from previous plans
|
|
323
367
|
const isResume = planModeEntry !== undefined
|
|
324
368
|
if (isResume && executionMode && todoItems.length > 0) {
|
|
325
|
-
|
|
326
|
-
let executeIndex = -1
|
|
327
|
-
for (let i = entries.length - 1; i >= 0; i--) {
|
|
328
|
-
const entry = entries[i] as { type: string; customType?: string }
|
|
329
|
-
if (entry.customType === 'plan-mode-execute') {
|
|
330
|
-
executeIndex = i
|
|
331
|
-
break
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// Only scan messages after the execute marker
|
|
336
|
-
const messages: AssistantMessage[] = []
|
|
337
|
-
for (let i = executeIndex + 1; i < entries.length; i++) {
|
|
338
|
-
const entry = entries[i]
|
|
339
|
-
if (entry.type === 'message' && 'message' in entry && isAssistantMessage(entry.message as AgentMessage)) {
|
|
340
|
-
messages.push(entry.message as AssistantMessage)
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
const allText = messages.map(getTextContent).join('\n')
|
|
344
|
-
markCompletedSteps(allText, todoItems)
|
|
369
|
+
rescanCompletion(entries)
|
|
345
370
|
}
|
|
346
371
|
|
|
347
372
|
if (planModeEnabled) {
|
|
@@ -27,7 +27,7 @@ const DESTRUCTIVE_PATTERNS = [
|
|
|
27
27
|
/\bpip\s+(install|uninstall)/i,
|
|
28
28
|
/\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
|
|
29
29
|
/\bbrew\s+(install|uninstall|upgrade)/i,
|
|
30
|
-
/\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-
|
|
30
|
+
/\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-d|stash|cherry-pick|revert|tag|init|clone)/i,
|
|
31
31
|
/\bsudo\b/i,
|
|
32
32
|
/\bsu\b/i,
|
|
33
33
|
/\bkill\b/i,
|
|
@@ -40,7 +40,9 @@ const DESTRUCTIVE_PATTERNS = [
|
|
|
40
40
|
/\b(vim?|nano|emacs|code|subl)\b/i,
|
|
41
41
|
]
|
|
42
42
|
|
|
43
|
-
// Safe read-only commands allowed in plan mode
|
|
43
|
+
// Safe read-only commands allowed in plan mode. Deliberately excludes env/printenv
|
|
44
|
+
// (secret disclosure, and env is an exec wrapper), curl/wget (fetch plus -o writes),
|
|
45
|
+
// awk (system()) and sed (w/W/e write even under -n).
|
|
44
46
|
const SAFE_PATTERNS = [
|
|
45
47
|
/^\s*cat\b/,
|
|
46
48
|
/^\s*head\b/,
|
|
@@ -65,8 +67,6 @@ const SAFE_PATTERNS = [
|
|
|
65
67
|
/^\s*which\b/,
|
|
66
68
|
/^\s*whereis\b/,
|
|
67
69
|
/^\s*type\b/,
|
|
68
|
-
/^\s*env\b/,
|
|
69
|
-
/^\s*printenv\b/,
|
|
70
70
|
/^\s*uname\b/,
|
|
71
71
|
/^\s*whoami\b/,
|
|
72
72
|
/^\s*id\b/,
|
|
@@ -83,21 +83,86 @@ const SAFE_PATTERNS = [
|
|
|
83
83
|
/^\s*yarn\s+(list|info|why|audit)/i,
|
|
84
84
|
/^\s*node\s+--version/i,
|
|
85
85
|
/^\s*python\s+--version/i,
|
|
86
|
-
/^\s*curl\s/i,
|
|
87
|
-
/^\s*wget\s+-O\s*-/i,
|
|
88
86
|
/^\s*jq\b/,
|
|
89
|
-
/^\s*sed\s+-n/i,
|
|
90
|
-
/^\s*awk\b/,
|
|
91
87
|
/^\s*rg\b/,
|
|
92
88
|
/^\s*fd\b/,
|
|
93
89
|
/^\s*bat\b/,
|
|
94
90
|
/^\s*eza\b/,
|
|
95
91
|
]
|
|
96
92
|
|
|
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
|
+
function splitSegments(command: string): string[] {
|
|
106
|
+
const segments: string[] = []
|
|
107
|
+
let current = ''
|
|
108
|
+
let quote: "'" | '"' | undefined
|
|
109
|
+
|
|
110
|
+
for (let i = 0; i < command.length; i++) {
|
|
111
|
+
const ch = command[i]
|
|
112
|
+
if (quote !== undefined) {
|
|
113
|
+
current += ch
|
|
114
|
+
if (ch === quote) quote = undefined
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
if (ch === "'" || ch === '"') {
|
|
118
|
+
quote = ch
|
|
119
|
+
current += ch
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
if (ch === '\\' && i + 1 < command.length) {
|
|
123
|
+
current += ch + command[++i]
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
const pair = command.slice(i, i + 2)
|
|
127
|
+
if (pair === '&&' || pair === '||' || pair === '|&') {
|
|
128
|
+
segments.push(current)
|
|
129
|
+
current = ''
|
|
130
|
+
i++
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
if (ch === ';' || ch === '|' || ch === '&' || ch === '\n') {
|
|
134
|
+
segments.push(current)
|
|
135
|
+
current = ''
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
current += ch
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (quote !== undefined) return []
|
|
142
|
+
segments.push(current)
|
|
143
|
+
return segments.map((segment) => segment.trim()).filter(Boolean)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// find is allowlisted for traversal only; these actions run commands or delete.
|
|
147
|
+
const FIND_ACTIONS = /\s-(exec|execdir|ok|okdir|delete|fls|fprint|fprintf)\b/
|
|
148
|
+
|
|
149
|
+
function isSafeSegment(segment: string): boolean {
|
|
150
|
+
if (DESTRUCTIVE_PATTERNS.some((p) => p.test(segment))) return false
|
|
151
|
+
if (!SAFE_PATTERNS.some((p) => p.test(segment))) return false
|
|
152
|
+
return !(/^\s*find\b/.test(segment) && FIND_ACTIONS.test(segment))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Whether plan mode should let this bash command run.
|
|
157
|
+
*
|
|
158
|
+
* Model steering, not a sandbox: an allowlisted interpreter can still read and write
|
|
159
|
+
* whatever the user can, so this narrows the blast radius of a wrong turn rather than
|
|
160
|
+
* containing a determined one. Only OS-level isolation would be a boundary.
|
|
161
|
+
*/
|
|
97
162
|
export function isSafeCommand(command: string): boolean {
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
return
|
|
163
|
+
if (SUBSTITUTION.test(command)) return false
|
|
164
|
+
const segments = splitSegments(command)
|
|
165
|
+
return segments.length > 0 && segments.every(isSafeSegment)
|
|
101
166
|
}
|
|
102
167
|
|
|
103
168
|
export interface TodoItem {
|
|
@@ -123,16 +188,53 @@ export function cleanStepText(text: string): string {
|
|
|
123
188
|
return cleaned
|
|
124
189
|
}
|
|
125
190
|
|
|
191
|
+
// Horizontal whitespace before the newline: \s would include \n itself and overlap
|
|
192
|
+
// the following \n, which is what backtracks super-linearly.
|
|
193
|
+
const PLAN_HEADER = /\*{0,2}Plan:\*{0,2}[^\S\n]*\n/i
|
|
194
|
+
|
|
195
|
+
const isBlank = (ch: string | undefined): boolean => ch !== undefined && ch !== '\n' && ch.trim() === ''
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Text of a `1. step` / `2) step` line, stopping at an inline `*`, or undefined when
|
|
199
|
+
* the line is not a numbered step. Scanned rather than matched: the equivalent
|
|
200
|
+
* pattern needs adjacent quantifiers over overlapping classes, which backtracks
|
|
201
|
+
* super-linearly on a long line that turns out not to be a step.
|
|
202
|
+
*/
|
|
203
|
+
function numberedStepText(line: string): string | undefined {
|
|
204
|
+
let i = 0
|
|
205
|
+
while (isBlank(line[i])) i++
|
|
206
|
+
|
|
207
|
+
const digitsStart = i
|
|
208
|
+
while (line[i] >= '0' && line[i] <= '9') i++
|
|
209
|
+
if (i === digitsStart) return undefined
|
|
210
|
+
|
|
211
|
+
if (line[i] !== '.' && line[i] !== ')') return undefined
|
|
212
|
+
i++
|
|
213
|
+
|
|
214
|
+
const spaceStart = i
|
|
215
|
+
while (isBlank(line[i])) i++
|
|
216
|
+
if (i === spaceStart) return undefined // the marker must be followed by whitespace
|
|
217
|
+
|
|
218
|
+
for (let stars = 0; stars < 2 && line[i] === '*'; stars++) i++
|
|
219
|
+
const first = line[i]
|
|
220
|
+
if (first === undefined || first === '*' || isBlank(first)) return undefined
|
|
221
|
+
|
|
222
|
+
const rest = line.slice(i)
|
|
223
|
+
const star = rest.indexOf('*')
|
|
224
|
+
return star === -1 ? rest : rest.slice(0, star)
|
|
225
|
+
}
|
|
226
|
+
|
|
126
227
|
export function extractTodoItems(message: string): TodoItem[] {
|
|
127
228
|
const items: TodoItem[] = []
|
|
128
|
-
const headerMatch =
|
|
229
|
+
const headerMatch = PLAN_HEADER.exec(message)
|
|
129
230
|
if (!headerMatch) return items
|
|
130
231
|
|
|
131
232
|
const planSection = message.slice(message.indexOf(headerMatch[0]) + headerMatch[0].length)
|
|
132
|
-
const numberedPattern = /^\s*(\d+)[.)]\s+\*{0,2}([^*\n]+)/gm
|
|
133
233
|
|
|
134
|
-
for (const
|
|
135
|
-
const
|
|
234
|
+
for (const line of planSection.split('\n')) {
|
|
235
|
+
const captured = numberedStepText(line)
|
|
236
|
+
if (captured === undefined) continue
|
|
237
|
+
const text = captured
|
|
136
238
|
.trim()
|
|
137
239
|
.replace(/\*{1,2}$/, '')
|
|
138
240
|
.trim()
|
|
@@ -170,6 +272,6 @@ export function markCompletedSteps(text: string, items: TodoItem[]): number {
|
|
|
170
272
|
* the tool input is already known to be the plan itself.
|
|
171
273
|
*/
|
|
172
274
|
export function planToTodos(plan: string): TodoItem[] {
|
|
173
|
-
const withHeader =
|
|
275
|
+
const withHeader = PLAN_HEADER.test(plan) ? plan : `Plan:\n${plan}`
|
|
174
276
|
return extractTodoItems(withHeader)
|
|
175
277
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project Approval
|
|
3
|
+
*
|
|
4
|
+
* `ctx.isProjectTrusted()` is not sufficient on its own. pi decides whether to ask for
|
|
5
|
+
* trust in `hasTrustRequiringProjectResources`, which looks only under `cwd/.pi` and for
|
|
6
|
+
* `.agents/skills`. A repository shipping just `.claude/` and `.mcp.json` matches neither,
|
|
7
|
+
* so `resolveProjectTrusted` short-circuits to `true` before it ever emits `project_trust`:
|
|
8
|
+
*
|
|
9
|
+
* if (!hasTrustRequiringProjectResources(cwd)) return true
|
|
10
|
+
* if (extensionsResult) { ...emitProjectTrustEvent... }
|
|
11
|
+
*
|
|
12
|
+
* A `project_trust` handler therefore cannot cover this case; the event only fires for
|
|
13
|
+
* projects pi was already going to prompt about. The decision has to be made where the
|
|
14
|
+
* project config is consumed instead, which is what this module does.
|
|
15
|
+
*
|
|
16
|
+
* Answers are stored in pi's own trust store, so approving here also satisfies pi if the
|
|
17
|
+
* project later grows `.pi` resources, and a decision recorded on a parent directory applies.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import * as fs from 'node:fs'
|
|
21
|
+
import * as path from 'node:path'
|
|
22
|
+
import { getAgentDir, hasTrustRequiringProjectResources, ProjectTrustStore } from '@earendil-works/pi-coding-agent'
|
|
23
|
+
|
|
24
|
+
/** Project files pi-code acts on that pi's own trust check does not look for. */
|
|
25
|
+
const CLAUDE_SHAPED = [path.join('.claude', 'settings.json'), path.join('.claude', 'settings.local.json'), path.join('.claude', 'agents'), path.join('.claude', 'hooks'), path.join('.claude', 'output-styles'), '.mcp.json', path.join('.pi', 'mcp.json'), path.join('.pi', 'agents')]
|
|
26
|
+
|
|
27
|
+
export function hasClaudeShapedConfig(cwd: string): boolean {
|
|
28
|
+
return CLAUDE_SHAPED.some((entry) => fs.existsSync(path.join(cwd, entry)))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ApprovalContext {
|
|
32
|
+
cwd: string
|
|
33
|
+
hasUI: boolean
|
|
34
|
+
isProjectTrusted?: () => boolean
|
|
35
|
+
ui: { confirm: (title: string, body: string) => Promise<boolean> }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ApprovalDeps {
|
|
39
|
+
hasClaudeShaped: (cwd: string) => boolean
|
|
40
|
+
piWouldAsk: (cwd: string) => boolean
|
|
41
|
+
savedDecision: (cwd: string) => boolean | null
|
|
42
|
+
remember: (cwd: string, trusted: boolean) => void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const defaultDeps: ApprovalDeps = {
|
|
46
|
+
hasClaudeShaped: hasClaudeShapedConfig,
|
|
47
|
+
piWouldAsk: hasTrustRequiringProjectResources,
|
|
48
|
+
savedDecision: (cwd) => new ProjectTrustStore(getAgentDir()).get(cwd),
|
|
49
|
+
remember: (cwd, trusted) => new ProjectTrustStore(getAgentDir()).set(cwd, trusted),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const APPROVAL_BODY = 'It ships Claude Code configuration that pi-code loads. MCP servers, hooks and agents can run commands from this repository.'
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether project-controlled config may be acted on.
|
|
56
|
+
*
|
|
57
|
+
* Refuses without a UI rather than deferring: pi reached this point without consulting
|
|
58
|
+
* `defaultProjectTrust` at all, so there is no user preference to fall back on. A run
|
|
59
|
+
* that cannot ask has not been approved.
|
|
60
|
+
*/
|
|
61
|
+
export async function isProjectApproved(ctx: ApprovalContext, deps: ApprovalDeps = defaultDeps): Promise<boolean> {
|
|
62
|
+
if (ctx.isProjectTrusted?.() !== true) return false // pi already declined, or never trusted
|
|
63
|
+
if (!deps.hasClaudeShaped(ctx.cwd)) return true // nothing here pi's own check would miss
|
|
64
|
+
if (deps.piWouldAsk(ctx.cwd)) return true // pi genuinely prompted for this project
|
|
65
|
+
|
|
66
|
+
const stored = deps.savedDecision(ctx.cwd)
|
|
67
|
+
if (stored !== null) return stored
|
|
68
|
+
if (!ctx.hasUI) return false
|
|
69
|
+
|
|
70
|
+
const approved = await ctx.ui.confirm('Trust this project?', `${ctx.cwd}\n\n${APPROVAL_BODY}`)
|
|
71
|
+
deps.remember(ctx.cwd, approved)
|
|
72
|
+
return approved
|
|
73
|
+
}
|
package/extensions/question.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Escape in editor returns to options, Escape in options cancels
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
7
|
+
import type { ExtensionAPI, Theme } from '@earendil-works/pi-coding-agent'
|
|
8
8
|
import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from '@earendil-works/pi-tui'
|
|
9
9
|
import { Type } from 'typebox'
|
|
10
10
|
|
|
@@ -33,6 +33,60 @@ const QuestionParams = Type.Object({
|
|
|
33
33
|
options: Type.Array(OptionSchema, { description: 'Options for the user to choose from' }),
|
|
34
34
|
})
|
|
35
35
|
|
|
36
|
+
function optionLine(opt: DisplayOption, index: number, selected: boolean, editMode: boolean, theme: Theme): string {
|
|
37
|
+
const label = `${index + 1}. ${opt.label}`
|
|
38
|
+
const prefix = selected ? theme.fg('accent', '> ') : ' '
|
|
39
|
+
if (opt.isOther === true && editMode) {
|
|
40
|
+
return prefix + theme.fg('accent', `${label} ✎`)
|
|
41
|
+
}
|
|
42
|
+
if (selected) {
|
|
43
|
+
return prefix + theme.fg('accent', label)
|
|
44
|
+
}
|
|
45
|
+
return ` ${theme.fg('text', label)}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface QuestionView {
|
|
49
|
+
width: number
|
|
50
|
+
question: string
|
|
51
|
+
options: DisplayOption[]
|
|
52
|
+
optionIndex: number
|
|
53
|
+
editMode: boolean
|
|
54
|
+
editor: Editor
|
|
55
|
+
theme: Theme
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function buildQuestionLines(view: QuestionView): string[] {
|
|
59
|
+
const { width, question, options, optionIndex, editMode, editor, theme } = view
|
|
60
|
+
const lines: string[] = []
|
|
61
|
+
const add = (s: string) => lines.push(truncateToWidth(s, width))
|
|
62
|
+
|
|
63
|
+
add(theme.fg('accent', '─'.repeat(width)))
|
|
64
|
+
add(theme.fg('text', ` ${question}`))
|
|
65
|
+
lines.push('')
|
|
66
|
+
|
|
67
|
+
for (let i = 0; i < options.length; i++) {
|
|
68
|
+
const opt = options[i]
|
|
69
|
+
add(optionLine(opt, i, i === optionIndex, editMode, theme))
|
|
70
|
+
if (opt.description) {
|
|
71
|
+
add(` ${theme.fg('muted', opt.description)}`)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (editMode) {
|
|
76
|
+
lines.push('')
|
|
77
|
+
add(theme.fg('muted', ' Your answer:'))
|
|
78
|
+
for (const line of editor.render(width - 2)) {
|
|
79
|
+
add(` ${line}`)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
lines.push('')
|
|
84
|
+
add(theme.fg('dim', editMode ? ' Enter to submit • Esc to go back' : ' ↑↓ navigate • Enter to select • Esc to cancel'))
|
|
85
|
+
add(theme.fg('accent', '─'.repeat(width)))
|
|
86
|
+
|
|
87
|
+
return lines
|
|
88
|
+
}
|
|
89
|
+
|
|
36
90
|
export default function question(pi: ExtensionAPI) {
|
|
37
91
|
pi.registerTool({
|
|
38
92
|
name: 'question',
|
|
@@ -65,6 +119,7 @@ export default function question(pi: ExtensionAPI) {
|
|
|
65
119
|
let optionIndex = 0
|
|
66
120
|
let editMode = false
|
|
67
121
|
let cachedLines: string[] | undefined
|
|
122
|
+
let cachedWidth: number | undefined
|
|
68
123
|
|
|
69
124
|
const editorTheme: EditorTheme = {
|
|
70
125
|
borderColor: (s) => theme.fg('accent', s),
|
|
@@ -135,58 +190,16 @@ export default function question(pi: ExtensionAPI) {
|
|
|
135
190
|
}
|
|
136
191
|
|
|
137
192
|
function render(width: number): string[] {
|
|
138
|
-
if (cachedLines) return cachedLines
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
add(theme.fg('accent', '─'.repeat(width)))
|
|
144
|
-
add(theme.fg('text', ` ${params.question}`))
|
|
145
|
-
lines.push('')
|
|
146
|
-
|
|
147
|
-
for (let i = 0; i < allOptions.length; i++) {
|
|
148
|
-
const opt = allOptions[i]
|
|
149
|
-
const selected = i === optionIndex
|
|
150
|
-
const isOther = opt.isOther === true
|
|
151
|
-
const prefix = selected ? theme.fg('accent', '> ') : ' '
|
|
152
|
-
|
|
153
|
-
if (isOther && editMode) {
|
|
154
|
-
add(prefix + theme.fg('accent', `${i + 1}. ${opt.label} ✎`))
|
|
155
|
-
} else if (selected) {
|
|
156
|
-
add(prefix + theme.fg('accent', `${i + 1}. ${opt.label}`))
|
|
157
|
-
} else {
|
|
158
|
-
add(` ${theme.fg('text', `${i + 1}. ${opt.label}`)}`)
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Show description if present
|
|
162
|
-
if (opt.description) {
|
|
163
|
-
add(` ${theme.fg('muted', opt.description)}`)
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
if (editMode) {
|
|
168
|
-
lines.push('')
|
|
169
|
-
add(theme.fg('muted', ' Your answer:'))
|
|
170
|
-
for (const line of editor.render(width - 2)) {
|
|
171
|
-
add(` ${line}`)
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
lines.push('')
|
|
176
|
-
if (editMode) {
|
|
177
|
-
add(theme.fg('dim', ' Enter to submit • Esc to go back'))
|
|
178
|
-
} else {
|
|
179
|
-
add(theme.fg('dim', ' ↑↓ navigate • Enter to select • Esc to cancel'))
|
|
180
|
-
}
|
|
181
|
-
add(theme.fg('accent', '─'.repeat(width)))
|
|
182
|
-
|
|
183
|
-
cachedLines = lines
|
|
184
|
-
return lines
|
|
193
|
+
if (cachedLines && cachedWidth === width) return cachedLines
|
|
194
|
+
cachedWidth = width
|
|
195
|
+
cachedLines = buildQuestionLines({ width, question: params.question, options: allOptions, optionIndex, editMode, editor, theme })
|
|
196
|
+
return cachedLines
|
|
185
197
|
}
|
|
186
198
|
|
|
187
199
|
return {
|
|
188
200
|
render,
|
|
189
201
|
invalidate: () => {
|
|
202
|
+
cachedWidth = undefined
|
|
190
203
|
cachedLines = undefined
|
|
191
204
|
},
|
|
192
205
|
handleInput,
|
|
@@ -231,7 +244,8 @@ export default function question(pi: ExtensionAPI) {
|
|
|
231
244
|
if (opts.length) {
|
|
232
245
|
const labels = opts.map((o: OptionWithDesc) => o.label)
|
|
233
246
|
const numbered = [...labels, 'Type something.'].map((o, i) => `${i + 1}. ${o}`)
|
|
234
|
-
|
|
247
|
+
const optionsLine = ` Options: ${numbered.join(', ')}`
|
|
248
|
+
text += `\n${theme.fg('dim', optionsLine)}`
|
|
235
249
|
}
|
|
236
250
|
return new Text(text, 0, 0)
|
|
237
251
|
},
|
|
@@ -111,6 +111,17 @@ function findNearestDir(cwd: string, relative: string): string | null {
|
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
function buildAgentMap(userAgents: AgentConfig[], projectAgents: AgentConfig[], scope: AgentScope): Map<string, AgentConfig> {
|
|
115
|
+
const agentMap = new Map<string, AgentConfig>()
|
|
116
|
+
const register = (agents: AgentConfig[]): void => {
|
|
117
|
+
for (const agent of agents) agentMap.set(agent.name, agent)
|
|
118
|
+
}
|
|
119
|
+
// user agents first so project agents win on name conflicts
|
|
120
|
+
if (scope !== 'project') register(userAgents)
|
|
121
|
+
if (scope !== 'user') register(projectAgents)
|
|
122
|
+
return agentMap
|
|
123
|
+
}
|
|
124
|
+
|
|
114
125
|
export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
|
|
115
126
|
const userDir = path.join(getAgentDir(), 'agents')
|
|
116
127
|
const claudeUserDir = path.join(os.homedir(), '.claude', 'agents')
|
|
@@ -122,17 +133,7 @@ export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryRe
|
|
|
122
133
|
// project .claude/agents loads first so project .pi/agents wins on name conflicts
|
|
123
134
|
const projectAgents = scope === 'user' ? [] : [...(projectClaudeDir ? loadAgentsFromDir(projectClaudeDir, 'project') : []), ...(projectPiDir ? loadAgentsFromDir(projectPiDir, 'project') : [])]
|
|
124
135
|
|
|
125
|
-
const agentMap =
|
|
126
|
-
|
|
127
|
-
if (scope === 'both') {
|
|
128
|
-
for (const agent of userAgents) agentMap.set(agent.name, agent)
|
|
129
|
-
for (const agent of projectAgents) agentMap.set(agent.name, agent)
|
|
130
|
-
} else if (scope === 'user') {
|
|
131
|
-
for (const agent of userAgents) agentMap.set(agent.name, agent)
|
|
132
|
-
} else {
|
|
133
|
-
for (const agent of projectAgents) agentMap.set(agent.name, agent)
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
+
const agentMap = buildAgentMap(userAgents, projectAgents, scope)
|
|
136
137
|
return { agents: Array.from(agentMap.values()), projectAgentsDir: projectPiDir }
|
|
137
138
|
}
|
|
138
139
|
|