@bacnh85/pi-subagent 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,100 +1,52 @@
1
1
  # pi-subagent
2
2
 
3
- Minimal-overhead sub-agent extension for pi. Delegate tasks to specialized agents with isolated context running in-process via the pi SDK for zero spawn overhead and ~10x fewer tokens than process-spawning.
4
-
5
- ## Features
6
-
7
- - **In-process execution**: Uses `createAgentSession()` directly — no `spawn("pi")` overhead
8
- - **Minimal token budget**: Only the agent's system prompt (no AGENTS.md, no extensions, no thinking)
9
- - **Streaming progress**: Real-time tool-call and text updates during execution
10
- - **Three modes**: single, parallel (max 8 tasks, 4 concurrent), chain (sequential with `{previous}`)
11
- - **Abort support**: Esc propagates to all sub-agents
12
- - **TUI rendering**: Collapsed/expanded views with tool-call formatting and usage stats
13
- - **Bundled agents**: scout, reviewer, worker — overridable with your own
14
- - **Thread viewing**: `/agent` slash command to view subagent threads in isolation
3
+ Isolated in-process subagents for Pi. The `subagent` tool supports single, parallel (8 tasks, 4 concurrent), and chained execution; `/agent` opens inspectable child threads.
15
4
 
16
5
  ## Install
17
6
 
18
7
  ```bash
19
- cd extensions/pi-subagent
20
- npm install
21
- cd ../..
22
- pi install ./extensions/pi-subagent
23
- ```
24
-
25
- Or test directly:
26
-
27
- ```bash
28
- pi -e ./extensions/pi-subagent
8
+ pi install npm:@bacnh85/pi-subagent
9
+ # local checkout
10
+ pi install ./pi-subagent
29
11
  ```
30
12
 
31
- ## Usage
13
+ ## Bundled roles
32
14
 
33
- ### Thread Viewing (`/agent`)
15
+ | Role | Model | Thinking | Tools |
16
+ | --- | --- | --- | --- |
17
+ | `scout` | parent model | low | read, grep, find, ls |
18
+ | `reviewer` | parent model | high | read, grep, find, ls |
19
+ | `worker` | parent model | medium | standard coding tools |
20
+ | `general-purpose` | parent model | off | standard coding tools |
34
21
 
35
- After running subagents, type `/agent` to view individual subagent threads:
22
+ Bundled roles inherit the parent model so they work with the account already active in Pi. User/project agent files may override `model` and `thinking`.
36
23
 
37
- 1. A picker shows `Main [default]` + all subagent threads with status icons
38
- 2. Select a thread to view its full output (task, tool calls, final result, usage stats)
39
- 3. Within the viewer: `Esc` to close, `Alt+←`/`Alt+→` to cycle between threads, `↑/↓` to scroll
24
+ ## Agent files
40
25
 
41
- This is useful when running many parallel subagents — instead of Ctrl+O to
42
- see all output at once, you can focus on one thread at a time.
43
-
44
- ### Single agent
45
-
46
- ```
47
- Use scout to find authentication code in this project
48
- ```
49
-
50
- ### Parallel execution
51
-
52
- ```
53
- Run 2 scouts in parallel: one for models, one for providers
54
- ```
55
-
56
- ### Chain workflow
57
-
58
- ```
59
- Chain: scout finds auth code, then reviewer checks it for security issues
60
- ```
61
-
62
- ## Included Agents
63
-
64
- | Agent | Model | Tools | Purpose |
65
- |-------|-------|-------|---------|
66
- | `scout` | Haiku | read, grep, find, ls | Fast codebase recon |
67
- | `reviewer` | Sonnet | read, grep, find, ls, bash | Code review |
68
- | `worker` | Sonnet | all | General implementation |
69
-
70
- ## Custom Agents
71
-
72
- Create Markdown files with YAML frontmatter in `~/.pi/agent/agents/` (user-level) or `.pi/agents/` (project-level):
26
+ Create `~/.pi/agent/agents/*.md` or `.pi/agents/*.md`:
73
27
 
74
28
  ```markdown
75
29
  ---
76
- name: my-agent
77
- description: When to use this agent
78
- tools: read, grep, find, ls, bash
79
- model: claude-haiku-4-5
30
+ name: scout-fast
31
+ description: Locate relevant files and symbols
32
+ tools: read, grep, find, ls
33
+ thinking: low
34
+ model: optional-provider/optional-model
80
35
  ---
81
36
 
82
- Your system prompt here. This is the ONLY prompt the sub-agent sees.
37
+ Return concise evidence with file/symbol anchors.
83
38
  ```
84
39
 
85
- See `agent-format.md` for the full format specification.
40
+ Project agents require confirmation when requested through the public tool. Definitions are cached with file-signature invalidation; `/subagent reload` clears the cache.
41
+
42
+ ## Context and limits
86
43
 
87
- ## Architecture
44
+ Children use in-memory SDK sessions with no extensions, skills, prompt templates, or automatic `AGENTS.md` loading. The optional `instructions` argument passes a bounded 16 KB task/repository contract. Only Pi built-in tools are available; Serena, FFF, web, and Munin are not available in lean children.
88
45
 
89
- Sub-agents run in-process via the pi SDK. Compared to the process-spawn approach (spawning `pi --mode json`), this saves ~4-11K tokens per sub-agent invocation by:
46
+ Threads are session-memory only and are cleared when Pi replaces or reloads the session. Timeout and parent cancellation propagate to child sessions. Subagents cannot recursively invoke `subagent`.
90
47
 
