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.
@@ -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
- export const QuestionParams = Type.Object({
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: 'Ask the user a question and let them pick from options. Use when you need user input to proceed.',
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, params, _signal, _onUpdate, ctx) {
128
- if (!ctx.hasUI) {
129
- return {
130
- content: [{ type: 'text', text: 'Error: UI not available (running in non-interactive mode)' }],
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
- const selectionText = multiSelect ? `User selected: ${result.answer || '(none)'}` : `User selected: ${result.index}. ${result.answer}`
271
- return {
272
- content: [{ type: 'text', text: selectionText }],
273
- details: { ...base, answer: result.answer, wasCustom: false } as QuestionDetails,
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
+ }
@@ -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
- export function skillDirs(cwd: string, home: string): string[] {
28
- const candidates = [path.join(home, '.claude', 'skills'), path.join(cwd, '.claude', 'skills')]
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
- const skillPaths = skillDirs(ctx.cwd, os.homedir())
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 and `{cancel: "<id>"}` stops one (signalling its process group); max 8 running at once
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`, `inherit`) run on the session's default
117
- model. Fields with no pi equivalent are ignored: `skills`, `memory`,
118
- `mcpServers`, `maxTurns`.
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>): string {
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
- const run: BackgroundRun = { id, agent, task, state: 'running', turns: 0 }
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
  }