pi-code 0.8.0 → 1.0.0
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 +5 -5
- package/extensions/commands.ts +85 -15
- package/extensions/git-checkpoint.ts +13 -0
- package/extensions/internal/command-file.ts +170 -0
- package/extensions/mcp.ts +75 -36
- package/extensions/memory.ts +35 -7
- package/extensions/question.ts +196 -150
- package/extensions/skills.ts +12 -3
- package/extensions/subagent/README.md +12 -5
- package/extensions/subagent/agents.ts +73 -0
- package/extensions/subagent/background.ts +38 -3
- package/extensions/subagent/index.ts +63 -24
- package/package.json +1 -1
package/extensions/question.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Multiple questions per call are not batched; ask sequentially.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type { ExtensionAPI, Theme } from '@earendil-works/pi-coding-agent'
|
|
9
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from '@earendil-works/pi-coding-agent'
|
|
10
10
|
import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from '@earendil-works/pi-tui'
|
|
11
11
|
import { Type } from 'typebox'
|
|
12
12
|
|
|
@@ -32,13 +32,38 @@ const OptionSchema = Type.Object({
|
|
|
32
32
|
description: Type.Optional(Type.String({ description: 'Optional description shown below label' })),
|
|
33
33
|
})
|
|
34
34
|
|
|
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 (max 12 characters)', maxLength: 12 })),
|
|
38
38
|
options: Type.Array(OptionSchema, { description: 'Options for the user to choose from (1-4)', minItems: 1, maxItems: 4 }),
|
|
39
39
|
multiSelect: Type.Optional(Type.Boolean({ description: 'Allow selecting several options (space toggles, enter confirms)' })),
|
|
40
40
|
})
|
|
41
41
|
|
|
42
|
+
/** One question in the flat form, plus an optional batch for Claude's 1-4 questions.
|
|
43
|
+
* The flat fields stay the documented path: a schema offering two equally optional
|
|
44
|
+
* shapes gave smaller models nothing to follow, and they produced neither. */
|
|
45
|
+
export const QuestionParams = Type.Object({
|
|
46
|
+
question: Type.Optional(Type.String({ description: 'The question to ask. Required, unless asking several via questions.' })),
|
|
47
|
+
options: Type.Optional(Type.Array(OptionSchema, { description: 'The 1-4 choices for this question, each {label, description?}. Required with question.', minItems: 1, maxItems: 4 })),
|
|
48
|
+
header: Type.Optional(Type.String({ description: 'Optional short label shown above the question (max 12 characters)', maxLength: 12 })),
|
|
49
|
+
multiSelect: Type.Optional(Type.Boolean({ description: 'Optional: allow selecting several options (space toggles, enter confirms)' })),
|
|
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 })),
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
export interface QuestionSpec {
|
|
54
|
+
question: string
|
|
55
|
+
header?: string
|
|
56
|
+
options: DisplayOption[]
|
|
57
|
+
multiSelect?: boolean
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Normalize either accepted shape into the list of questions to ask. */
|
|
61
|
+
export function questionList(params: Partial<QuestionSpec> & { questions?: QuestionSpec[] }): QuestionSpec[] {
|
|
62
|
+
if (params.questions && params.questions.length > 0) return params.questions
|
|
63
|
+
if (typeof params.question === 'string') return [{ question: params.question, header: params.header, options: params.options ?? [], multiSelect: params.multiSelect }]
|
|
64
|
+
return []
|
|
65
|
+
}
|
|
66
|
+
|
|
42
67
|
function checkbox(checked: boolean | undefined): string {
|
|
43
68
|
if (checked === undefined) return ''
|
|
44
69
|
return checked ? '[x] ' : '[ ] '
|
|
@@ -121,157 +146,29 @@ export default function question(pi: ExtensionAPI) {
|
|
|
121
146
|
pi.registerTool({
|
|
122
147
|
name: 'question',
|
|
123
148
|
label: 'Question',
|
|
124
|
-
description:
|
|
149
|
+
description:
|
|
150
|
+
'Ask the user a question and let them pick from options. Use when you need user input to proceed. Pass question and options, for example {"question": "Which one?", "options": [{"label": "alpha"}, {"label": "beta"}]}. To ask 2-4 questions at once, pass questions instead, with the same fields per entry.',
|
|
125
151
|
parameters: QuestionParams,
|
|
126
152
|
|
|
127
|
-
async execute(_toolCallId,
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
details: {
|
|
132
|
-
question: params.question,
|
|
133
|
-
options: params.options.map((o) => o.label),
|
|
134
|
-
answer: null,
|
|
135
|
-
} as QuestionDetails,
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
if (params.options.length === 0) {
|
|
140
|
-
return {
|
|
141
|
-
content: [{ type: 'text', text: 'Error: No options provided' }],
|
|
142
|
-
details: { question: params.question, options: [], answer: null } as QuestionDetails,
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const multiSelect = params.multiSelect === true
|
|
147
|
-
// The free-text option does not compose with checkbox selection, so it is single-select only.
|
|
148
|
-
const allOptions: DisplayOption[] = multiSelect ? [...params.options] : [...params.options, { label: 'Type something.', isOther: true }]
|
|
149
|
-
|
|
150
|
-
const result = await ctx.ui.custom<{ answer: string; wasCustom: boolean; index?: number } | null>((tui, theme, _kb, done) => {
|
|
151
|
-
let optionIndex = 0
|
|
152
|
-
let editMode = false
|
|
153
|
-
const checked: boolean[] = allOptions.map(() => false)
|
|
154
|
-
let cachedLines: string[] | undefined
|
|
155
|
-
let cachedWidth: number | undefined
|
|
156
|
-
|
|
157
|
-
const editorTheme: EditorTheme = {
|
|
158
|
-
borderColor: (s) => theme.fg('accent', s),
|
|
159
|
-
selectList: {
|
|
160
|
-
selectedPrefix: (t) => theme.fg('accent', t),
|
|
161
|
-
selectedText: (t) => theme.fg('accent', t),
|
|
162
|
-
description: (t) => theme.fg('muted', t),
|
|
163
|
-
scrollInfo: (t) => theme.fg('dim', t),
|
|
164
|
-
noMatch: (t) => theme.fg('warning', t),
|
|
165
|
-
},
|
|
166
|
-
}
|
|
167
|
-
const editor = new Editor(tui, editorTheme)
|
|
168
|
-
|
|
169
|
-
editor.onSubmit = (value) => {
|
|
170
|
-
const trimmed = value.trim()
|
|
171
|
-
if (trimmed) {
|
|
172
|
-
done({ answer: trimmed, wasCustom: true })
|
|
173
|
-
} else {
|
|
174
|
-
editMode = false
|
|
175
|
-
editor.setText('')
|
|
176
|
-
refresh()
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function refresh() {
|
|
181
|
-
cachedLines = undefined
|
|
182
|
-
tui.requestRender()
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function handleInput(data: string) {
|
|
186
|
-
if (editMode) {
|
|
187
|
-
if (matchesKey(data, Key.escape)) {
|
|
188
|
-
editMode = false
|
|
189
|
-
editor.setText('')
|
|
190
|
-
refresh()
|
|
191
|
-
return
|
|
192
|
-
}
|
|
193
|
-
editor.handleInput(data)
|
|
194
|
-
refresh()
|
|
195
|
-
return
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
if (matchesKey(data, Key.up)) {
|
|
199
|
-
optionIndex = Math.max(0, optionIndex - 1)
|
|
200
|
-
refresh()
|
|
201
|
-
return
|
|
202
|
-
}
|
|
203
|
-
if (matchesKey(data, Key.down)) {
|
|
204
|
-
optionIndex = Math.min(allOptions.length - 1, optionIndex + 1)
|
|
205
|
-
refresh()
|
|
206
|
-
return
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
if (multiSelect && data === ' ') {
|
|
210
|
-
checked[optionIndex] = !checked[optionIndex]
|
|
211
|
-
refresh()
|
|
212
|
-
return
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
if (matchesKey(data, Key.enter)) {
|
|
216
|
-
if (multiSelect) {
|
|
217
|
-
done({ answer: selectedLabels(allOptions, checked), wasCustom: false })
|
|
218
|
-
return
|
|
219
|
-
}
|
|
220
|
-
const selected = allOptions[optionIndex]
|
|
221
|
-
if (selected.isOther) {
|
|
222
|
-
editMode = true
|
|
223
|
-
refresh()
|
|
224
|
-
} else {
|
|
225
|
-
done({ answer: selected.label, wasCustom: false, index: optionIndex + 1 })
|
|
226
|
-
}
|
|
227
|
-
return
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
if (matchesKey(data, Key.escape)) {
|
|
231
|
-
done(null)
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
function render(width: number): string[] {
|
|
236
|
-
if (cachedLines && cachedWidth === width) return cachedLines
|
|
237
|
-
cachedWidth = width
|
|
238
|
-
cachedLines = buildQuestionLines({ width, question: params.question, header: params.header, options: allOptions, optionIndex, editMode, multiSelect, checked, editor, theme })
|
|
239
|
-
return cachedLines
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
return {
|
|
243
|
-
render,
|
|
244
|
-
invalidate: () => {
|
|
245
|
-
cachedWidth = undefined
|
|
246
|
-
cachedLines = undefined
|
|
247
|
-
},
|
|
248
|
-
handleInput,
|
|
249
|
-
}
|
|
250
|
-
})
|
|
251
|
-
|
|
252
|
-
// Build simple options list for details; header/multiSelect appear only when set,
|
|
253
|
-
// so single-select details are unchanged.
|
|
254
|
-
const simpleOptions = params.options.map((o) => o.label)
|
|
255
|
-
const base = { question: params.question, options: simpleOptions, ...(params.header ? { header: params.header } : {}), ...(multiSelect ? { multiSelect: true } : {}) }
|
|
256
|
-
|
|
257
|
-
if (!result) {
|
|
258
|
-
return {
|
|
259
|
-
content: [{ type: 'text', text: 'User cancelled the selection' }],
|
|
260
|
-
details: { ...base, answer: null } as QuestionDetails,
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
if (result.wasCustom) {
|
|
265
|
-
return {
|
|
266
|
-
content: [{ type: 'text', text: `User wrote: ${result.answer}` }],
|
|
267
|
-
details: { ...base, answer: result.answer, wasCustom: true } as QuestionDetails,
|
|
268
|
-
}
|
|
153
|
+
async execute(_toolCallId, rawParams, _signal, _onUpdate, ctx) {
|
|
154
|
+
const specs = questionList(rawParams as Partial<QuestionSpec> & { questions?: QuestionSpec[] })
|
|
155
|
+
if (specs.length === 0) {
|
|
156
|
+
return { content: [{ type: 'text', text: 'Error: No question provided' }], details: { question: '', options: [], answer: null } as QuestionDetails }
|
|
269
157
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
158
|
+
if (specs.length === 1) return await askOne(specs[0], ctx)
|
|
159
|
+
|
|
160
|
+
// Several questions are asked in sequence; a cancel ends the run, since the
|
|
161
|
+
// remaining answers would be guesses about a flow the user just declined.
|
|
162
|
+
const texts: string[] = []
|
|
163
|
+
const collected: QuestionDetails[] = []
|
|
164
|
+
for (const spec of specs) {
|
|
165
|
+
const result = await askOne(spec, ctx)
|
|
166
|
+
const detail = result.details as QuestionDetails
|
|
167
|
+
collected.push(detail)
|
|
168
|
+
texts.push(`${spec.question}\n${result.content[0].text}`)
|
|
169
|
+
if (detail.answer === null) break
|
|
274
170
|
}
|
|
171
|
+
return { content: [{ type: 'text', text: texts.join('\n\n') }], details: { ...collected[0], questions: collected } as QuestionDetails }
|
|
275
172
|
},
|
|
276
173
|
|
|
277
174
|
renderCall(args, theme, _context) {
|
|
@@ -288,7 +185,6 @@ export default function question(pi: ExtensionAPI) {
|
|
|
288
185
|
}
|
|
289
186
|
return new Text(text, 0, 0)
|
|
290
187
|
},
|
|
291
|
-
|
|
292
188
|
renderResult(result, _options, theme, _context) {
|
|
293
189
|
const details = result.details as QuestionDetails | undefined
|
|
294
190
|
if (!details) {
|
|
@@ -312,3 +208,153 @@ export default function question(pi: ExtensionAPI) {
|
|
|
312
208
|
},
|
|
313
209
|
})
|
|
314
210
|
}
|
|
211
|
+
|
|
212
|
+
async function askOne(params: QuestionSpec, ctx: ExtensionContext): Promise<{ content: Array<{ type: 'text'; text: string }>; details: QuestionDetails }> {
|
|
213
|
+
if (!ctx.hasUI) {
|
|
214
|
+
return {
|
|
215
|
+
content: [{ type: 'text', text: 'Error: UI not available (running in non-interactive mode)' }],
|
|
216
|
+
details: {
|
|
217
|
+
question: params.question,
|
|
218
|
+
options: params.options.map((o) => o.label),
|
|
219
|
+
answer: null,
|
|
220
|
+
} as QuestionDetails,
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (params.options.length === 0) {
|
|
225
|
+
return {
|
|
226
|
+
content: [{ type: 'text', text: 'Error: No options provided' }],
|
|
227
|
+
details: { question: params.question, options: [], answer: null } as QuestionDetails,
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const multiSelect = params.multiSelect === true
|
|
232
|
+
// The free-text option does not compose with checkbox selection, so it is single-select only.
|
|
233
|
+
const allOptions: DisplayOption[] = multiSelect ? [...params.options] : [...params.options, { label: 'Type something.', isOther: true }]
|
|
234
|
+
|
|
235
|
+
const result = await ctx.ui.custom<{ answer: string; wasCustom: boolean; index?: number } | null>((tui: Parameters<Parameters<ExtensionContext['ui']['custom']>[0]>[0], theme: Theme, _kb: unknown, done: (value: { answer: string; wasCustom: boolean; index?: number } | null) => void) => {
|
|
236
|
+
let optionIndex = 0
|
|
237
|
+
let editMode = false
|
|
238
|
+
const checked: boolean[] = allOptions.map(() => false)
|
|
239
|
+
let cachedLines: string[] | undefined
|
|
240
|
+
let cachedWidth: number | undefined
|
|
241
|
+
|
|
242
|
+
const editorTheme: EditorTheme = {
|
|
243
|
+
borderColor: (s) => theme.fg('accent', s),
|
|
244
|
+
selectList: {
|
|
245
|
+
selectedPrefix: (t) => theme.fg('accent', t),
|
|
246
|
+
selectedText: (t) => theme.fg('accent', t),
|
|
247
|
+
description: (t) => theme.fg('muted', t),
|
|
248
|
+
scrollInfo: (t) => theme.fg('dim', t),
|
|
249
|
+
noMatch: (t) => theme.fg('warning', t),
|
|
250
|
+
},
|
|
251
|
+
}
|
|
252
|
+
const editor = new Editor(tui, editorTheme)
|
|
253
|
+
|
|
254
|
+
editor.onSubmit = (value) => {
|
|
255
|
+
const trimmed = value.trim()
|
|
256
|
+
if (trimmed) {
|
|
257
|
+
done({ answer: trimmed, wasCustom: true })
|
|
258
|
+
} else {
|
|
259
|
+
editMode = false
|
|
260
|
+
editor.setText('')
|
|
261
|
+
refresh()
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function refresh() {
|
|
266
|
+
cachedLines = undefined
|
|
267
|
+
tui.requestRender()
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function handleInput(data: string) {
|
|
271
|
+
if (editMode) {
|
|
272
|
+
if (matchesKey(data, Key.escape)) {
|
|
273
|
+
editMode = false
|
|
274
|
+
editor.setText('')
|
|
275
|
+
refresh()
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
editor.handleInput(data)
|
|
279
|
+
refresh()
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (matchesKey(data, Key.up)) {
|
|
284
|
+
optionIndex = Math.max(0, optionIndex - 1)
|
|
285
|
+
refresh()
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
if (matchesKey(data, Key.down)) {
|
|
289
|
+
optionIndex = Math.min(allOptions.length - 1, optionIndex + 1)
|
|
290
|
+
refresh()
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (multiSelect && data === ' ') {
|
|
295
|
+
checked[optionIndex] = !checked[optionIndex]
|
|
296
|
+
refresh()
|
|
297
|
+
return
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (matchesKey(data, Key.enter)) {
|
|
301
|
+
if (multiSelect) {
|
|
302
|
+
done({ answer: selectedLabels(allOptions, checked), wasCustom: false })
|
|
303
|
+
return
|
|
304
|
+
}
|
|
305
|
+
const selected = allOptions[optionIndex]
|
|
306
|
+
if (selected.isOther) {
|
|
307
|
+
editMode = true
|
|
308
|
+
refresh()
|
|
309
|
+
} else {
|
|
310
|
+
done({ answer: selected.label, wasCustom: false, index: optionIndex + 1 })
|
|
311
|
+
}
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (matchesKey(data, Key.escape)) {
|
|
316
|
+
done(null)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function render(width: number): string[] {
|
|
321
|
+
if (cachedLines && cachedWidth === width) return cachedLines
|
|
322
|
+
cachedWidth = width
|
|
323
|
+
cachedLines = buildQuestionLines({ width, question: params.question, header: params.header, options: allOptions, optionIndex, editMode, multiSelect, checked, editor, theme })
|
|
324
|
+
return cachedLines
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
render,
|
|
329
|
+
invalidate: () => {
|
|
330
|
+
cachedWidth = undefined
|
|
331
|
+
cachedLines = undefined
|
|
332
|
+
},
|
|
333
|
+
handleInput,
|
|
334
|
+
}
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
// Build simple options list for details; header/multiSelect appear only when set,
|
|
338
|
+
// so single-select details are unchanged.
|
|
339
|
+
const simpleOptions = params.options.map((o) => o.label)
|
|
340
|
+
const base = { question: params.question, options: simpleOptions, ...(params.header ? { header: params.header } : {}), ...(multiSelect ? { multiSelect: true } : {}) }
|
|
341
|
+
|
|
342
|
+
if (!result) {
|
|
343
|
+
return {
|
|
344
|
+
content: [{ type: 'text', text: 'User cancelled the selection' }],
|
|
345
|
+
details: { ...base, answer: null } as QuestionDetails,
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (result.wasCustom) {
|
|
350
|
+
return {
|
|
351
|
+
content: [{ type: 'text', text: `User wrote: ${result.answer}` }],
|
|
352
|
+
details: { ...base, answer: result.answer, wasCustom: true } as QuestionDetails,
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const selectionText = multiSelect ? `User selected: ${result.answer || '(none)'}` : `User selected: ${result.index}. ${result.answer}`
|
|
356
|
+
return {
|
|
357
|
+
content: [{ type: 'text', text: selectionText }],
|
|
358
|
+
details: { ...base, answer: result.answer, wasCustom: false } as QuestionDetails,
|
|
359
|
+
}
|
|
360
|
+
}
|
package/extensions/skills.ts
CHANGED
|
@@ -15,6 +15,8 @@ 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 { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
19
|
+
|
|
18
20
|
function isDirectory(target: string): boolean {
|
|
19
21
|
try {
|
|
20
22
|
return fs.statSync(target).isDirectory()
|
|
@@ -24,8 +26,13 @@ function isDirectory(target: string): boolean {
|
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
/** Existing `.claude/skills` directories, user first then project. */
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
/** Existing `.claude/skills` directories, user first then project. The project
|
|
30
|
+
* directory is included only for approved projects: pi's loader surfaces every skill's
|
|
31
|
+
* name and description to the model, so an untrusted repository would otherwise get
|
|
32
|
+
* text into the prompt without the user ever agreeing to load its config. */
|
|
33
|
+
export function skillDirs(cwd: string, home: string, trusted: boolean): string[] {
|
|
34
|
+
const candidates = [path.join(home, '.claude', 'skills')]
|
|
35
|
+
if (trusted) candidates.push(path.join(cwd, '.claude', 'skills'))
|
|
29
36
|
const dirs: string[] = []
|
|
30
37
|
for (const dir of candidates) {
|
|
31
38
|
if (!dirs.includes(dir) && isDirectory(dir)) dirs.push(dir)
|
|
@@ -35,7 +42,9 @@ export function skillDirs(cwd: string, home: string): string[] {
|
|
|
35
42
|
|
|
36
43
|
export default function skillsExtension(pi: ExtensionAPI) {
|
|
37
44
|
pi.on('resources_discover', async (_event, ctx) => {
|
|
38
|
-
|
|
45
|
+
// resources_discover fires after session_start, so the approval is already
|
|
46
|
+
// resolved; reading it silently keeps a second trust dialog off the screen.
|
|
47
|
+
const skillPaths = skillDirs(ctx.cwd, os.homedir(), isProjectApprovedSilently(ctx))
|
|
39
48
|
return skillPaths.length > 0 ? { skillPaths } : undefined
|
|
40
49
|
})
|
|
41
50
|
}
|
|
@@ -7,7 +7,7 @@ Delegate tasks to specialized subagents with isolated context windows.
|
|
|
7
7
|
- **Isolated context**: Each subagent runs in a separate `pi` process
|
|
8
8
|
- **Streaming output**: See tool calls and progress as they happen
|
|
9
9
|
- **Parallel streaming**: All parallel tasks stream updates simultaneously
|
|
10
|
-
- **Background runs**: `{background: true}` returns a run id and notifies on completion; `{status: true}` lists runs
|
|
10
|
+
- **Background runs**: `{background: true}` returns a run id and notifies on completion; `{status: true}` lists runs, `{cancel: "<id>"}` stops one (signalling its process group), and `{resume: "<id>", task: "..."}` continues a finished run under its own session, so the child keeps everything it already saw; max 8 running at once
|
|
11
11
|
- **Bounded fan-out**: A subagent refuses to spawn subagents of its own (an env marker the tool honors: steering, not a sandbox)
|
|
12
12
|
- **Markdown rendering**: Final output rendered with proper formatting (expanded view)
|
|
13
13
|
- **Usage tracking**: Shows turns, tokens, cost, and context usage per agent
|
|
@@ -111,11 +111,18 @@ System prompt for the agent goes here.
|
|
|
111
111
|
|
|
112
112
|
Claude Code fields map onto pi where a sensible seam exists: `tools` and
|
|
113
113
|
`disallowedTools` (comma string or YAML list) become pi's `--tools` /
|
|
114
|
-
`--exclude-tools`; `effort` becomes the `:thinking` suffix on a pinned model
|
|
114
|
+
`--exclude-tools`; `effort` becomes the `:thinking` suffix on a pinned model, or
|
|
115
|
+
`--thinking` when no model is pinned;
|
|
115
116
|
`permissionMode: plan` selects a read-only toolset unless `tools` is set. Model
|
|
116
|
-
aliases (`sonnet`, `opus`, `haiku
|
|
117
|
-
|
|
118
|
-
`
|
|
117
|
+
aliases (`sonnet`, `opus`, `haiku`) resolve against the models this machine is
|
|
118
|
+
authenticated for, falling back to the session's default model when that tier is
|
|
119
|
+
unavailable; `inherit` is the session model by definition. `skills` names skills to preload: their bodies are inlined into the child's
|
|
120
|
+
prompt, since a child pi process does not inherit the parent's skill discovery, and
|
|
121
|
+
a name that resolves to nothing is reported in the prompt rather than dropped.
|
|
122
|
+
Fields with no pi seam are ignored, each verified against pi's CLI rather than
|
|
123
|
+
assumed: `maxTurns` (no turn-limit flag), `mcpServers` (a child reads MCP config
|
|
124
|
+
from files, and writing config into the workspace to fake it would be worse than
|
|
125
|
+
the gap), and `memory` (pi-code's memory is per project, not per agent).
|
|
119
126
|
|
|
120
127
|
**Locations:**
|
|
121
128
|
- `~/.claude/agents/*.md`, `~/.pi/agent/agents/*.md` - User-level (always loaded; `~/.pi` wins a name conflict)
|
|
@@ -54,6 +54,24 @@ function parseModelField(raw: unknown): string | undefined {
|
|
|
54
54
|
return model && !CLAUDE_MODEL_ALIASES.has(model.toLowerCase()) ? model : undefined
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** The tier alias an agent asked for, kept so it can be resolved against the models
|
|
58
|
+
* this user is actually authenticated for. `inherit` is not a tier: it means the
|
|
59
|
+
* session model, which is also the fallback when a tier is unavailable. */
|
|
60
|
+
function parseModelAlias(raw: unknown): string | undefined {
|
|
61
|
+
if (typeof raw !== 'string') return undefined
|
|
62
|
+
const alias = raw.trim().toLowerCase()
|
|
63
|
+
return alias !== 'inherit' && CLAUDE_MODEL_ALIASES.has(alias) ? alias : undefined
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Resolve a Claude tier alias to a concrete model id the user can actually run.
|
|
67
|
+
* Returning undefined leaves the child on the session model, which is what the
|
|
68
|
+
* unresolvable case degraded to before and still does. */
|
|
69
|
+
export function resolveModelAlias(alias: string | undefined, available: ReadonlyArray<{ id: string; provider?: string }>): string | undefined {
|
|
70
|
+
if (!alias || alias === 'inherit') return undefined
|
|
71
|
+
const needle = alias.toLowerCase()
|
|
72
|
+
return available.find((model) => model.id.toLowerCase().includes(needle))?.id
|
|
73
|
+
}
|
|
74
|
+
|
|
57
75
|
/** pi's extended thinking levels; Claude's effort values are a subset, so they map 1:1. */
|
|
58
76
|
const THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
|
|
59
77
|
|
|
@@ -63,6 +81,55 @@ function parseEffortField(raw: unknown): string | undefined {
|
|
|
63
81
|
return THINKING_LEVELS.has(effort) ? effort : undefined
|
|
64
82
|
}
|
|
65
83
|
|
|
84
|
+
/** Claude's `skills` frontmatter: a comma string or YAML list of skill names. */
|
|
85
|
+
function parseSkillsField(raw: unknown): string[] | undefined {
|
|
86
|
+
let names: string[] = []
|
|
87
|
+
if (Array.isArray(raw)) names = raw.map(String)
|
|
88
|
+
else if (typeof raw === 'string') names = raw.split(',')
|
|
89
|
+
const cleaned = names.map((name) => name.trim()).filter(Boolean)
|
|
90
|
+
return cleaned.length > 0 ? cleaned : undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Inline the named skills into an agent's prompt. Claude preloads a subagent's
|
|
94
|
+
* `skills` at startup rather than letting it discover them, and a child pi process
|
|
95
|
+
* does not inherit the parent's skill discovery, so the bodies travel in the prompt.
|
|
96
|
+
* A name that resolves to nothing is reported rather than dropped: a silently missing
|
|
97
|
+
* instruction is worse than a visible gap. */
|
|
98
|
+
export function withPreloadedSkills(prompt: string, skills: string[] | undefined, skillDirs: string[]): string {
|
|
99
|
+
if (!skills || skills.length === 0) return prompt
|
|
100
|
+
const sections: string[] = []
|
|
101
|
+
for (const name of skills) {
|
|
102
|
+
const body = readSkillBody(name, skillDirs)
|
|
103
|
+
sections.push(body === undefined ? `<skill name="${name}">(skill not found)</skill>` : `<skill name="${name}">\n${body.trim()}\n</skill>`)
|
|
104
|
+
}
|
|
105
|
+
return `${prompt}\n\n## Preloaded skills\n\n${sections.join('\n\n')}`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A skill name is a single directory or file stem, never a path. The name comes from
|
|
109
|
+
* agent frontmatter, which a repository can control, and the body is inlined into the
|
|
110
|
+
* prompt sent to the model, so a traversal would be an arbitrary-file read. */
|
|
111
|
+
const SKILL_NAME = /^[A-Za-z0-9_.-]+$/
|
|
112
|
+
|
|
113
|
+
function readSkillBody(name: string, skillDirs: string[]): string | undefined {
|
|
114
|
+
if (!SKILL_NAME.test(name) || name === '.' || name === '..') return undefined
|
|
115
|
+
for (const dir of skillDirs) {
|
|
116
|
+
const root = path.resolve(dir)
|
|
117
|
+
for (const candidate of [path.join(dir, name, 'SKILL.md'), path.join(dir, `${name}.md`)]) {
|
|
118
|
+
// Belt and braces against symlinks and platform path quirks: the file actually
|
|
119
|
+
// read must still sit under the skills directory it was resolved from.
|
|
120
|
+
if (!path.resolve(candidate).startsWith(root + path.sep)) continue
|
|
121
|
+
try {
|
|
122
|
+
const content = fs.readFileSync(candidate, 'utf-8')
|
|
123
|
+
const match = /^---\r?\n[\s\S]*?\r?\n---/.exec(content)
|
|
124
|
+
return match ? content.slice(match[0].length) : content
|
|
125
|
+
} catch {
|
|
126
|
+
// try the next shape
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return undefined
|
|
131
|
+
}
|
|
132
|
+
|
|
66
133
|
/** permissionMode has no pi equivalent; 'plan' means a research agent, so translate
|
|
67
134
|
* the intent into a read-only toolset unless the file pins tools itself. */
|
|
68
135
|
const READ_ONLY_TOOLS = ['read', 'grep', 'find', 'ls']
|
|
@@ -90,6 +157,8 @@ function parseAgentFile(content: string, source: AgentSource, filePath: string):
|
|
|
90
157
|
disallowedTools,
|
|
91
158
|
model: parseModelField(frontmatter.model),
|
|
92
159
|
effort: parseEffortField(frontmatter.effort),
|
|
160
|
+
modelAlias: parseModelAlias(frontmatter.model),
|
|
161
|
+
skills: parseSkillsField(frontmatter.skills),
|
|
93
162
|
systemPrompt: body,
|
|
94
163
|
source,
|
|
95
164
|
filePath,
|
|
@@ -105,6 +174,10 @@ export interface AgentConfig {
|
|
|
105
174
|
disallowedTools?: string[]
|
|
106
175
|
model?: string
|
|
107
176
|
effort?: string
|
|
177
|
+
/** Claude tier alias (`sonnet`/`opus`/`haiku`) when the file named one. */
|
|
178
|
+
modelAlias?: string
|
|
179
|
+
/** Skill names to inline into the child's prompt, per Claude's `skills` field. */
|
|
180
|
+
skills?: string[]
|
|
108
181
|
systemPrompt: string
|
|
109
182
|
source: AgentSource
|
|
110
183
|
filePath: string
|
|
@@ -18,6 +18,10 @@ export interface BackgroundRun {
|
|
|
18
18
|
turns: number
|
|
19
19
|
/** Set while running so the run can be cancelled; cleared on completion. */
|
|
20
20
|
kill?: () => void
|
|
21
|
+
/** pi session the child ran under, so a follow-up can continue its context. */
|
|
22
|
+
sessionId: string
|
|
23
|
+
/** How the child was spawned, so a follow-up can repeat it with a new task. */
|
|
24
|
+
spawn: BackgroundSpawn
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
export interface BackgroundSpawn {
|
|
@@ -57,7 +61,7 @@ export function parseFinalOutputFromJsonl(jsonl: string): { text: string; turns:
|
|
|
57
61
|
return { text, turns }
|
|
58
62
|
}
|
|
59
63
|
|
|
60
|
-
export function formatStatus(all: Iterable<BackgroundRun
|
|
64
|
+
export function formatStatus(all: Iterable<Pick<BackgroundRun, 'id' | 'agent' | 'task' | 'state' | 'turns' | 'exitCode'>>): string {
|
|
61
65
|
const lines = [...all].map((run) => {
|
|
62
66
|
const label = run.state === 'running' ? 'running' : `${run.state} (exit ${run.exitCode ?? '?'}, ${run.turns} turns)`
|
|
63
67
|
return `${run.id} ${run.agent}: ${label} - ${run.task.slice(0, 60)}`
|
|
@@ -81,15 +85,47 @@ export function backgroundStatusText(): string {
|
|
|
81
85
|
return formatStatus(runs.values())
|
|
82
86
|
}
|
|
83
87
|
|
|
88
|
+
/** A finished run, so a caller can continue its session with a follow-up task. */
|
|
89
|
+
export function backgroundRun(id: string): BackgroundRun | undefined {
|
|
90
|
+
return runs.get(id)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Re-spawn a finished run's session with a new task. The child is started with the
|
|
94
|
+
* same --session-id, so it continues with everything it already saw rather than
|
|
95
|
+
* re-deriving context the parent would have to repeat. */
|
|
96
|
+
export function resumeBackgroundRun(id: string, task: string, onComplete: (run: BackgroundRun) => void): 'resumed' | 'still-running' | 'unknown' {
|
|
97
|
+
const run = runs.get(id)
|
|
98
|
+
if (!run) return 'unknown'
|
|
99
|
+
if (run.state === 'running') return 'still-running'
|
|
100
|
+
const args = run.spawn.args.map((arg) => (arg.startsWith('Task: ') ? `Task: ${task}` : arg))
|
|
101
|
+
run.state = 'running'
|
|
102
|
+
run.task = task
|
|
103
|
+
run.output = undefined
|
|
104
|
+
run.exitCode = undefined
|
|
105
|
+
driveRun(run, { ...run.spawn, args }, onComplete)
|
|
106
|
+
return 'resumed'
|
|
107
|
+
}
|
|
108
|
+
|
|
84
109
|
export function startBackgroundRun(agent: string, task: string, invocation: BackgroundSpawn, onComplete: (run: BackgroundRun) => void): string | null {
|
|
85
110
|
// Checked here, synchronously with registration: callers await temp-file writes
|
|
86
111
|
// between any check of their own and this call, so a parallel tool-call batch
|
|
87
112
|
// could otherwise all pass that earlier check and overshoot the cap.
|
|
88
113
|
if (activeBackgroundRuns() >= MAX_BACKGROUND_RUNS) return null
|
|
89
114
|
const id = `bg-${randomUUID().slice(0, 8)}`
|
|
90
|
-
|
|
115
|
+
// A stable session id per run: the child persists its session, so a follow-up can
|
|
116
|
+
// resume it instead of starting cold.
|
|
117
|
+
const sessionId = `pi-code-${id}-${randomUUID().slice(0, 8)}`
|
|
118
|
+
const args = invocation.args.map((arg) => (arg === '--no-session' ? '--session-id' : arg))
|
|
119
|
+
const withSession = args.includes('--session-id') ? args.flatMap((arg) => (arg === '--session-id' ? ['--session-id', sessionId] : [arg])) : args
|
|
120
|
+
const spawnSpec: BackgroundSpawn = { ...invocation, args: withSession }
|
|
121
|
+
const run: BackgroundRun = { id, agent, task, state: 'running', turns: 0, sessionId, spawn: spawnSpec }
|
|
91
122
|
runs.set(id, run)
|
|
123
|
+
driveRun(run, spawnSpec, onComplete)
|
|
124
|
+
return id
|
|
125
|
+
}
|
|
92
126
|
|
|
127
|
+
/** Spawn the child for a run and wire its lifecycle back onto the record. */
|
|
128
|
+
function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (run: BackgroundRun) => void): void {
|
|
93
129
|
const proc = spawn(invocation.command, invocation.args, {
|
|
94
130
|
cwd: invocation.cwd,
|
|
95
131
|
shell: false,
|
|
@@ -133,5 +169,4 @@ export function startBackgroundRun(agent: string, task: string, invocation: Back
|
|
|
133
169
|
run.exitCode = 1
|
|
134
170
|
complete()
|
|
135
171
|
})
|
|
136
|
-
return id
|
|
137
172
|
}
|