91
- - Using only the agent's system prompt (no pi defaults)
92
- - Skipping AGENTS.md, extensions, skills, prompt templates
93
- - Disabling thinking, compaction, retry
94
- - Using in-memory sessions (no disk I/O)
95
- - Sharing the parent's auth/model infrastructure
48
+ ## Extension contract
96
49
 
97
- ## Requirements
50
+ `pi-subagent` owns the `pi-subagent:run` event contract for one named-agent request. `pi-review` uses it for isolated review. Requests use an immediate boolean `accept()` claim and exactly one `respond()` callback; this suppresses duplicate responders while missing services and timeouts remain caller-controlled.
98
51
 
99
- - pi coding agent with configured API keys
100
- - Peer dependencies satisfied by the pi runtime (no extra npm install needed)
52
+ See [`agent-format.md`](./agent-format.md) for all frontmatter fields.
package/agent-format.md CHANGED
@@ -8,7 +8,7 @@ Sub-agents are defined as Markdown files with YAML frontmatter.
8
8
  |----------|-------|
9
9
  | `~/.pi/agent/agents/*.md` | User-level (all projects) |
10
10
  | `.pi/agents/*.md` | Project-level |
11
- | `<skill>/agents/*.md` | Bundled with pi-sugagents |
11
+ | `<package>/agents/*.md` | Bundled with pi-subagent |
12
12
 
13
13
  Project agents override user agents with the same name when `agentScope: "both"`.
14
14
 
@@ -19,7 +19,8 @@ Project agents override user agents with the same name when `agentScope: "both"`
19
19
  name: my-agent # Required. Unique identifier (kebab-case).
20
20
  description: ... # Required. When to use this agent.
21
21
  tools: read, grep, ... # Optional. Comma-separated tool names. Defaults to all.
22
- model: claude-haiku-4-5 # Optional. Model ID. Defaults to parent's model.
22
+ model: provider/model # Optional. Defaults to parent's model.
23
+ thinking: low # Optional: off|minimal|low|medium|high|xhigh|max.
23
24
  ---
