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 +9 -7
- package/extensions/commands.ts +5 -1
- package/extensions/git-checkpoint.ts +13 -0
- package/extensions/mcp.ts +75 -36
- package/extensions/memory.ts +35 -7
- package/extensions/question.ts +203 -151
- package/extensions/skills.ts +12 -3
- package/extensions/subagent/README.md +12 -5
- package/extensions/subagent/agents.ts +73 -0
- package/extensions/subagent/background.ts +38 -3
- package/extensions/subagent/index.ts +63 -24
- package/package.json +1 -1
package/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
|

|
|
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 |
|
|
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
|
|
package/extensions/commands.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
321
|
-
|
|
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
|
-
|
|
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
|
-
|
|
353
|
-
|
|
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 })
|
package/extensions/memory.ts
CHANGED
|
@@ -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
|
-
|
|
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') {
|
package/extensions/question.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Multiple questions per call are not batched; ask sequentially.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type { ExtensionAPI, Theme } from '@earendil-works/pi-coding-agent'
|
|
9
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from '@earendil-works/pi-coding-agent'
|
|
10
10
|
import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from '@earendil-works/pi-tui'
|
|
11
11
|
import { Type } from 'typebox'
|
|
12
12
|
|
|
@@ -32,13 +32,44 @@ const OptionSchema = Type.Object({
|
|
|
32
32
|
description: Type.Optional(Type.String({ description: 'Optional description shown below label' })),
|
|
33
33
|
})
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
const SingleQuestion = Type.Object({
|
|
36
36
|
question: Type.String({ description: 'The question to ask the user' }),
|
|
37
|
-
header: Type.Optional(Type.String({ description: 'Short label for the question, shown above it
|
|
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:
|
|
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,
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
+
}
|
package/extensions/skills.ts
CHANGED
|
@@ -15,6 +15,8 @@ import * as os from 'node:os'
|
|
|
15
15
|
import * as path from 'node:path'
|
|
16
16
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
17
17
|
|
|
18
|
+
import { isProjectApprovedSilently } from './internal/project-approval.js'
|
|
19
|
+
|
|
18
20
|
function isDirectory(target: string): boolean {
|
|
19
21
|
try {
|
|
20
22
|
return fs.statSync(target).isDirectory()
|
|
@@ -24,8 +26,13 @@ function isDirectory(target: string): boolean {
|
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
/** Existing `.claude/skills` directories, user first then project. */
|
|
27
|
-
|
|
28
|
-
|
|
29
|
+
/** Existing `.claude/skills` directories, user first then project. The project
|
|
30
|
+
* directory is included only for approved projects: pi's loader surfaces every skill's
|
|
31
|
+
* name and description to the model, so an untrusted repository would otherwise get
|
|
32
|
+
* text into the prompt without the user ever agreeing to load its config. */
|
|
33
|
+
export function skillDirs(cwd: string, home: string, trusted: boolean): string[] {
|
|
34
|
+
const candidates = [path.join(home, '.claude', 'skills')]
|
|
35
|
+
if (trusted) candidates.push(path.join(cwd, '.claude', 'skills'))
|
|
29
36
|
const dirs: string[] = []
|
|
30
37
|
for (const dir of candidates) {
|
|
31
38
|
if (!dirs.includes(dir) && isDirectory(dir)) dirs.push(dir)
|
|
@@ -35,7 +42,9 @@ export function skillDirs(cwd: string, home: string): string[] {
|
|
|
35
42
|
|
|
36
43
|
export default function skillsExtension(pi: ExtensionAPI) {
|
|
37
44
|
pi.on('resources_discover', async (_event, ctx) => {
|
|
38
|
-
|
|
45
|
+
// resources_discover fires after session_start, so the approval is already
|
|
46
|
+
// resolved; reading it silently keeps a second trust dialog off the screen.
|
|
47
|
+
const skillPaths = skillDirs(ctx.cwd, os.homedir(), isProjectApprovedSilently(ctx))
|
|
39
48
|
return skillPaths.length > 0 ? { skillPaths } : undefined
|
|
40
49
|
})
|
|
41
50
|
}
|
|
@@ -7,7 +7,7 @@ Delegate tasks to specialized subagents with isolated context windows.
|
|
|
7
7
|
- **Isolated context**: Each subagent runs in a separate `pi` process
|
|
8
8
|
- **Streaming output**: See tool calls and progress as they happen
|
|
9
9
|
- **Parallel streaming**: All parallel tasks stream updates simultaneously
|
|
10
|
-
- **Background runs**: `{background: true}` returns a run id and notifies on completion; `{status: true}` lists runs
|
|
10
|
+
- **Background runs**: `{background: true}` returns a run id and notifies on completion; `{status: true}` lists runs, `{cancel: "<id>"}` stops one (signalling its process group), and `{resume: "<id>", task: "..."}` continues a finished run under its own session, so the child keeps everything it already saw; max 8 running at once
|
|
11
11
|
- **Bounded fan-out**: A subagent refuses to spawn subagents of its own (an env marker the tool honors: steering, not a sandbox)
|
|
12
12
|
- **Markdown rendering**: Final output rendered with proper formatting (expanded view)
|
|
13
13
|
- **Usage tracking**: Shows turns, tokens, cost, and context usage per agent
|
|
@@ -111,11 +111,18 @@ System prompt for the agent goes here.
|
|
|
111
111
|
|
|
112
112
|
Claude Code fields map onto pi where a sensible seam exists: `tools` and
|
|
113
113
|
`disallowedTools` (comma string or YAML list) become pi's `--tools` /
|
|
114
|
-
`--exclude-tools`; `effort` becomes the `:thinking` suffix on a pinned model
|
|
114
|
+
`--exclude-tools`; `effort` becomes the `:thinking` suffix on a pinned model, or
|
|
115
|
+
`--thinking` when no model is pinned;
|
|
115
116
|
`permissionMode: plan` selects a read-only toolset unless `tools` is set. Model
|
|
116
|
-
aliases (`sonnet`, `opus`, `haiku
|
|
117
|
-
|
|
118
|
-
`
|
|
117
|
+
aliases (`sonnet`, `opus`, `haiku`) resolve against the models this machine is
|
|
118
|
+
authenticated for, falling back to the session's default model when that tier is
|
|
119
|
+
unavailable; `inherit` is the session model by definition. `skills` names skills to preload: their bodies are inlined into the child's
|
|
120
|
+
prompt, since a child pi process does not inherit the parent's skill discovery, and
|
|
121
|
+
a name that resolves to nothing is reported in the prompt rather than dropped.
|
|
122
|
+
Fields with no pi seam are ignored, each verified against pi's CLI rather than
|
|
123
|
+
assumed: `maxTurns` (no turn-limit flag), `mcpServers` (a child reads MCP config
|
|
124
|
+
from files, and writing config into the workspace to fake it would be worse than
|
|
125
|
+
the gap), and `memory` (pi-code's memory is per project, not per agent).
|
|
119
126
|
|
|
120
127
|
**Locations:**
|
|
121
128
|
- `~/.claude/agents/*.md`, `~/.pi/agent/agents/*.md` - User-level (always loaded; `~/.pi` wins a name conflict)
|
|
@@ -54,6 +54,24 @@ function parseModelField(raw: unknown): string | undefined {
|
|
|
54
54
|
return model && !CLAUDE_MODEL_ALIASES.has(model.toLowerCase()) ? model : undefined
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** The tier alias an agent asked for, kept so it can be resolved against the models
|
|
58
|
+
* this user is actually authenticated for. `inherit` is not a tier: it means the
|
|
59
|
+
* session model, which is also the fallback when a tier is unavailable. */
|
|
60
|
+
function parseModelAlias(raw: unknown): string | undefined {
|
|
61
|
+
if (typeof raw !== 'string') return undefined
|
|
62
|
+
const alias = raw.trim().toLowerCase()
|
|
63
|
+
return alias !== 'inherit' && CLAUDE_MODEL_ALIASES.has(alias) ? alias : undefined
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Resolve a Claude tier alias to a concrete model id the user can actually run.
|
|
67
|
+
* Returning undefined leaves the child on the session model, which is what the
|
|
68
|
+
* unresolvable case degraded to before and still does. */
|
|
69
|
+
export function resolveModelAlias(alias: string | undefined, available: ReadonlyArray<{ id: string; provider?: string }>): string | undefined {
|
|
70
|
+
if (!alias || alias === 'inherit') return undefined
|
|
71
|
+
const needle = alias.toLowerCase()
|
|
72
|
+
return available.find((model) => model.id.toLowerCase().includes(needle))?.id
|
|
73
|
+
}
|
|
74
|
+
|
|
57
75
|
/** pi's extended thinking levels; Claude's effort values are a subset, so they map 1:1. */
|
|
58
76
|
const THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
|
|
59
77
|
|
|
@@ -63,6 +81,55 @@ function parseEffortField(raw: unknown): string | undefined {
|
|
|
63
81
|
return THINKING_LEVELS.has(effort) ? effort : undefined
|
|
64
82
|
}
|
|
65
83
|
|
|
84
|
+
/** Claude's `skills` frontmatter: a comma string or YAML list of skill names. */
|
|
85
|
+
function parseSkillsField(raw: unknown): string[] | undefined {
|
|
86
|
+
let names: string[] = []
|
|
87
|
+
if (Array.isArray(raw)) names = raw.map(String)
|
|
88
|
+
else if (typeof raw === 'string') names = raw.split(',')
|
|
89
|
+
const cleaned = names.map((name) => name.trim()).filter(Boolean)
|
|
90
|
+
return cleaned.length > 0 ? cleaned : undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Inline the named skills into an agent's prompt. Claude preloads a subagent's
|
|
94
|
+
* `skills` at startup rather than letting it discover them, and a child pi process
|
|
95
|
+
* does not inherit the parent's skill discovery, so the bodies travel in the prompt.
|
|
96
|
+
* A name that resolves to nothing is reported rather than dropped: a silently missing
|
|
97
|
+
* instruction is worse than a visible gap. */
|
|
98
|
+
export function withPreloadedSkills(prompt: string, skills: string[] | undefined, skillDirs: string[]): string {
|
|
99
|
+
if (!skills || skills.length === 0) return prompt
|
|
100
|
+
const sections: string[] = []
|
|
101
|
+
for (const name of skills) {
|
|
102
|
+
const body = readSkillBody(name, skillDirs)
|
|
103
|
+
sections.push(body === undefined ? `<skill name="${name}">(skill not found)</skill>` : `<skill name="${name}">\n${body.trim()}\n</skill>`)
|
|
104
|
+
}
|
|
105
|
+
return `${prompt}\n\n## Preloaded skills\n\n${sections.join('\n\n')}`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** A skill name is a single directory or file stem, never a path. The name comes from
|
|
109
|
+
* agent frontmatter, which a repository can control, and the body is inlined into the
|
|
110
|
+
* prompt sent to the model, so a traversal would be an arbitrary-file read. */
|
|
111
|
+
const SKILL_NAME = /^[A-Za-z0-9_.-]+$/
|
|
112
|
+
|
|
113
|
+
function readSkillBody(name: string, skillDirs: string[]): string | undefined {
|
|
114
|
+
if (!SKILL_NAME.test(name) || name === '.' || name === '..') return undefined
|
|
115
|
+
for (const dir of skillDirs) {
|
|
116
|
+
const root = path.resolve(dir)
|
|
117
|
+
for (const candidate of [path.join(dir, name, 'SKILL.md'), path.join(dir, `${name}.md`)]) {
|
|
118
|
+
// Belt and braces against symlinks and platform path quirks: the file actually
|
|
119
|
+
// read must still sit under the skills directory it was resolved from.
|
|
120
|
+
if (!path.resolve(candidate).startsWith(root + path.sep)) continue
|
|
121
|
+
try {
|
|
122
|
+
const content = fs.readFileSync(candidate, 'utf-8')
|
|
123
|
+
const match = /^---\r?\n[\s\S]*?\r?\n---/.exec(content)
|
|
124
|
+
return match ? content.slice(match[0].length) : content
|
|
125
|
+
} catch {
|
|
126
|
+
// try the next shape
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return undefined
|
|
131
|
+
}
|
|
132
|
+
|
|
66
133
|
/** permissionMode has no pi equivalent; 'plan' means a research agent, so translate
|
|
67
134
|
* the intent into a read-only toolset unless the file pins tools itself. */
|
|
68
135
|
const READ_ONLY_TOOLS = ['read', 'grep', 'find', 'ls']
|
|
@@ -90,6 +157,8 @@ function parseAgentFile(content: string, source: AgentSource, filePath: string):
|
|
|
90
157
|
disallowedTools,
|
|
91
158
|
model: parseModelField(frontmatter.model),
|
|
92
159
|
effort: parseEffortField(frontmatter.effort),
|
|
160
|
+
modelAlias: parseModelAlias(frontmatter.model),
|
|
161
|
+
skills: parseSkillsField(frontmatter.skills),
|
|
93
162
|
systemPrompt: body,
|
|
94
163
|
source,
|
|
95
164
|
filePath,
|
|
@@ -105,6 +174,10 @@ export interface AgentConfig {
|
|
|
105
174
|
disallowedTools?: string[]
|
|
106
175
|
model?: string
|
|
107
176
|
effort?: string
|
|
177
|
+
/** Claude tier alias (`sonnet`/`opus`/`haiku`) when the file named one. */
|
|
178
|
+
modelAlias?: string
|
|
179
|
+
/** Skill names to inline into the child's prompt, per Claude's `skills` field. */
|
|
180
|
+
skills?: string[]
|
|
108
181
|
systemPrompt: string
|
|
109
182
|
source: AgentSource
|
|
110
183
|
filePath: string
|
|
@@ -18,6 +18,10 @@ export interface BackgroundRun {
|
|
|
18
18
|
turns: number
|
|
19
19
|
/** Set while running so the run can be cancelled; cleared on completion. */
|
|
20
20
|
kill?: () => void
|
|
21
|
+
/** pi session the child ran under, so a follow-up can continue its context. */
|
|
22
|
+
sessionId: string
|
|
23
|
+
/** How the child was spawned, so a follow-up can repeat it with a new task. */
|
|
24
|
+
spawn: BackgroundSpawn
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
export interface BackgroundSpawn {
|
|
@@ -57,7 +61,7 @@ export function parseFinalOutputFromJsonl(jsonl: string): { text: string; turns:
|
|
|
57
61
|
return { text, turns }
|
|
58
62
|
}
|
|
59
63
|
|
|
60
|
-
export function formatStatus(all: Iterable<BackgroundRun
|
|
64
|
+
export function formatStatus(all: Iterable<Pick<BackgroundRun, 'id' | 'agent' | 'task' | 'state' | 'turns' | 'exitCode'>>): string {
|
|
61
65
|
const lines = [...all].map((run) => {
|
|
62
66
|
const label = run.state === 'running' ? 'running' : `${run.state} (exit ${run.exitCode ?? '?'}, ${run.turns} turns)`
|
|
63
67
|
return `${run.id} ${run.agent}: ${label} - ${run.task.slice(0, 60)}`
|
|
@@ -81,15 +85,47 @@ export function backgroundStatusText(): string {
|
|
|
81
85
|
return formatStatus(runs.values())
|
|
82
86
|
}
|
|
83
87
|
|
|
88
|
+
/** A finished run, so a caller can continue its session with a follow-up task. */
|
|
89
|
+
export function backgroundRun(id: string): BackgroundRun | undefined {
|
|
90
|
+
return runs.get(id)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Re-spawn a finished run's session with a new task. The child is started with the
|
|
94
|
+
* same --session-id, so it continues with everything it already saw rather than
|
|
95
|
+
* re-deriving context the parent would have to repeat. */
|
|
96
|
+
export function resumeBackgroundRun(id: string, task: string, onComplete: (run: BackgroundRun) => void): 'resumed' | 'still-running' | 'unknown' {
|
|
97
|
+
const run = runs.get(id)
|
|
98
|
+
if (!run) return 'unknown'
|
|
99
|
+
if (run.state === 'running') return 'still-running'
|
|
100
|
+
const args = run.spawn.args.map((arg) => (arg.startsWith('Task: ') ? `Task: ${task}` : arg))
|
|
101
|
+
run.state = 'running'
|
|
102
|
+
run.task = task
|
|
103
|
+
run.output = undefined
|
|
104
|
+
run.exitCode = undefined
|
|
105
|
+
driveRun(run, { ...run.spawn, args }, onComplete)
|
|
106
|
+
return 'resumed'
|
|
107
|
+
}
|
|
108
|
+
|
|
84
109
|
export function startBackgroundRun(agent: string, task: string, invocation: BackgroundSpawn, onComplete: (run: BackgroundRun) => void): string | null {
|
|
85
110
|
// Checked here, synchronously with registration: callers await temp-file writes
|
|
86
111
|
// between any check of their own and this call, so a parallel tool-call batch
|
|
87
112
|
// could otherwise all pass that earlier check and overshoot the cap.
|
|
88
113
|
if (activeBackgroundRuns() >= MAX_BACKGROUND_RUNS) return null
|
|
89
114
|
const id = `bg-${randomUUID().slice(0, 8)}`
|
|
90
|
-
|
|
115
|
+
// A stable session id per run: the child persists its session, so a follow-up can
|
|
116
|
+
// resume it instead of starting cold.
|
|
117
|
+
const sessionId = `pi-code-${id}-${randomUUID().slice(0, 8)}`
|
|
118
|
+
const args = invocation.args.map((arg) => (arg === '--no-session' ? '--session-id' : arg))
|
|
119
|
+
const withSession = args.includes('--session-id') ? args.flatMap((arg) => (arg === '--session-id' ? ['--session-id', sessionId] : [arg])) : args
|
|
120
|
+
const spawnSpec: BackgroundSpawn = { ...invocation, args: withSession }
|
|
121
|
+
const run: BackgroundRun = { id, agent, task, state: 'running', turns: 0, sessionId, spawn: spawnSpec }
|
|
91
122
|
runs.set(id, run)
|
|
123
|
+
driveRun(run, spawnSpec, onComplete)
|
|
124
|
+
return id
|
|
125
|
+
}
|
|
92
126
|
|
|
127
|
+
/** Spawn the child for a run and wire its lifecycle back onto the record. */
|
|
128
|
+
function driveRun(run: BackgroundRun, invocation: BackgroundSpawn, onComplete: (run: BackgroundRun) => void): void {
|
|
93
129
|
const proc = spawn(invocation.command, invocation.args, {
|
|
94
130
|
cwd: invocation.cwd,
|
|
95
131
|
shell: false,
|
|
@@ -133,5 +169,4 @@ export function startBackgroundRun(agent: string, task: string, invocation: Back
|
|
|
133
169
|
run.exitCode = 1
|
|
134
170
|
complete()
|
|
135
171
|
})
|
|
136
|
-
return id
|
|
137
172
|
}
|
|
@@ -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 {
|
|
30
|
-
import {
|
|
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
|
-
|
|
325
|
-
|
|
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
|
-
//
|
|
545
|
-
//
|
|
546
|
-
|
|
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
|
-
|
|
596
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|