pi-code 0.9.0 → 1.0.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 CHANGED
@@ -13,6 +13,8 @@
13
13
 
14
14
  Claude Code experience for the [pi](https://pi.dev) coding agent, in one package. Point pi at a project that already has a `.claude/` directory and it reads your existing config: rules, commands, skills, hooks, output styles, MCP servers, and agents. It also adds the Claude Code features pi lacks: a todo overlay, checkpoints, memory, web search, and subagents.
15
15
 
16
+ What a repository ships is treated as untrusted until you approve it: project MCP servers, hooks, agents, rules, output styles, commands and skills load only once you say yes.
17
+
16
18
  ![pi-code demo](demos/hero.gif)
17
19
 
18
20
  ## Install
@@ -37,19 +39,19 @@ One `pi install` and everything below loads on the next start. `pi list` shows w
37
39
  |---|---|---|
38
40
  | Global + project rules | `~/.claude/rules`, `.claude/rules` (+ `paths:` frontmatter scoping) | `claude-rules.ts` |
39
41
  | Custom slash commands | `.claude/commands/**/*.md` (namespaced `/dir:name`), `$ARGUMENTS`/`$1`, `` !`cmd` `` bash output, `@file` inlining, `allowed-tools`/`model`/`argument-hint` frontmatter; project commands gated on approval | `commands.ts` |
40
- | Skills | `.claude/skills` → pi skill discovery (pi reads `name`, `description`, `disable-model-invocation`; `allowed-tools` is inert in pi's loader) | `skills.ts` |
42
+ | Skills | `.claude/skills` → pi skill discovery, project skills gated on approval (pi reads `name`, `description`, `disable-model-invocation`; `allowed-tools` is inert in pi's loader) | `skills.ts` |
41
43
  | Hooks | `.claude/settings.json` hooks: PreToolUse (blocks, rewrites input via `updatedInput`), PostToolUse (feedback and `additionalContext` land next to the tool result), PostToolUseFailure, SessionStart (context injection), UserPromptSubmit (blocks and injects context), Stop (a block continues the conversation), SubagentStart/SubagentStop, PreCompact, PostCompact, SessionEnd; Claude matcher semantics incl. `mcp__server__tool` names; payloads carry session_id, transcript_path, cwd, permission_mode, effort | `hooks.ts` |
42
44
  | Output styles | `.claude/output-styles` + active `outputStyle`; Claude replace semantics with `keep-coding-instructions`; bundled Explanatory/Learning/Proactive; `/output-style [name]` | `output-styles.ts` |
43
45
  | CLAUDE.md `@imports` | resolves `@path` imports pi's native loader skips; loads `CLAUDE.local.md` (approval-gated) | `context-imports.ts` |
44
- | MCP servers | user `~/.claude.json` (incl. per-project `projects[cwd]` local scope), `~/.pi/agent/mcp.json`; project `.mcp.json`, `.pi/mcp.json` (once approved; `enabledMcpjsonServers`/`disabledMcpjsonServers`/`enableAllProjectMcpServers` honored, consent keys only from non-repo settings); stdio/HTTP/SSE by `type`; `${VAR:-default}` expansion; `MCP_TIMEOUT`/`MCP_TOOL_TIMEOUT` | `mcp.ts` |
45
- | Project trust | prompts before loading project config (MCP servers, hooks, agents, rules, output styles) that pi would otherwise trust silently | `internal/project-approval.ts` |
46
- | Subagents / Task | builtin Explore/Plan/general-purpose agents, `~/.claude/agents` and `~/.pi/agent/agents`, plus project `.claude/agents` and `.pi/agents`; agent roster with descriptions in the system prompt; background runs | `subagent/` |
46
+ | MCP servers | user `~/.claude.json` (incl. per-project `projects[cwd]` local scope), `~/.pi/agent/mcp.json`; project `.mcp.json`, `.pi/mcp.json` (once approved; `enabledMcpjsonServers`/`disabledMcpjsonServers`/`enableAllProjectMcpServers` honored, consent keys only from non-repo settings); stdio/HTTP/SSE by `type`; `${VAR:-default}` expansion; `MCP_TIMEOUT`/`MCP_TOOL_TIMEOUT`; tools refresh on `list_changed` | `mcp.ts` |
47
+ | Project trust | prompts before loading project config (MCP servers, hooks, agents, rules, output styles, commands, skills) that pi would otherwise trust silently | `internal/project-approval.ts` |
48
+ | Subagents / Task | builtin Explore/Plan/general-purpose agents, `~/.claude/agents` and `~/.pi/agent/agents`, plus project `.claude/agents` and `.pi/agents`; agent roster with descriptions in the system prompt; `skills` preload; background runs with cancel and resume | `subagent/` |
47
49
  | Plan mode | `plan_mode_complete` tool, exact tool snapshot/restore | `plan-mode/` |
48
50
  | Todo list | persistent overlay, status machine, compaction-safe | `todo.ts` |
49
- | Checkpoints / rewind | shadow-repo snapshots; restore overwrites checkpointed files, keeps files created later | `git-checkpoint.ts` |
50
- | Persistent memory | per-project memories, index injected each session | `memory.ts` |
51
+ | Checkpoints / rewind | shadow-repo snapshots; restore overwrites checkpointed files, keeps files created later; 100 per session, repos pruned after 30 days | `git-checkpoint.ts` |
52
+ | Persistent memory | per-project memories, index injected each session within Claude's 200-line/25KB bound; a save that would overflow it reports why | `memory.ts` |
51
53
  | WebSearch / WebFetch | key-free DuckDuckGo search, SSRF-guarded fetch | `web.ts` |
52
- | AskUserQuestion | one question with `header`, single- or `multiSelect` options, plus free-text; no multi-question batching | `question.ts` |
54
+ | AskUserQuestion | 1-4 questions per call (asked in sequence), each with `header`, single- or `multiSelect` options, plus free-text | `question.ts` |
53
55
  | Statusline | Claude `statusLine` command contract (stdin JSON, `padding`, `refreshInterval`); built-in turn state + session cost fallback | `status-line.ts` |
54
56
  | Notifications | vendored example | `notify.ts` |
55
57
 
@@ -63,7 +63,11 @@ export default function commandsExtension(pi: ExtensionAPI) {
63
63
  async function runCommand(parsed: ParsedCommand, args: string, ctx: ExtensionCommandContext): Promise<void> {
64
64
  const withArgs = substituteArgs(parsed.body, args)
65
65
  const expanded = await expandDynamicContent(withArgs, ctx.cwd, async (shell) => {
66
- const result = await pi.exec('/bin/sh', ['-c', shell], { timeout: BASH_TIMEOUT_MS })
66
+ // Hooks get CLAUDE_PROJECT_DIR, and a command's bash span is the same kind of
67
+ // project-scoped script. pi.exec takes no env, so it is exported in the script.
68
+ const projectDir = ctx.cwd.replaceAll("'", String.raw`'\''`)
69
+ const script = `export CLAUDE_PROJECT_DIR='${projectDir}'; ${shell}`
70
+ const result = await pi.exec('/bin/sh', ['-c', script], { cwd: ctx.cwd, timeout: BASH_TIMEOUT_MS })
67
71
  return { stdout: result.stdout, stderr: result.stderr, code: result.code }
68
72
  })
69
73
 
@@ -34,6 +34,15 @@ interface Checkpoint {
34
34
  * for the life of the machine. */
35
35
  export const CHECKPOINT_RETENTION_DAYS = 30
36
36
 
37
+ /** Claude keeps the 100 most recent checkpoints per session. Older ones drop off the
38
+ * rewind list; their commits stay in the shadow repo until the retention sweep. */
39
+ export const MAX_CHECKPOINTS_PER_SESSION = 100
40
+
41
+ /** The newest entries, up to the per-session cap, oldest first. */
42
+ export function capCheckpoints<T>(all: T[]): T[] {
43
+ return all.length <= MAX_CHECKPOINTS_PER_SESSION ? all : all.slice(all.length - MAX_CHECKPOINTS_PER_SESSION)
44
+ }
45
+
37
46
  /** Remove shadow repos untouched for longer than the retention window. The live
38
47
  * session's repo is always kept, whatever its age: a long session's directory mtime
39
48
  * can predate the window. Failures are ignored; this is housekeeping, not a gate. */
@@ -209,6 +218,10 @@ export default function gitCheckpointExtension(pi: ExtensionAPI) {
209
218
 
210
219
  const checkpoint: Checkpoint = { entryId: target.entryId, ref: snap.ref, prompt: target.prompt, createdAt: snap.createdAt }
211
220
  checkpoints.set(checkpoint.entryId, checkpoint)
221
+ // Bound the rewind list the way Claude does, dropping the oldest first.
222
+ for (const stale of [...checkpoints.keys()].slice(0, Math.max(0, checkpoints.size - MAX_CHECKPOINTS_PER_SESSION))) {
223
+ checkpoints.delete(stale)
224
+ }
212
225
  pi.appendEntry(CUSTOM_TYPE, checkpoint)
213
226
  })
214
227
 
package/extensions/mcp.ts CHANGED
@@ -28,6 +28,7 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'
28
28
  import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js' // NOSONAR
29
29
  import { getDefaultEnvironment, StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
30
30
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
31
+ import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js'
31
32
  import { Type } from 'typebox'
32
33
  import { MCP_TOOLS_CHANNEL, type McpToolAlias } from './internal/mcp-alias.js'
33
34
  import { capForContext } from './internal/output-guard.js'
@@ -317,8 +318,14 @@ async function connectWithTimeout(client: Client, transport: Parameters<Client['
317
318
  }
318
319
  }
319
320
 
320
- async function listAllTools(client: Client): Promise<Array<{ name: string; description?: string; inputSchema?: unknown }>> {
321
- const tools: Array<{ name: string; description?: string; inputSchema?: unknown }> = []
321
+ export interface McpToolInfo {
322
+ name: string
323
+ description?: string
324
+ inputSchema?: unknown
325
+ }
326
+
327
+ async function listAllTools(client: Client): Promise<McpToolInfo[]> {
328
+ const tools: McpToolInfo[] = []
322
329
  let cursor: string | undefined
323
330
  do {
324
331
  const page = await client.listTools({ cursor })
@@ -331,10 +338,73 @@ async function listAllTools(client: Client): Promise<Array<{ name: string; descr
331
338
  export default async function mcpExtension(pi: ExtensionAPI) {
332
339
  const clients = new Map<string, Client>()
333
340
  const status = new Map<string, { state: string; tools: number }>()
334
- const registered = new Set<string>()
341
+ // pi tool name -> owning server, so a refresh can tell its own tools from a conflict.
342
+ const registered = new Map<string, string>()
335
343
  // Original server/tool names per registered pi name, for Claude-style hook matchers.
336
344
  const aliases: McpToolAlias[] = []
337
345
 
346
+ /** Register every not-yet-registered tool of a server; returns how many were added. */
347
+ function registerTools(name: string, config: ServerConfig, client: Client, tools: McpToolInfo[]): number {
348
+ let count = 0
349
+ for (const tool of tools) {
350
+ const toolName = formatToolName(name, tool.name)
351
+ const owner = registered.get(toolName)
352
+ if (owner === name) continue // already registered for this server: a refresh re-listing it
353
+ if (RESERVED_NAMES.has(toolName) || owner !== undefined) {
354
+ console.warn(`pi-code-mcp: skipping colliding tool name ${toolName}`)
355
+ continue
356
+ }
357
+ registered.set(toolName, name)
358
+ aliases.push({ pi: toolName, claude: `mcp__${name}__${tool.name}` })
359
+ count++
360
+ pi.registerTool({
361
+ name: toolName,
362
+ label: `${name}: ${tool.name}`,
363
+ description: tool.description ?? `MCP tool ${tool.name} from ${name}`,
364
+ parameters: Type.Unsafe(normalizeSchema(tool.inputSchema)),
365
+ async execute(_id, params) {
366
+ // Pass the timeout to the SDK too: its own default request timeout is 60s and
367
+ // would otherwise reject first, so the outer race at CALL_TIMEOUT_MS was dead.
368
+ // Claude's per-server timeout wins over MCP_TOOL_TIMEOUT, with a 1s floor.
369
+ const declared = typeof config.timeout === 'number' && config.timeout >= 1000 ? config.timeout : undefined
370
+ const budget = declared ?? callTimeoutMs()
371
+ const result = await withTimeout(client.callTool({ name: tool.name, arguments: params as Record<string, unknown> }, undefined, { timeout: budget }), budget, toolName)
372
+ const content = mapContent(result.content as McpContentBlock[], result.structuredContent)
373
+ const details: { error?: string } = {}
374
+ if (result.isError) {
375
+ details.error = 'tool_error'
376
+ const hint = JSON.stringify(normalizeSchema(tool.inputSchema))
377
+ content.push({ type: 'text', text: `Tool reported an error. Expected input schema: ${hint}` })
378
+ }
379
+ return { content, details }
380
+ },
381
+ })
382
+ }
383
+ return count
384
+ }
385
+
386
+ /** Claude refreshes tools on a server's list_changed notification. pi has no
387
+ * unregister, so a withdrawn tool keeps its registration and surfaces the server's
388
+ * own error when called; a newly announced one is registered without a restart. */
389
+ function subscribeToToolChanges(name: string, config: ServerConfig, client: Client): void {
390
+ try {
391
+ client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
392
+ try {
393
+ const refreshed = await withTimeout(listAllTools(client), connectTimeoutMs(), `list tools ${name}`)
394
+ const added = registerTools(name, config, client, refreshed)
395
+ if (added === 0) return
396
+ const current = status.get(name)
397
+ status.set(name, { state: current?.state ?? 'connected', tools: (current?.tools ?? 0) + added })
398
+ pi.events.emit(MCP_TOOLS_CHANNEL, [...aliases])
399
+ } catch (error) {
400
+ console.warn(`pi-code-mcp: tool refresh failed for ${name}: ${error instanceof Error ? error.message : String(error)}`)
401
+ }
402
+ })
403
+ } catch {
404
+ // a transport or client without notification support simply never refreshes
405
+ }
406
+ }
407
+
338
408
  async function connectServers(servers: Record<string, ServerConfig>): Promise<void> {
339
409
  for (const [name, config] of Object.entries(servers)) {
340
410
  // A later scope must not take the name of a server that already connected: it
@@ -349,39 +419,8 @@ export default async function mcpExtension(pi: ExtensionAPI) {
349
419
  const client = await connect(name, config)
350
420
  clients.set(name, client)
351
421
  const tools = await withTimeout(listAllTools(client), connectTimeoutMs(), `list tools ${name}`)
352
- let count = 0
353
- for (const tool of tools) {
354
- const toolName = formatToolName(name, tool.name)
355
- if (RESERVED_NAMES.has(toolName) || registered.has(toolName)) {
356
- console.warn(`pi-code-mcp: skipping colliding tool name ${toolName}`)
357
- continue
358
- }
359
- registered.add(toolName)
360
- aliases.push({ pi: toolName, claude: `mcp__${name}__${tool.name}` })
361
- count++
362
- pi.registerTool({
363
- name: toolName,
364
- label: `${name}: ${tool.name}`,
365
- description: tool.description ?? `MCP tool ${tool.name} from ${name}`,
366
- parameters: Type.Unsafe(normalizeSchema(tool.inputSchema)),
367
- async execute(_id, params) {
368
- // Pass the timeout to the SDK too: its own default request timeout is 60s and
369
- // would otherwise reject first, so the outer race at CALL_TIMEOUT_MS was dead.
370
- // Claude's per-server timeout wins over MCP_TOOL_TIMEOUT, with a 1s floor.
371
- const declared = typeof config.timeout === 'number' && config.timeout >= 1000 ? config.timeout : undefined
372
- const budget = declared ?? callTimeoutMs()
373
- const result = await withTimeout(client.callTool({ name: tool.name, arguments: params as Record<string, unknown> }, undefined, { timeout: budget }), budget, toolName)
374
- const content = mapContent(result.content as McpContentBlock[], result.structuredContent)
375
- const details: { error?: string } = {}
376
- if (result.isError) {
377
- details.error = 'tool_error'
378
- const hint = JSON.stringify(normalizeSchema(tool.inputSchema))
379
- content.push({ type: 'text', text: `Tool reported an error. Expected input schema: ${hint}` })
380
- }
381
- return { content, details }
382
- },
383
- })
384
- }
422
+ const count = registerTools(name, config, client, tools)
423
+ subscribeToToolChanges(name, config, client)
385
424
  status.set(name, { state: 'connected', tools: count })
386
425
  } catch (error) {
387
426
  status.set(name, { state: `failed: ${error instanceof Error ? error.message : String(error)}`, tools: 0 })
@@ -63,6 +63,40 @@ export function migrateLegacyStore(cwd: string): void {
63
63
  }
64
64
  }
65
65
 
66
+ /** Whether adding this memory would push the index past what a session can load.
67
+ * Claude reports an explicit error instead of silently writing a memory that will
68
+ * never be seen; replacing an existing entry is not growth. */
69
+ export function indexWouldOverflow(index: string, name: string, description: string): boolean {
70
+ // Editing an entry that already exists is always allowed: it adds no entry, and
71
+ // refusing it would strand a user whose index is already at the bound with no way
72
+ // to revise their way back under it. An over-long description is bounded anyway,
73
+ // since the injected index is capped at read time.
74
+ const isUpdate = index.split('\n').some((entry) => entry.startsWith(entryPrefix(name)))
75
+ if (isUpdate) return false
76
+ const next = upsertIndexLine(index, name, description)
77
+ return next.split('\n').length > INDEX_MAX_LINES || Buffer.byteLength(next, 'utf-8') > INDEX_MAX_BYTES
78
+ }
79
+
80
+ /** Write a memory and its index line, or say why it cannot be written. */
81
+ export function saveMemory(dir: string, indexPath: string, name: string | undefined, description: string | undefined, content: string | undefined): { content: Array<{ type: 'text'; text: string }>; details: Record<string, never> } {
82
+ if (!name || !description || !content) {
83
+ return { content: [{ type: 'text', text: 'save requires name, description, and content.' }], details: {} }
84
+ }
85
+ const index = readIndex(dir)
86
+ // Claude reports an explicit error rather than writing a memory the next session
87
+ // would never load, and says what to do about it.
88
+ if (indexWouldOverflow(index, name, description)) {
89
+ return {
90
+ content: [{ type: 'text', text: `Memory index is full (${INDEX_MAX_LINES} entries or ${INDEX_MAX_BYTES} bytes). Delete or consolidate memories before saving ${name}.` }],
91
+ details: {},
92
+ }
93
+ }
94
+ fs.mkdirSync(dir, { recursive: true })
95
+ fs.writeFileSync(path.join(dir, `${name}.md`), content)
96
+ fs.writeFileSync(indexPath, upsertIndexLine(index, name, description))
97
+ return { content: [{ type: 'text', text: `Saved memory ${name}.` }], details: {} }
98
+ }
99
+
66
100
  /** The index as injected into the prompt, bounded like Claude's startup load. */
67
101
  export function capIndexForPrompt(index: string): string {
68
102
  const withinLines = index.split('\n').slice(0, INDEX_MAX_LINES)
@@ -151,13 +185,7 @@ export default function memoryExtension(pi: ExtensionAPI) {
151
185
  const indexPath = path.join(dir, INDEX_FILE)
152
186
 
153
187
  if (params.action === 'save') {
154
- if (!name || !params.description || !params.content) {
155
- return { content: [{ type: 'text' as const, text: 'save requires name, description, and content.' }], details: {} }
156
- }
157
- fs.mkdirSync(dir, { recursive: true })
158
- fs.writeFileSync(path.join(dir, `${name}.md`), params.content)
159
- fs.writeFileSync(indexPath, upsertIndexLine(readIndex(dir), name, params.description))
160
- return { content: [{ type: 'text' as const, text: `Saved memory ${name}.` }], details: {} }
188
+ return saveMemory(dir, indexPath, name, params.description, params.content)
161
189
  }
162
190
 
163
191
  if (params.action === 'read') {
@@ -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,44 @@ 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
- header: Type.Optional(Type.String({ description: 'Short label for the question, shown above it (max 12 characters)', maxLength: 12 })),
37
+ header: Type.Optional(Type.String({ description: 'Short label for the question, shown above it, kept to 12 characters' })),
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, kept to 12 characters' })),
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: shortHeader(params.header), options: params.options ?? [], multiSelect: params.multiSelect }]
64
+ return []
65
+ }
66
+
67
+ /** Claude keeps a header short for the label slot. Truncating is the forgiving read:
68
+ * rejecting the call costs a turn while the model recovers from a validation error,
69
+ * which is a poor trade for a display detail. */
70
+ export const HEADER_MAX = 12
71
+ export const shortHeader = (header: string | undefined): string | undefined => (header === undefined ? undefined : header.slice(0, HEADER_MAX))
72
+
42
73
  function checkbox(checked: boolean | undefined): string {
43
74
  if (checked === undefined) return ''
44
75
  return checked ? '[x] ' : '[ ] '
@@ -121,157 +152,29 @@ export default function question(pi: ExtensionAPI) {
121
152
  pi.registerTool({
122
153
  name: 'question',
123
154
  label: 'Question',
124
- description: 'Ask the user a question and let them pick from options. Use when you need user input to proceed.',
155
+ description:
156
+ '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
157
  parameters: QuestionParams,
126
158
 
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
- }
159
+ async execute(_toolCallId, rawParams, _signal, _onUpdate, ctx) {
160
+ const specs = questionList(rawParams as Partial<QuestionSpec> & { questions?: QuestionSpec[] })
161
+ if (specs.length === 0) {
162
+ return { content: [{ type: 'text', text: 'Error: No question provided' }], details: { question: '', options: [], answer: null } as QuestionDetails }
137
163
  }
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
- }
269
- }
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,
164
+ if (specs.length === 1) return await askOne(specs[0], ctx)
165
+
166
+ // Several questions are asked in sequence; a cancel ends the run, since the
167
+ // remaining answers would be guesses about a flow the user just declined.
168
+ const texts: string[] = []
169
+ const collected: QuestionDetails[] = []
170
+ for (const spec of specs) {
171
+ const result = await askOne(spec, ctx)
172
+ const detail = result.details as QuestionDetails
173
+ collected.push(detail)
174
+ texts.push(`${spec.question}\n${result.content[0].text}`)
175
+ if (detail.answer === null) break
274
176
  }
177
+ return { content: [{ type: 'text', text: texts.join('\n\n') }], details: { ...collected[0], questions: collected } as QuestionDetails }
275
178
  },
276
179
 
277
180
  renderCall(args, theme, _context) {
@@ -288,7 +191,6 @@ export default function question(pi: ExtensionAPI) {
288
191
  }
289
192
  return new Text(text, 0, 0)
290
193
  },
291
-
292
194
  renderResult(result, _options, theme, _context) {
293
195
  const details = result.details as QuestionDetails | undefined
294
196
  if (!details) {
@@ -312,3 +214,153 @@ export default function question(pi: ExtensionAPI) {
312
214
  },
313
215
  })
314
216
  }
217
+
218
+ async function askOne(params: QuestionSpec, ctx: ExtensionContext): Promise<{ content: Array<{ type: 'text'; text: string }>; details: QuestionDetails }> {
219
+ if (!ctx.hasUI) {
220
+ return {
221
+ content: [{ type: 'text', text: 'Error: UI not available (running in non-interactive mode)' }],
222
+ details: {
223
+ question: params.question,
224
+ options: params.options.map((o) => o.label),
225
+ answer: null,
226
+ } as QuestionDetails,
227
+ }
228
+ }
229
+
230
+ if (params.options.length === 0) {
231
+ return {
232
+ content: [{ type: 'text', text: 'Error: No options provided' }],
233
+ details: { question: params.question, options: [], answer: null } as QuestionDetails,
234
+ }
235
+ }
236
+
237
+ const multiSelect = params.multiSelect === true
238
+ // The free-text option does not compose with checkbox selection, so it is single-select only.
239
+ const allOptions: DisplayOption[] = multiSelect ? [...params.options] : [...params.options, { label: 'Type something.', isOther: true }]
240
+
241
+ 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) => {
242
+ let optionIndex = 0
243
+ let editMode = false
244
+ const checked: boolean[] = allOptions.map(() => false)
245
+ let cachedLines: string[] | undefined
246
+ let cachedWidth: number | undefined
247
+
248
+ const editorTheme: EditorTheme = {
249
+ borderColor: (s) => theme.fg('accent', s),
250
+ selectList: {
251
+ selectedPrefix: (t) => theme.fg('accent', t),
252
+ selectedText: (t) => theme.fg('accent', t),
253
+ description: (t) => theme.fg('muted', t),
254
+ scrollInfo: (t) => theme.fg('dim', t),
255
+ noMatch: (t) => theme.fg('warning', t),
256
+ },
257
+ }
258
+ const editor = new Editor(tui, editorTheme)
259
+
260
+ editor.onSubmit = (value) => {
261
+ const trimmed = value.trim()
262
+ if (trimmed) {
263
+ done({ answer: trimmed, wasCustom: true })
264
+ } else {
265
+ editMode = false
266
+ editor.setText('')
267
+ refresh()
268
+ }
269
+ }
270
+
271
+ function refresh() {
272
+ cachedLines = undefined
273
+ tui.requestRender()
274
+ }
275
+
276
+ function handleInput(data: string) {
277
+ if (editMode) {
278
+ if (matchesKey(data, Key.escape)) {
279
+ editMode = false
280
+ editor.setText('')
281
+ refresh()
282
+ return
283
+ }
284
+ editor.handleInput(data)
285
+ refresh()
286
+ return
287
+ }
288
+
289
+ if (matchesKey(data, Key.up)) {
290
+ optionIndex = Math.max(0, optionIndex - 1)
291
+ refresh()
292
+ return
293
+ }
294
+ if (matchesKey(data, Key.down)) {
295
+ optionIndex = Math.min(allOptions.length - 1, optionIndex + 1)
296
+ refresh()
297
+ return
298
+ }
299
+
300
+ if (multiSelect && data === ' ') {
301
+ checked[optionIndex] = !checked[optionIndex]
302
+ refresh()
303
+ return
304
+ }
305
+
306
+ if (matchesKey(data, Key.enter)) {
307
+ if (multiSelect) {
308
+ done({ answer: selectedLabels(allOptions, checked), wasCustom: false })
309
+ return
310
+ }
311
+ const selected = allOptions[optionIndex]
312
+ if (selected.isOther) {
313
+ editMode = true
314
+ refresh()
315
+ } else {
316
+ done({ answer: selected.label, wasCustom: false, index: optionIndex + 1 })
317
+ }
318
+ return
319
+ }
320
+
321
+ if (matchesKey(data, Key.escape)) {
322
+ done(null)
323
+ }
324
+ }
325
+
326
+ function render(width: number): string[] {
327
+ if (cachedLines && cachedWidth === width) return cachedLines
328
+ cachedWidth = width
329
+ cachedLines = buildQuestionLines({ width, question: params.question, header: shortHeader(params.header), options: allOptions, optionIndex, editMode, multiSelect, checked, editor, theme })
330
+ return cachedLines
331
+ }
332
+
333
+ return {
334
+ render,
335
+ invalidate: () => {
336
+ cachedWidth = undefined
337
+ cachedLines = undefined
338
+ },
339
+ handleInput,
340
+ }
341
+ })
342
+
343
+ // Build simple options list for details; header/multiSelect appear only when set,
344
+ // so single-select details are unchanged.
345
+ const simpleOptions = params.options.map((o) => o.label)
346
+ const base = { question: params.question, options: simpleOptions, ...(params.header ? { header: shortHeader(params.header) } : {}), ...(multiSelect ? { multiSelect: true } : {}) }
347
+
348
+ if (!result) {
349
+ return {
350
+ content: [{ type: 'text', text: 'User cancelled the selection' }],
351
+ details: { ...base, answer: null } as QuestionDetails,
352
+ }
353
+ }
354
+
355
+ if (result.wasCustom) {
356
+ return {
357
+ content: [{ type: 'text', text: `User wrote: ${result.answer}` }],
358
+ details: { ...base, answer: result.answer, wasCustom: true } as QuestionDetails,
359
+ }
360
+ }
361
+ const selectionText = multiSelect ? `User selected: ${result.answer || '(none)'}` : `User selected: ${result.index}. ${result.answer}`
362
+ return {
363
+ content: [{ type: 'text', text: selectionText }],
364
+ details: { ...base, answer: result.answer, wasCustom: false } as QuestionDetails,
365
+ }
366
+ }
@@ -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
  }
@@ -26,8 +26,9 @@ import { type Static, Type } from 'typebox'
26
26
  import { capForContext } from '../internal/output-guard.js'
27
27
  import { isProjectApproved, isProjectApprovedSilently } from '../internal/project-approval.js'
28
28
  import { SUBAGENT_CHANNEL } from '../internal/subagent-events.js'
29
- import { type AgentConfig, type AgentScope, discoverAgents } from './agents.js'
30
- import { activeBackgroundRuns, backgroundStatusText, cancelBackgroundRun, MAX_BACKGROUND_RUNS, startBackgroundRun } from './background.js'
29
+ import { skillDirs } from '../skills.js'
30
+ import { type AgentConfig, type AgentScope, discoverAgents, resolveModelAlias, withPreloadedSkills } from './agents.js'
31
+ import { activeBackgroundRuns, backgroundStatusText, cancelBackgroundRun, MAX_BACKGROUND_RUNS, resumeBackgroundRun, startBackgroundRun } from './background.js'
31
32
 
32
33
  const MAX_PARALLEL_TASKS = 8
33
34
  const MAX_CONCURRENCY = 4
@@ -259,6 +260,10 @@ interface RunAgentOptions {
259
260
  onUpdate?: OnUpdateCallback
260
261
  makeDetails: (results: SingleResult[]) => SubagentDetails
261
262
  onPhase?: SubagentPhaseSink
263
+ /** Skill directories to preload from, resolved where project trust is known. */
264
+ skillRoots?: string[]
265
+ /** Models this user can actually run, for resolving a tier alias. */
266
+ availableModels?: ReadonlyArray<{ id: string }>
262
267
  }
263
268
 
264
269
  /** Publishes a child run's start/stop for the hooks extension's SubagentStart/Stop. */
@@ -294,7 +299,7 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
294
299
  }
295
300
  }
296
301
 
297
- const args = agentInvocationArgs(agent)
302
+ const args = agentInvocationArgs(agent, resolveModelAlias(agent.modelAlias, options.availableModels ?? []))
298
303
 
299
304
  let tmpPromptDir: string | null = null
300
305
  let tmpPromptPath: string | null = null
@@ -321,8 +326,9 @@ async function runSingleAgentInner(options: RunAgentOptions): Promise<SingleResu
321
326
  }
322
327
 
323
328
  try {
324
- if (agent.systemPrompt.trim()) {
325
- const tmp = await writePromptToTempFile(agent.name, agent.systemPrompt)
329
+ const promptWithSkills = withPreloadedSkills(agent.systemPrompt, agent.skills, options.skillRoots ?? [])
330
+ if (promptWithSkills.trim()) {
331
+ const tmp = await writePromptToTempFile(agent.name, promptWithSkills)
326
332
  tmpPromptDir = tmp.dir
327
333
  tmpPromptPath = tmp.filePath
328
334
  args.push('--append-system-prompt', tmpPromptPath)
@@ -461,6 +467,7 @@ const SubagentParams = Type.Object({
461
467
  background: Type.Optional(Type.Boolean({ description: 'Run the single-mode task in the background: returns a run id immediately and a notification arrives when it completes.' })),
462
468
  status: Type.Optional(Type.Boolean({ description: 'Set true (alone, no other params) to list background runs instead of running anything.' })),
463
469
  cancel: Type.Optional(Type.String({ description: 'Background run id to cancel (from the id returned when it started, or from status).' })),
470
+ resume: Type.Optional(Type.String({ description: 'Finished background run id to continue with a follow-up task; the child keeps everything it already saw. Pass task with it.' })),
464
471
  })
465
472
 
466
473
  /**
@@ -490,6 +497,21 @@ type SubagentParamsStatic = Static<typeof SubagentParams>
490
497
  type ChainStepParam = Static<typeof ChainItem>
491
498
  type TaskItemParam = Static<typeof TaskItem>
492
499
 
500
+ /** The completion notice a background run sends when it finishes. */
501
+ export function backgroundCompletionText(run: { id: string; agent: string; state: string; turns: number; output?: string }): string {
502
+ const output = capForContext(run.output ?? '') || '(no output)'
503
+ return `Background subagent run ${run.id} (${run.agent}) ${run.state} after ${run.turns} turns.\n\n${output}`
504
+ }
505
+
506
+ /** What to tell the model about a resume request. */
507
+ export function resumeResultText(id: string, task: string | undefined, onComplete: (run: { id: string; agent: string; state: string; turns: number; output?: string }) => void): string {
508
+ if (!task) return 'Pass task with resume: the follow-up needs an instruction.'
509
+ const outcome = resumeBackgroundRun(id, task, onComplete)
510
+ if (outcome === 'resumed') return `Resumed background run ${id} with the follow-up task; a notification will arrive on completion.`
511
+ if (outcome === 'still-running') return `Background run ${id} is still running; wait for it or cancel it first.`
512
+ return `Unknown background run: ${id}.\n\n${backgroundStatusText()}`
513
+ }
514
+
493
515
  /** What to tell the model about a cancel request. */
494
516
  export function cancelResultText(id: string): string {
495
517
  const outcome = cancelBackgroundRun(id)
@@ -506,6 +528,8 @@ interface ModeContext {
506
528
  onUpdate: OnUpdateCallback | undefined
507
529
  makeDetails: MakeDetails
508
530
  onPhase?: SubagentPhaseSink
531
+ skillRoots: string[]
532
+ availableModels: ReadonlyArray<{ id: string }>
509
533
  }
510
534
 
511
535
  async function checkProjectAgentGate(params: SubagentParamsStatic, agents: AgentConfig[], ctx: ExtensionContext, projectAgentsDir: string | null, gateMode: SubagentMode, makeDetails: MakeDetails): Promise<ToolResult | null> {
@@ -539,11 +563,14 @@ async function checkProjectAgentGate(params: SubagentParamsStatic, agents: Agent
539
563
  }
540
564
 
541
565
  /** CLI args shared by foreground and background children, from the agent's config. */
542
- function agentInvocationArgs(agent: AgentConfig): string[] {
566
+ function agentInvocationArgs(agent: AgentConfig, aliasModel?: string): string[] {
543
567
  const args: string[] = ['--mode', 'json', '-p', '--no-session']
544
- // pi reads a thinking level from the model pattern's :suffix, so Claude's effort
545
- // has a seam only when the agent pins a concrete model.
546
- if (agent.model) args.push('--model', agent.effort ? `${agent.model}:${agent.effort}` : agent.model)
568
+ // A concrete model wins; otherwise a Claude tier alias resolved against the models
569
+ // this user can actually run. pi reads a thinking level from the model pattern's
570
+ // :suffix when a model is pinned, and from --thinking otherwise.
571
+ const model = agent.model ?? aliasModel
572
+ if (model) args.push('--model', agent.effort ? `${model}:${agent.effort}` : model)
573
+ else if (agent.effort) args.push('--thinking', agent.effort)
547
574
  if (agent.tools && agent.tools.length > 0) args.push('--tools', agent.tools.join(','))
548
575
  if (agent.disallowedTools && agent.disallowedTools.length > 0) args.push('--exclude-tools', agent.disallowedTools.join(','))
549
576
  return args
@@ -570,7 +597,7 @@ function removeTmpPrompt(tmpPrompt: { dir: string; filePath: string } | undefine
570
597
  }
571
598
  }
572
599
 
573
- async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConfig[], defaultCwd: string, pi: ExtensionAPI, makeDetails: MakeDetails): Promise<ToolResult> {
600
+ async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConfig[], defaultCwd: string, pi: ExtensionAPI, makeDetails: MakeDetails, skillRoots: string[], availableModels: ReadonlyArray<{ id: string }>): Promise<ToolResult> {
574
601
  const task = params.task
575
602
  const agentName = params.agent
576
603
  if (!task || !agentName) {
@@ -590,10 +617,11 @@ async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConf
590
617
  if (activeBackgroundRuns() >= MAX_BACKGROUND_RUNS) {
591
618
  return backgroundCapResult(makeDetails)
592
619
  }
593
- const args = agentInvocationArgs(agent)
620
+ const args = agentInvocationArgs(agent, resolveModelAlias(agent.modelAlias, availableModels))
594
621
  let tmpPrompt: { dir: string; filePath: string } | undefined
595
- if (agent.systemPrompt.trim()) {
596
- tmpPrompt = await writePromptToTempFile(agent.name, agent.systemPrompt)
622
+ const promptWithSkills = withPreloadedSkills(agent.systemPrompt, agent.skills, skillRoots)
623
+ if (promptWithSkills.trim()) {
624
+ tmpPrompt = await writePromptToTempFile(agent.name, promptWithSkills)
597
625
  args.push('--append-system-prompt', tmpPrompt.filePath)
598
626
  }
599
627
  args.push(`Task: ${task}`)
@@ -601,15 +629,7 @@ async function runBackgroundMode(params: SubagentParamsStatic, agents: AgentConf
601
629
  const id = startBackgroundRun(agent.name, task, { command: invocation.command, args: invocation.args, cwd: params.cwd ?? defaultCwd }, (run) => {
602
630
  removeTmpPrompt(tmpPrompt)
603
631
  pi.events.emit(SUBAGENT_CHANNEL, { phase: 'stop', agentType: run.agent, agentId: run.id })
604
- const output = capForContext(run.output ?? '') || '(no output)'
605
- pi.sendMessage(
606
- {
607
- customType: 'subagent-background',
608
- content: `Background subagent run ${run.id} (${run.agent}) ${run.state} after ${run.turns} turns.\n\n${output}`,
609
- display: true,
610
- },
611
- { triggerTurn: true },
612
- )
632
+ pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
613
633
  })
614
634
  if (id === null) {
615
635
  // Lost the cap race to a parallel batch: the atomic check inside startBackgroundRun refused.
@@ -659,6 +679,8 @@ async function runChainMode(chain: ChainStepParam[], mode: ModeContext): Promise
659
679
  onUpdate: chainUpdate,
660
680
  makeDetails: makeDetails('chain'),
661
681
  onPhase: mode.onPhase,
682
+ skillRoots: mode.skillRoots,
683
+ availableModels: mode.availableModels,
662
684
  })
663
685
  results.push(result)
664
686
 
@@ -774,6 +796,8 @@ async function runSingleMode(agentName: string, task: string, cwd: string | unde
774
796
  onUpdate,
775
797
  makeDetails: makeDetails('single'),
776
798
  onPhase: mode.onPhase,
799
+ skillRoots: mode.skillRoots,
800
+ availableModels: mode.availableModels,
777
801
  })
778
802
  const isError = result.exitCode !== 0 || result.stopReason === 'error' || result.stopReason === 'aborted'
779
803
  if (isError) {
@@ -1077,6 +1101,10 @@ function renderParallelResult(results: SingleResult[], expanded: boolean, theme:
1077
1101
  }
1078
1102
 
1079
1103
  export default function subagentExtension(pi: ExtensionAPI) {
1104
+ const notifyBackgroundCompletion = (run: { id: string; agent: string; state: string; turns: number; output?: string }): void => {
1105
+ pi.sendMessage({ customType: 'subagent-background', content: backgroundCompletionText(run), display: true }, { triggerTurn: true })
1106
+ }
1107
+
1080
1108
  // Claude surfaces each agent's description so the model can pick one autonomously.
1081
1109
  // Rebuilt per turn (agents are rediscovered per invocation too); project agents are
1082
1110
  // included only when the project is already approved, read without prompting, since
@@ -1129,6 +1157,10 @@ export default function subagentExtension(pi: ExtensionAPI) {
1129
1157
  results,
1130
1158
  })
1131
1159
 
1160
+ if (params.resume) {
1161
+ return { content: [{ type: 'text', text: resumeResultText(params.resume, params.task, notifyBackgroundCompletion) }], details: makeDetails('single')([]) }
1162
+ }
1163
+
1132
1164
  if (params.cancel) {
1133
1165
  return { content: [{ type: 'text', text: cancelResultText(params.cancel) }], details: makeDetails('single')([]) }
1134
1166
  }
@@ -1157,9 +1189,16 @@ export default function subagentExtension(pi: ExtensionAPI) {
1157
1189
  const gateResult = await checkProjectAgentGate(params, agents, ctx, discovery.projectAgentsDir, gateMode, makeDetails)
1158
1190
  if (gateResult) return gateResult
1159
1191
 
1160
- if (params.background) return runBackgroundMode(params, agents, ctx.cwd, pi, makeDetails)
1192
+ // Project skills only preload once the project is approved, matching the
1193
+ // gate the skills extension applies to discovery itself.
1194
+ const skillRoots = skillDirs(ctx.cwd, os.homedir(), isProjectApprovedSilently(ctx))
1195
+ // Tier aliases resolve against what this user is authenticated for; an
1196
+ // unavailable tier still falls back to the session model.
1197
+ const availableModels = ctx.modelRegistry?.getAvailable?.() ?? []
1198
+
1199
+ if (params.background) return runBackgroundMode(params, agents, ctx.cwd, pi, makeDetails, skillRoots, availableModels)
1161
1200
 
1162
- const mode: ModeContext = { agents, defaultCwd: ctx.cwd, signal, onUpdate, makeDetails, onPhase: (phase, agentType, agentId) => pi.events.emit(SUBAGENT_CHANNEL, { phase, agentType, agentId }) }
1201
+ const mode: ModeContext = { agents, defaultCwd: ctx.cwd, signal, onUpdate, makeDetails, skillRoots, availableModels, onPhase: (phase, agentType, agentId) => pi.events.emit(SUBAGENT_CHANNEL, { phase, agentType, agentId }) }
1163
1202
 
1164
1203
  if (params.chain?.length) return runChainMode(params.chain, mode)
1165
1204
  if (params.tasks?.length) return runParallelMode(params.tasks, mode)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-code",
3
- "version": "0.9.0",
3
+ "version": "1.0.1",
4
4
  "description": "Claude Code experience for the pi coding agent: reads your .claude config (rules, commands, skills, hooks, output styles, MCP servers, agents) and adds todo, checkpoints, memory, web, and subagents",
5
5
  "keywords": [
6
6
  "pi",