24
25
  ```
25
26
 
@@ -47,13 +48,17 @@ Model IDs are resolved via `getModel("provider", "id")`. Common values:
47
48
 
48
49
  If not specified, defaults to the parent session's model.
49
50
 
51
+ ## Instruction handoff
52
+
53
+ Children do not automatically load repository instructions. Callers may pass an `instructions` task contract, truncated to 16 KB. Use this for relevant repository rules or review contracts rather than copying the parent transcript.
54
+
50
55
  ## Token Budget
51
56
 
52
57
  Each sub-agent runs with:
53
58
  - **System prompt**: agent body only (~200-1K tokens typical)
54
59
  - **No AGENTS.md**: saves 500-5K tokens
55
60
  - **No extensions/skills loaded**: saves 200-1K tokens
56
- - **Thinking off**: saves reasoning overhead
61
+ - **Thinking per role**: defaults off; bundled scout/reviewer/worker choose low/high/medium
57
62
  - **No compaction**: avoids compaction token cost
58
63
 
59
64
  This is ~10x leaner than spawning a full `pi` process.
@@ -1,30 +1,36 @@
1
1
  ---
2
2
  name: reviewer
3
- description: Code review specialist. Use for reviewing changes, finding bugs, suggesting improvements.
4
- tools: read, grep, find, ls, bash
5
- model: openai-codex/gpt-5.5
3
+ description: Code review specialist. Use for correctness, security, regression, and meaningful test-gap review.
4
+ tools: read, grep, find, ls
5
+ thinking: high
6
6
  ---
7
7
 
8
- You are a senior code reviewer. Review code changes and provide specific, actionable feedback.
8
+ You are an independent senior code reviewer. Inspect the requested Git scope with read-only tools.
9
9
 
10
- Focus on:
11
- 1. **Correctness**: Logic errors, edge cases, off-by-one
12
- 2. **Security**: Injection risks, auth bypasses, data leaks
13
- 3. **Performance**: N+1 queries, unnecessary allocations, blocking calls
14
- 4. **Maintainability**: Unclear naming, missing error handling, tight coupling
15
- 5. **Best practices**: Idiomatic patterns, testability, documentation
10
+ Focus only on actionable issues introduced by the reviewed change:
11
+ 1. Correctness and edge cases
12
+ 2. Security and data loss
13
+ 3. Regressions and API compatibility
14
+ 4. Missing tests that allow a likely bug to escape
16
15
 
17
- Output format:
16
+ Avoid style noise, praise, and speculative redesign. Every finding needs code evidence.
18
17
 
19
- ## Summary
20
- Brief assessment (1-2 sentences)
18
+ Return JSON only:
19
+ ```json
20
+ {
21
+ "summary": "compact scope/result summary",
22
+ "findings": [
23
+ {
24
+ "severity": "critical|high|medium|low",
25
+ "file": "relative/path",
26
+ "line": 1,
27
+ "issue": "what is wrong and why it matters",
28
+ "evidence": "specific inspected code evidence",
29
+ "suggestedFix": "smallest safe fix",
30
+ "blocking": true
31
+ }
32
+ ]
33
+ }
34
+ ```
21
35
 
22
- ## Issues Found
23
- For each issue:
24
- - **Severity**: critical | high | medium | low
25
- - **File**: path with line numbers
26
- - **Problem**: What's wrong
27
- - **Fix**: Suggested change (code block)
28
-
29
- ## Overall Assessment
30
- Green/yellow/red with reasoning.
36
+ Use an empty `findings` array when clean. Do not modify files or Git state.
package/agents/scout.md CHANGED
@@ -2,7 +2,7 @@
2
2
  name: scout
3
3
  description: Fast codebase recon that returns compressed context for handoff. Use for finding files, understanding structure, locating symbols.
4
4
  tools: read, grep, find, ls
5
- model: opencode-go/deepseek-v4-flash
5
+ thinking: low
6
6
  ---
7
7
 
8
8
  You are a scout. Quickly investigate a codebase and return structured findings that another agent can use without re-reading everything.
@@ -22,8 +22,8 @@ Strategy:
22
22
 
23
23
  Output format:
24
24
 
25
- ## Files Retrieved
26
- List with exact line ranges:
25
+ ## Evidence
26
+ List exact file/symbol anchors and relevant line ranges:
27
27
  1. `path/to/file.ts` (lines 10-50) - Description of what's here
28
28
  2. `path/to/other.ts` (lines 100-150) - Description
29
29
  3. ...
package/agents/worker.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: worker
3
- description: General-purpose coding agent with full tool access. Use for implementation, refactoring, debugging, and complex multi-step tasks.
4
- model: opencode-go/deepseek-v4-pro
3
+ description: General-purpose coding agent with full tool access. Use only when explicitly requested for isolated implementation.
4
+ thinking: medium
5
5
  ---
6
6
 
7
7
  You are a skilled software engineer. Implement the requested task with care and precision.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Agent discovery and configuration for pi-sugagents.
2
+ * Agent discovery and configuration for pi-subagent.
3
3
  *
4
4
  * Loads agent definitions from Markdown files with YAML frontmatter.
5
5
  * Discovers from user-level (~/.pi/agent/agents/), project-level
@@ -18,6 +18,7 @@ export interface AgentConfig {
18
18
  description: string;
19
19
  tools?: string[];
20
20
  model?: string;
21
+ thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
21
22
  systemPrompt: string;
22
23
  source: "user" | "project" | "bundled";
23
24
  filePath: string;
@@ -86,6 +87,9 @@ function loadAgentsFromDir(dir: string, source: "user" | "project" | "bundled"):
86
87
  description: frontmatter.description,
87
88
  tools: tools && tools.length > 0 ? tools : undefined,
88
89
  model: typeof frontmatter.model === "string" ? frontmatter.model : undefined,
90
+ thinking: typeof frontmatter.thinking === "string" && ["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(frontmatter.thinking)
91
+ ? frontmatter.thinking as AgentConfig["thinking"]
92
+ : undefined,
89
93
  systemPrompt: body,
90
94
  source,
91
95
  filePath,
@@ -15,13 +15,13 @@
15
15
 
16
16
  import * as path from "node:path";
17
17
  import type { Model } from "@earendil-works/pi-ai";
18
- import { getModel } from "@earendil-works/pi-ai/compat";
19
18
  import { StringEnum } from "@earendil-works/pi-ai";
20
19
  import {
21
20
  AuthStorage,
22
21
  CONFIG_DIR_NAME,
23
22
  DynamicBorder,
24
23
  type ExtensionAPI,
24
+ type ExtensionContext,
25
25
  getAgentDir,
26
26
  getMarkdownTheme,
27
27
  ModelRegistry,
@@ -44,6 +44,7 @@ import {
44
44
  renderSingleResult,
45
45
  } from "./render.ts";
46
46
  import { type SubagentThread, threadStore } from "./threads.ts";
47
+ import { SUBAGENT_REQUEST_EVENT, runNamedAgent, type SubagentRunRequest } from "./service.ts";
47
48
  import { ThreadViewer, type ThreadViewerCallbacks } from "./thread-viewer.ts";
48
49
 
49
50
  // ---------------------------------------------------------------------------
@@ -54,6 +55,8 @@ const MAX_PARALLEL_TASKS = 8;
54
55
  const MAX_CONCURRENCY = 4;
55
56
  const PER_TASK_OUTPUT_CAP = 50 * 1024; // 50 KB per parallel task
56
57
 
58
+ import { resolveModel } from "./model.ts";
59
+
57
60
  // ---------------------------------------------------------------------------
58
61
  // Helpers
59
62
  // ---------------------------------------------------------------------------
@@ -69,35 +72,6 @@ function truncateParallelOutput(output: string): string {
69
72
  return `${truncated}\n\n[Output truncated: ${byteLength - Buffer.byteLength(truncated, "utf8")} bytes omitted.]`;
70
73
  }
71
74
 
72
- interface ResolvedModel {
73
- model: Model | null;
74
- attempted: string[];
75
- }
76
-
77
- function resolveModel(
78
- modelName: string | undefined,
79
- parentModel: Model | undefined,
80
- ): ResolvedModel {
81
- const attempted: string[] = [];
82
- if (modelName) {
83
- // Try as provider/id first, then fall back to anthropic/id
84
- const parts = modelName.split("/");
85
- if (parts.length === 2) {
86
- attempted.push(modelName);
87
- const found = getModel(parts[0], parts[1]) ?? null;
88
- if (found) return { model: found, attempted };
89
- } else {
90
- // Assume Anthropic shorthand
91
- attempted.push(`anthropic/${modelName}`);
92
- const found = getModel("anthropic", modelName) ?? null;
93
- if (found) return { model: found, attempted };
94
- }
95
- } else if (parentModel) {
96
- attempted.push(`${parentModel.provider}/${parentModel.id}`);
97
- return { model: parentModel, attempted };
98
- }
99
- return { model: null, attempted };
100
- }
101
75
 
102
76
  // ---------------------------------------------------------------------------
103
77
  // Tool parameter schema
@@ -143,6 +117,7 @@ const SubagentParams = Type.Object({
143
117
  ),
144
118
  cwd: Type.Optional(Type.String({ description: "Working directory (single mode)" })),
145
119
  timeout: Type.Optional(Type.Number({ description: "Global timeout in milliseconds for all sub-agents (overridden by per-task/step timeouts)" })),
120
+ instructions: Type.Optional(Type.String({ description: "Bounded repository/task instructions passed to each child (max 16 KB)" })),
146
121
  abortOnFailure: Type.Optional(Type.Boolean({ description: "In parallel mode, cancel remaining tasks when one fails. Default: false.", default: false })),
147
122
  });
148
123
 
@@ -162,8 +137,11 @@ interface SubagentDetails {
162
137
  // ---------------------------------------------------------------------------
163
138
 
164
139
  export default function (pi: ExtensionAPI) {
165
- // Invalidate agent cache + clear thread store on reload
166
- pi.on("session_start", (event) => {
140
+ let currentCtx: ExtensionContext | undefined;
141
+
142
+ // Invalidate agent cache + clear thread store on session replacement.
143
+ pi.on("session_start", (event, ctx) => {
144
+ currentCtx = ctx;
167
145
  if (event.reason === "reload") invalidateAgentCache();
168
146
  threadStore.clear();
169
147
  });
@@ -180,22 +158,43 @@ export default function (pi: ExtensionAPI) {
180
158
  }
181
159
  });
182
160
 
183
- // Inject available agent list into system prompt on every session
184
- pi.on("before_agent_start", async (event, ctx) => {
185
- const discovery = discoverAgents(ctx.cwd, "both", bundledAgentsDir);
186
- if (discovery.agents.length > 0) {
187
- const names = discovery.agents.map(a => a.name).join(", ");
188
- return {
189
- systemPrompt:
190
- event.systemPrompt +
191
- `\n\nAvailable sub-agents: ${names}. Use /subagent for details.`,
192
- };
161
+ // Resolve bundled agents directory relative to this extension file
162
+ const bundledAgentsDir = path.resolve(__dirname, "../agents");
163
+
164
+ // Public one-request/one-response service used by pi-review.
165
+ pi.events.on(SUBAGENT_REQUEST_EVENT, (raw) => {
166
+ const request = raw as SubagentRunRequest;
167
+ const ctx = currentCtx;
168
+ if (!ctx || !request?.id || typeof request.respond !== "function") return;
169
+ if (request.accept && !request.accept()) return;
170
+ const agent = discoverAgents(ctx.cwd, "user", bundledAgentsDir).agents.find((item) => item.name === request.agent);
171
+ if (!agent) {
172
+ request.respond({ id: request.id, ok: false, error: `Unknown agent: ${request.agent}` });
173
+ return;
193
174
  }
175
+ const thread = threadStore.createThread({ agentName: agent.name, task: request.task, mode: "single" });
176
+ void runNamedAgent({
177
+ agent: request.readOnly ? { ...agent, tools: ["read", "grep", "find", "ls"] } : agent,
178
+ task: request.task,
179
+ cwd: request.cwd ?? ctx.cwd,
180
+ ctx,
181
+ timeout: request.timeout,
182
+ instructions: request.instructions,
183
+ signal: request.signal,
184
+ onMessage: (result) => threadStore.updateThread(thread.id, { result }),
185
+ }).then((result) => {
186
+ threadStore.updateThread(thread.id, {
187
+ status: isFailedResult(result) ? (result.stopReason === "aborted" ? "aborted" : "failed") : "completed",
188
+ result,
189
+ });
190
+ if (isFailedResult(result)) request.respond({ id: request.id, ok: false, error: getResultOutput(result) });
191
+ else request.respond({ id: request.id, ok: true, result });
192
+ }, (error) => {
193
+ threadStore.updateThread(thread.id, { status: "failed" });
194
+ request.respond({ id: request.id, ok: false, error: error instanceof Error ? error.message : String(error) });
195
+ });
194
196
  });
195
197
 
196
- // Resolve bundled agents directory relative to this extension file
197
- const bundledAgentsDir = path.resolve(__dirname, "agents");
198
-
199
198
  // /subagent command — list available agents
200
199
  pi.registerCommand("subagent", {
201
200
  description: "List available sub-agents, reload agent definitions, or show agent details",
@@ -246,6 +245,7 @@ export default function (pi: ExtensionAPI) {
246
245
  `Agent: ${agent.name} (${agent.source})`,
247
246
  `Description: ${agent.description}`,
248
247
  `Model: ${agent.model || "inherits from parent"}`,
248
+ `Thinking: ${agent.thinking || "off"}`,
249
249
  `Tools: ${agent.tools?.join(", ") || "all default"}`,
250
250
  `Source file: ${agent.filePath}`,
251
251
  "",
@@ -326,12 +326,8 @@ export default function (pi: ExtensionAPI) {
326
326
  };
327
327
  }
328
328
 
329
- // Confirm project-local agents
330
- if (
331
- (agentScope === "project" || agentScope === "both") &&
332
- confirmProjectAgents &&
333
- ctx.hasUI
334
- ) {
329
+ // Handle project-local agent confirmation
330
+ if (agentScope === "project" || agentScope === "both") {
335
331
  const requestedAgentNames = new Set<string>();
336
332
  if (params.chain) for (const s of params.chain) requestedAgentNames.add(s.agent);
337
333
  if (params.tasks) for (const t of params.tasks) requestedAgentNames.add(t.agent);
@@ -341,16 +337,30 @@ export default function (pi: ExtensionAPI) {
341
337
  .map((name) => agents.find((a) => a.name === name))
342
338
  .filter((a): a is AgentConfig => a?.source === "project");
343
339
 
344
- if (projectAgentsRequested.length > 0) {
345
- const names = projectAgentsRequested.map((a) => a.name).join(", ");
346
- const dir = discovery.projectAgentsDir ?? "(unknown)";
347
- const ok = await ctx.ui.confirm(
348
- "Run project-local agents?",
349
- `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
350
- );
351
- if (!ok) {
340
+ if (projectAgentsRequested.length > 0 && confirmProjectAgents) {
341
+ if (ctx.hasUI) {
342
+ const names = projectAgentsRequested.map((a) => a.name).join(", ");
343
+ const dir = discovery.projectAgentsDir ?? "(unknown)";
344
+ const ok = await ctx.ui.confirm(
345
+ "Run project-local agents?",
346
+ `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
347
+ );
348
+ if (!ok) {
349
+ return {
350
+ content: [{ type: "text", text: "Canceled: project-local agents not approved." }],
351
+ details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
352
+ };
353
+ }
354
+ } else {
355
+ // ponytail: fail closed in headless sessions — project agent
356
+ // prompts and tools run without user oversight.
352
357
  return {
353
- content: [{ type: "text", text: "Canceled: project-local agents not approved." }],
358
+ content: [{
359
+ type: "text",
360
+ text: "Cannot run project-local agents without UI confirmation. "
361
+ + "Set confirmProjectAgents: false to allow in headless sessions, "
362
+ + "or use agentScope: 'user' to skip project agents.",
363
+ }],
354
364
  details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
355
365
  };
356
366
  }
@@ -358,14 +368,17 @@ export default function (pi: ExtensionAPI) {
358
368
  }
359
369
 
360
370
  // Shared auth/model setup for SDK sessions
361
- const authStorage = AuthStorage.create();
362
- const modelRegistry = ModelRegistry.create(authStorage);
371
+ // ponytail: reuse parent modelRegistry instead of a fresh copy — avoids
372
+ // internal API casts (storeModelHeaders) and preserves env/headers/OAuth.
373
+ const authStorage = AuthStorage.inMemory();
374
+ const modelRegistry = ctx.modelRegistry;
363
375
 
364
376
  // Helper: inject parent's API key into child auth storage
365
- async function injectApiKey(model: Model): Promise<void> {
377
+ async function injectApiKey(model: Model<any>): Promise<void> {
366
378
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
367
- if (auth.ok && auth.apiKey) {
368
- authStorage.setRuntimeApiKey(model.provider, auth.apiKey);
379
+ if (auth.ok) {
380
+ if (auth.apiKey) authStorage.setRuntimeApiKey(model.provider, auth.apiKey);
381
+ // ponytail: headers/env stay on the parent registry — no copy needed.
369
382
  }
370
383
  }
371
384
 
@@ -393,7 +406,7 @@ export default function (pi: ExtensionAPI) {
393
406
  };
394
407
  }
395
408
 
396
- const resolved = resolveModel(agent.model, ctx.model);
409
+ const resolved = resolveModel(agent.model, ctx.model, ctx.modelRegistry);
397
410
  if (!resolved.model) {
398
411
  const tried = resolved.attempted.join(", ") || "none";
399
412
  const parentInfo = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "none";
@@ -417,53 +430,40 @@ export default function (pi: ExtensionAPI) {
417
430
  let tools = agent.tools ?? defaultTools;
418
431
  tools = tools.filter((t) => t !== "subagent");
419
432
 
420
- // Build timeout + parent signal into a combined AbortSignal
421
- let combinedSignal = parentSignal;
422
- let timeoutId: ReturnType<typeof setTimeout> | undefined;
423
- let timeoutController: AbortController | undefined;
424
- if (timeoutMs && timeoutMs > 0) {
425
- timeoutController = new AbortController();
426
- timeoutId = setTimeout(() => {
427
- timeoutController!.abort();
428
- }, timeoutMs);
429
- // Combine with parent signal if present (Node 20+ AbortSignal.any)
430
- if (parentSignal && typeof (AbortSignal as any).any === "function") {
431
- combinedSignal = (AbortSignal as any).any([parentSignal, timeoutController.signal]);
432
- } else if (parentSignal) {
433
- combinedSignal = timeoutController.signal;
434
- // Link parent to timeout: if parent aborts, also abort our timeout controller
435
- if (parentSignal.aborted) timeoutController.abort();
436
- else parentSignal.addEventListener("abort", () => timeoutController!.abort(), { once: true });
437
- } else {
438
- combinedSignal = timeoutController.signal;
433
+ const timeoutController = timeoutMs && timeoutMs > 0 ? new AbortController() : undefined;
434
+ const timeoutId = timeoutController ? setTimeout(() => timeoutController.abort(), timeoutMs) : undefined;
435
+ const signals = [parentSignal, timeoutController?.signal].filter((value): value is AbortSignal => Boolean(value));
436
+ const combinedSignal = signals.length > 1
437
+ ? typeof (AbortSignal as any).any === "function"
438
+ ? (AbortSignal as any).any(signals)
439
+ : signals[0]
440
+ : signals[0];
441
+
442
+ try {
443
+ const result = await runSubAgent({
444
+ cwd: cwd ?? ctx.cwd,
445
+ systemPrompt: params.instructions
446
+ ? `${agent.systemPrompt}\n\n## Task Contract\n${params.instructions.slice(0, 16 * 1024)}`
447
+ : agent.systemPrompt,
448
+ task,
449
+ tools,
450
+ model: resolved.model,
451
+ authStorage,
452
+ modelRegistry,
453
+ signal: combinedSignal,
454
+ agentName,
455
+ thinkingLevel: agent.thinking,
456
+ onMessage: onProgress,
457
+ });
458
+ if (timeoutController?.signal.aborted && !parentSignal?.aborted) {
459
+ result.exitCode = 1;
460
+ result.stopReason = "timeout";
461
+ result.errorMessage ||= `Timeout after ${timeoutMs}ms`;
439
462
  }
463
+ return result;
464
+ } finally {
465
+ if (timeoutId) clearTimeout(timeoutId);
440
466
  }
441
-
442
- const result = await runSubAgent({
443
- cwd: cwd ?? ctx.cwd,
444
- systemPrompt: agent.systemPrompt,
445
- task,
446
- tools,
447
- model: resolved.model,
448
- authStorage,
449
- modelRegistry,
450
- signal: combinedSignal,
451
- agentName,
452
- onMessage: onProgress,
453
- });
454
-
455
- // Clean up timeout
456
- if (timeoutId) clearTimeout(timeoutId);
457
-
458
- // Detect timeout: our timeout controller fired, not the parent
459
- const timedOut = timeoutController?.signal.aborted && !parentSignal?.aborted;
460
- if (timedOut) {
461
- result.exitCode = 1;
462
- result.stopReason = "timeout";
463
- if (!result.errorMessage) result.errorMessage = `Timeout after ${timeoutMs}ms`;
464
- }
465
-
466
- return result;
467
467
  }
468
468
 
469
469
  // --- Chain mode ---
@@ -1051,7 +1051,7 @@ export default function (pi: ExtensionAPI) {
1051
1051
  ctx: { ui: { custom: <T>(factory: any, opts?: any) => Promise<T> } },
1052
1052
  items: PickerItem[],
1053
1053
  ): Promise<string | null> {
1054
- return ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
1054
+ return ctx.ui.custom<string | null>((tui: any, theme: any, _kb: any, done: (value: string | null) => void) => {
1055
1055
  const container = new Container();
1056
1056
  container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
1057
1057
  container.addChild(new Text(theme.fg("accent", theme.bold("Subagents")), 1, 0));
@@ -1101,7 +1101,7 @@ export default function (pi: ExtensionAPI) {
1101
1101
  const getThreads = () => threadStore.getAllThreads();
1102
1102
 
1103
1103
  // Overlay mode: viewer appears above editor, Esc dismisses
1104
- await ctx.ui.custom<void>((tui, theme, _kb, done) => {
1104
+ await ctx.ui.custom<void>((tui: any, theme: any, _kb: any, done: () => void) => {
1105
1105
  let unsubscribe: (() => void) | undefined;
1106
1106
  let closed = false;
1107
1107
 
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Shared model resolution for pi-subagent.
3
+ *
4
+ * Provides a single canonical resolveModel() used by both the tool handler
5
+ * (index.ts) and the event-driven service path (service.ts), ensuring
6
+ * consistent error reporting across all sub-agent invocation paths.
7
+ *
8
+ * Queries the parent ModelRegistry first (catches custom-configured models
9
+ * with overridden base URLs, headers, compatibility settings). Falls back
10
+ * to the built-in registry for unconfigured models.
11
+ * For unqualified names (no provider prefix), known naming conventions
12
+ * are tried before assuming Anthropic.
13
+ */
14
+
15
+ import { getModel } from "@earendil-works/pi-ai/compat";
16
+ import type { Model } from "@earendil-works/pi-ai";
17
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
18
+
19
+ export interface ResolvedModel {
20
+ model: Model<any> | null;
21
+ attempted: string[];
22
+ }
23
+
24
+ /** Known provider prefixes for unqualified model names. */
25
+ const KNOWN_PROVIDERS: [string, RegExp][] = [
26
+ ["openai", /^gpt-/i],
27
+ ["anthropic", /^claude-/i],
28
+ ["google", /^gemini-/i],
29
+ ["cohere", /^command-/i],
30
+ ["deepseek", /^(deepseek-|ds-)/i],
31
+ ["mistral", /^mistral-/i],
32
+ ["groq", /^(groq-|llama-)/i],
33
+ ];
34
+
35
+ function tryGetModel(
36
+ provider: string,
37
+ id: string,
38
+ modelRegistry?: ModelRegistry,
39
+ ): Model<any> | null {
40
+ // Query parent ModelRegistry first — it includes custom-configured models
41
+ // (overridden base URLs, headers, compatibility settings, per-model overrides).
42
+ // Fall back to built-in registry for unconfigured models.
43
+ if (modelRegistry) {
44
+ const found = modelRegistry.find(provider as any, id as any) ?? null;
45
+ if (found) return found;
46
+ }
47
+ const builtIn = getModel(provider as any, id as any) ?? null;
48
+ if (builtIn) return builtIn;
49
+ return null;
50
+ }
51
+
52
+ export function resolveModel(
53
+ modelName: string | undefined,
54
+ parentModel: Model<any> | undefined,
55
+ modelRegistry?: ModelRegistry,
56
+ ): ResolvedModel {
57
+ const attempted: string[] = [];
58
+ if (modelName) {
59
+ const idx = modelName.indexOf("/");
60
+ if (idx > 0) {
61
+ // Provider-qualified: "openai/gpt-4o" or "openrouter/anthropic/claude-3.5"
62
+ const provider = modelName.slice(0, idx);
63
+ const id = modelName.slice(idx + 1);
64
+ attempted.push(modelName);
65
+ const found = tryGetModel(provider, id, modelRegistry);
66
+ if (found) return { model: found, attempted };
67
+ } else {
68
+ // Unqualified: try known providers by naming convention
69
+ for (const [provider, pattern] of KNOWN_PROVIDERS) {
70
+ if (pattern.test(modelName)) {
71
+ attempted.push(`${provider}/${modelName}`);
72
+ const found = tryGetModel(provider, modelName, modelRegistry);
73
+ if (found) return { model: found, attempted };
74
+ }
75
+ }
76
+ // Fall back to Anthropic shorthand (backward compat)
77
+ attempted.push(`anthropic/${modelName}`);
78
+ const found = tryGetModel("anthropic", modelName, modelRegistry);
79
+ if (found) return { model: found, attempted };
80
+ }
81
+ } else if (parentModel) {
82
+ attempted.push(`${parentModel.provider}/${parentModel.id}`);
83
+ return { model: parentModel, attempted };
84
+ }
85
+ return { model: null, attempted };
86
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * TUI rendering for pi-sugagents.
2
+ * TUI rendering for pi-subagent.
3
3
  *
4
4
  * Renders sub-agent results in collapsed and expanded views.
5
5
  * Collapsed: status icon, agent name, last few items, usage stats.
@@ -187,7 +187,7 @@ function renderDisplayItems(
187
187
  export function renderSingleResult(
188
188
  result: SubAgentResult,
189
189
  expanded: boolean,
190
- theme: { fg: (c: string, t: string) => string; bold: (t: string) => string },
190
+ theme: { fg: (c: any, t: string) => string; bold: (t: string) => string },
191
191
  ): Container | Text {
192
192
  const isError = isFailedResult(result);
193
193
  const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
@@ -1,5 +1,5 @@
1
1
  /**
2
- * SDK-based sub-agent runner for pi-sugagents.
2
+ * SDK-based sub-agent runner for pi-subagent.
3
3
  *
4
4
  * Creates an in-process AgentSession via the pi SDK instead of spawning a
5
5
  * separate `pi` process. This eliminates cold-start overhead and allows
@@ -61,11 +61,12 @@ export async function runSubAgent(options: {
61
61
  systemPrompt: string;
62
62
  task: string;
63
63
  tools: string[];
64
- model: Model;
64
+ model: Model<any>;
65
65
  authStorage: AuthStorage;
66
66
  modelRegistry: ModelRegistry;
67
67
  signal?: AbortSignal;
68
68
  agentName?: string;
69
+ thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
69
70
  onUpdate?: (text: string) => void;
70
71
  onMessage?: (partialResult: SubAgentResult) => void;
71
72
  }): Promise<SubAgentResult> {
@@ -79,6 +80,7 @@ export async function runSubAgent(options: {
79
80
  modelRegistry,
80
81
  signal,
81
82
  agentName = "subagent",
83
+ thinkingLevel = "off",
82
84
  onUpdate,
83
85
  onMessage,
84
86
  } = options;
@@ -123,7 +125,7 @@ export async function runSubAgent(options: {
123
125
  const { session } = await createAgentSession({
124
126
  cwd,
125
127
  model,
126
- thinkingLevel: "off", // no reasoning token overhead
128
+ thinkingLevel,
127
129
  authStorage,
128
130
  modelRegistry,
129
131
  resourceLoader,
@@ -221,8 +223,10 @@ export async function runSubAgent(options: {
221
223
  }
222
224
  });
223
225
 
224
- await session.prompt(task);
225
- await eventPromise;
226
+ await Promise.race([
227
+ session.prompt(task),
228
+ eventPromise,
229
+ ]);
226
230
 
227
231
  if (result.stopReason !== "aborted") {
228
232
  result.exitCode = 0;
@@ -0,0 +1,79 @@
1
+ import { AuthStorage, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { AgentConfig } from "./agents.ts";
3
+ import { runSubAgent, type SubAgentResult } from "./runner.ts";
4
+ import { resolveModel } from "./model.ts";
5
+
6
+ export const SUBAGENT_REQUEST_EVENT = "pi-subagent:run";
7
+
8
+ export interface SubagentRunRequest {
9
+ id: string;
10
+ agent: string;
11
+ task: string;
12
+ cwd?: string;
13
+ timeout?: number;
14
+ instructions?: string;
15
+ readOnly?: boolean;
16
+ signal?: AbortSignal;
17
+ accept?: () => boolean;
18
+ respond: (response: SubagentRunResponse) => void;
19
+ }
20
+
21
+ export type SubagentRunResponse =
22
+ | { id: string; ok: true; result: SubAgentResult }
23
+ | { id: string; ok: false; error: string };
24
+
25
+ export async function runNamedAgent(options: {
26
+ agent: AgentConfig;
27
+ task: string;
28
+ cwd: string;
29
+ ctx: ExtensionContext;
30
+ timeout?: number;
31
+ instructions?: string;
32
+ signal?: AbortSignal;
33
+ onMessage?: (result: SubAgentResult) => void;
34
+ }): Promise<SubAgentResult> {
35
+ const { model, attempted } = resolveModel(options.agent.model, options.ctx.model, options.ctx.modelRegistry);
36
+ if (!model) throw new Error(`No model resolved for agent "${options.agent.name}" (tried: ${attempted.join(", ") || "none"})`);
37
+
38
+ const authStorage = AuthStorage.inMemory();
39
+ const modelRegistry = options.ctx.modelRegistry;
40
+ const auth = await options.ctx.modelRegistry.getApiKeyAndHeaders(model);
41
+ if (auth.ok) {
42
+ if (auth.apiKey) authStorage.setRuntimeApiKey(model.provider, auth.apiKey);
43
+ // ponytail: env and headers stay on the parent modelRegistry — reuse it directly.
44
+ }
45
+
46
+ const timeoutController = options.timeout && options.timeout > 0 ? new AbortController() : undefined;
47
+ const timeoutId = timeoutController ? setTimeout(() => timeoutController.abort(), options.timeout) : undefined;
48
+ const signals = [options.signal, timeoutController?.signal].filter((value): value is AbortSignal => Boolean(value));
49
+ const signal = signals.length > 1
50
+ ? typeof (AbortSignal as any).any === "function"
51
+ ? (AbortSignal as any).any(signals)
52
+ : signals[0]
53
+ : signals[0];
54
+ const contract = options.instructions?.slice(0, 16 * 1024);
55
+
56
+ try {
57
+ const result = await runSubAgent({
58
+ cwd: options.cwd,
59
+ systemPrompt: contract ? `${options.agent.systemPrompt}\n\n## Task Contract\n${contract}` : options.agent.systemPrompt,
60
+ task: options.task,
61
+ tools: (options.agent.tools ?? ["read", "bash", "edit", "write", "grep", "find", "ls"]).filter((tool) => tool !== "subagent"),
62
+ model,
63
+ authStorage,
64
+ modelRegistry,
65
+ signal,
66
+ agentName: options.agent.name,
67
+ thinkingLevel: options.agent.thinking,
68
+ onMessage: options.onMessage,
69
+ });
70
+ if (timeoutController?.signal.aborted && !options.signal?.aborted) {
71
+ result.exitCode = 1;
72
+ result.stopReason = "timeout";
73
+ result.errorMessage ||= `Timeout after ${options.timeout}ms`;
74
+ }
75
+ return result;
76
+ } finally {
77
+ if (timeoutId) clearTimeout(timeoutId);
78
+ }
79
+ }
package/package.json CHANGED
@@ -1,49 +1,54 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-subagent",
3
- "version": "0.4.1",
4
- "description": "Minimal-overhead sub-agent extension for pi. Delegate tasks to specialized agents with isolated context using the pi SDK in-process.",
3
+ "version": "0.5.0",
4
+ "description": "Minimal-overhead sub-agents for Pi with isolated SDK sessions, parallel and chained delegation, and inspectable threads.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
- "publishConfig": {
8
- "access": "public"
9
- },
10
- "homepage": "https://github.com/bacnh85/skills#readme",
7
+ "publishConfig": { "access": "public" },
8
+ "homepage": "https://github.com/bacnh85/pi-extensions#readme",
11
9
  "repository": {
12
10
  "type": "git",
13
- "url": "git+https://github.com/bacnh85/skills.git",
14
- "directory": "extensions/pi-subagent"
15
- },
16
- "bugs": {
17
- "url": "https://github.com/bacnh85/skills/issues"
11
+ "url": "git+https://github.com/bacnh85/pi-extensions.git",
12
+ "directory": "pi-subagent"
18
13
  },
19
- "keywords": [
20
- "pi-package",
21
- "pi-extension",
22
- "subagent",
23
- "sub-agent",
24
- "delegation",
25
- "parallel"
26
- ],
14
+ "bugs": { "url": "https://github.com/bacnh85/pi-extensions/issues" },
15
+ "keywords": ["pi-package", "pi-extension", "subagent", "sub-agent", "delegation", "parallel"],
27
16
  "files": [
28
17
  "README.md",
29
- "index.ts",
30
- "agents.ts",
31
- "runner.ts",
32
- "render.ts",
33
- "threads.ts",
34
- "thread-viewer.ts",
18
+ "agent-format.md",
35
19
  "agents/",
36
- "agent-format.md"
20
+ "extensions/index.ts",
21
+ "extensions/agents.ts",
22
+ "extensions/model.ts",
23
+ "extensions/runner.ts",
24
+ "extensions/service.ts",
25
+ "extensions/render.ts",
26
+ "extensions/threads.ts",
27
+ "extensions/thread-viewer.ts",
28
+ "extensions/package.json"
37
29
  ],
38
- "pi": {
39
- "extensions": [
40
- "./index.ts"
41
- ]
30
+ "pi": { "extensions": ["./extensions/index.ts"] },
31
+ "scripts": {
32
+ "test": "cd extensions && npx mocha",
33
+ "typecheck": "tsc --noEmit"
42
34
  },
43
35
  "peerDependencies": {
44
36
  "@earendil-works/pi-coding-agent": "*",
45
37
  "@earendil-works/pi-ai": "*",
46
38
  "@earendil-works/pi-agent-core": "*",
47
- "@earendil-works/pi-tui": "*"
39
+ "@earendil-works/pi-tui": "*",
40
+ "typebox": "*"
41
+ },
42
+ "devDependencies": {
43
+ "@earendil-works/pi-agent-core": "^0.80.2",
44
+ "@earendil-works/pi-ai": "^0.80.2",
45
+ "@earendil-works/pi-coding-agent": "^0.80.2",
46
+ "@earendil-works/pi-tui": "^0.80.2",
47
+ "@types/mocha": "^10.0.10",
48
+ "@types/node": "^20.19.43",
49
+ "mocha": "^10.8.2",
50
+ "tsx": "^4.22.4",
51
+ "typescript": "^5.9.3",
52
+ "typebox": "^1.3.1"
48
53
  }
49
54
  }
File without changes