pi-fast-subagent 0.6.1 → 0.7.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
@@ -72,12 +72,13 @@ You are code exploration specialist. Read relevant files, trace data flow, summa
72
72
 
73
73
  ### `tools:` field
74
74
 
75
- Controls which tools the subagent has access to. Subagents inherit parent extensions (web_search, fetch_content, mcp, …) by default.
75
+ Controls which tools the subagent has access to. The default is **all tools** — builtins plus parent extensions (web_search, fetch_content, mcp, playwright, …). Agents opt into lean mode with `tools: builtins` or an explicit built-in allowlist.
76
76
 
77
77
  | Value | Behavior |
78
78
  |-------|----------|
79
- | *(omitted)* | Inherit everything — all builtins + all parent extensions (**default**) |
79
+ | *(omitted)* | Builtins + every parent extension (**default**) |
80
80
  | `all` | Same as omitted — explicit "everything" |
81
+ | `builtins` | Builtins only — `read, bash, edit, write, grep, find, ls` |
81
82
  | `none` | No tools at all — pure reasoning agent |
82
83
  | comma list | Allowlist; extensions auto-load only if any listed tool is non-builtin |
83
84
 
@@ -95,9 +96,10 @@ tools: none
95
96
 
96
97
  ```md
97
98
  ---
98
- name: coder
99
- description: Lean code-editing agent, no extensions
100
- tools: read, bash, edit, write, grep, find, ls
99
+ name: scout
100
+ description: Read-only code explorer
101
+ # drop `edit` and `write` so the agent cannot mutate the codebase
102
+ tools: read, bash, grep, find, ls
101
103
  ---
102
104
  ```
103
105
 
@@ -105,11 +107,23 @@ tools: read, bash, edit, write, grep, find, ls
105
107
  ---
106
108
  name: researcher
107
109
  description: Web research agent
110
+ # listing `web_search` triggers extension loading; `read` + `write` keep the rest local
108
111
  tools: read, write, web_search, fetch_content
109
112
  ---
110
113
  ```
111
114
 
112
- > **Performance note:** inheriting all extensions adds startup cost (extension init) and token cost (larger system prompt). For tight, focused agents, list tools explicitly — extensions are only loaded when the allowlist actually needs them.
115
+ ```md
116
+ ---
117
+ name: general
118
+ description: Do-anything helper
119
+ # `tools` omitted means all tools; `tools: all` is equivalent
120
+ tools: all
121
+ ---
122
+ ```
123
+
124
+ > **Performance note:** omitted `tools` / `tools: all` loads every installed pi extension into the subagent session. That adds startup cost (extension init, possibly MCP server spawn, playwright runtime, …) and token cost (bigger system prompt). Use `tools: builtins` or list specific tools for tight, focused agents.
125
+
126
+ **YAML comments** (`# …`) are allowed inside the frontmatter — handy for documenting *why* a particular tool set was chosen. See `agents/general.md` and `agents/scout.md` for examples.
113
127
 
114
128
  ## Background Agents
115
129
 
package/agents/general.md CHANGED
@@ -2,6 +2,15 @@
2
2
  name: general
3
3
  description: General-purpose helper for coding, analysis, writing, debugging, and task execution
4
4
  model: anthropic/claude-haiku-4-5
5
+
6
+ # tools: which tools this agent can use.
7
+ # (omit) → all tools: builtins + every parent extension (default)
8
+ # all → same as omitted — explicit "everything"
9
+ # builtins → read, bash, edit, write, grep, find, ls only (fast startup)
10
+ # none → no tools — pure reasoning
11
+ # comma-separated list → explicit allowlist, e.g. `read, grep, web_search`
12
+ # General is meant to be a do-anything fallback, so it keeps everything explicit.
13
+ tools: all
5
14
  ---
6
15
 
7
16
  You are general-purpose subagent.
package/agents/scout.md CHANGED
@@ -2,7 +2,15 @@
2
2
  name: scout
3
3
  description: Explores codebases, maps structure, traces data flow, answers how things work across many files
4
4
  model: anthropic/claude-haiku-4-5
5
- tools: read, bash, edit, write, grep, find, ls
5
+
6
+ # tools: which tools this agent can use.
7
+ # (omit) → all tools: builtins + every parent extension (default)
8
+ # all → same as omitted — explicit "everything"
9
+ # builtins → read, bash, edit, write, grep, find, ls only (fast startup)
10
+ # none → no tools — pure reasoning
11
+ # comma-separated list → explicit allowlist
12
+ # Scout is read-only: no `edit`, no `write`, no extension tools. Keeps the agent from mutating the codebase.
13
+ tools: read, bash, grep, find, ls
6
14
  ---
7
15
 
8
16
  You are code exploration specialist.
package/agents.ts CHANGED
@@ -10,19 +10,19 @@ import { getAgentDir, parseFrontmatter } from "@mariozechner/pi-coding-agent";
10
10
 
11
11
  /**
12
12
  * tools frontmatter semantics:
13
- * unset → inherit everything (builtins + extensions)same as `all`
14
- * `all` → all builtins + all extension tools (web_search, fetch_content, mcp, …)
13
+ * unset → all builtins + all parent extensions — DEFAULT
14
+ * `all` → all builtins + all parent extensions (web_search, fetch_content, mcp, …)
15
+ * `builtins` → built-in coding tools only (read, bash, edit, write, grep, find, ls)
15
16
  * `none` → no tools at all
16
17
  * comma list → allowlist; extensions auto-loaded if any listed tool is non-builtin
17
- * for lean "builtins-only" mode, list them explicitly:
18
- * tools: read, bash, edit, write, grep, find, ls
19
18
  *
20
19
  * Represented as:
21
- * "all" everything (default when frontmatter omits `tools`)
20
+ * "builtins" only built-in coding tools
21
+ * "all" → everything (default)
22
22
  * "none" → no tools
23
23
  * string[] → allowlist
24
24
  */
25
- export type AgentTools = "all" | "none" | string[];
25
+ export type AgentTools = "builtins" | "all" | "none" | string[];
26
26
 
27
27
  export const BUILTIN_TOOL_NAMES = ["read", "bash", "edit", "write", "grep", "find", "ls"] as const;
28
28
 
@@ -40,11 +40,12 @@ const BUILTIN_TOOLS = new Set<string>(BUILTIN_TOOL_NAMES);
40
40
 
41
41
  export function agentNeedsExtensions(tools: AgentTools): boolean {
42
42
  if (tools === "all") return true;
43
- if (tools === "none") return false;
43
+ if (tools === "builtins" || tools === "none") return false;
44
44
  return tools.some((t) => !BUILTIN_TOOLS.has(t));
45
45
  }
46
46
 
47
- // Default: everything. Agents list specific tools for lean / restricted mode.
47
+ // Default: all tools, matching pi-subagents behavior. Agents opt into lean mode
48
+ // with `tools: builtins` or explicit built-in allowlists.
48
49
  function parseToolsField(raw: unknown): AgentTools {
49
50
  if (raw === undefined || raw === null) return "all";
50
51
  const str = String(raw).trim();
@@ -52,6 +53,7 @@ function parseToolsField(raw: unknown): AgentTools {
52
53
  const lower = str.toLowerCase();
53
54
  if (lower === "all") return "all";
54
55
  if (lower === "none") return "none";
56
+ if (lower === "builtins" || lower === "builtin") return "builtins";
55
57
  const list = str.split(",").map((t) => t.trim()).filter(Boolean);
56
58
  return list.length ? list : "all";
57
59
  }
package/index.ts CHANGED
@@ -14,6 +14,7 @@ import type {
14
14
  AgentToolUpdateCallback,
15
15
  ExtensionAPI,
16
16
  ExtensionContext,
17
+ ResourceLoader,
17
18
  ToolRenderResultOptions,
18
19
  } from "@mariozechner/pi-coding-agent";
19
20
  import { BackgroundJobManager } from "./background-job-manager.js";
@@ -36,6 +37,7 @@ import { type AgentConfig, agentNeedsExtensions, discoverAgents } from "./agents
36
37
 
37
38
  function formatTools(tools: AgentConfig["tools"]): string {
38
39
  if (tools === "all") return "all";
40
+ if (tools === "builtins") return "builtins (default)";
39
41
  if (tools === "none") return "none";
40
42
  return tools.join(", ");
41
43
  }
@@ -125,6 +127,125 @@ function refreshBgStatus(): void {
125
127
  _setBgStatus?.(running.length > 0 ? `⧗ ${running.length} bg agent${running.length > 1 ? "s" : ""}` : undefined);
126
128
  }
127
129
 
130
+ // ─── Resource loader pool ─────────────────────────────────────────────────────
131
+
132
+ interface LoaderPoolEntry {
133
+ idle: DefaultResourceLoader[];
134
+ active: Set<DefaultResourceLoader>;
135
+ warming: Set<Promise<void>>;
136
+ }
137
+
138
+ interface LoaderLease {
139
+ loader: ResourceLoader;
140
+ release: () => void;
141
+ }
142
+
143
+ const _loaderPool = new Map<string, LoaderPoolEntry>();
144
+
145
+ function loaderPoolKey(cwd: string, agentDir: string, noExtensions: boolean): string {
146
+ return `${cwd}\0${agentDir}\0${noExtensions ? "noext" : "ext"}`;
147
+ }
148
+
149
+ function getLoaderPoolEntry(cwd: string, agentDir: string, noExtensions: boolean): LoaderPoolEntry {
150
+ const key = loaderPoolKey(cwd, agentDir, noExtensions);
151
+ let entry = _loaderPool.get(key);
152
+ if (!entry) {
153
+ entry = { idle: [], active: new Set(), warming: new Set() };
154
+ _loaderPool.set(key, entry);
155
+ }
156
+ return entry;
157
+ }
158
+
159
+ function makeLoaderOptions(cwd: string, agentDir: string, noExtensions: boolean): DefaultResourceLoaderOptions {
160
+ return {
161
+ cwd,
162
+ agentDir,
163
+ noExtensions,
164
+ noContextFiles: true,
165
+ noSkills: true,
166
+ };
167
+ }
168
+
169
+ class AgentPromptResourceLoader implements ResourceLoader {
170
+ constructor(
171
+ private readonly base: ResourceLoader,
172
+ private readonly systemPromptOverride: string | undefined,
173
+ ) {}
174
+
175
+ getExtensions() { return this.base.getExtensions(); }
176
+ getSkills() { return this.base.getSkills(); }
177
+ getPrompts() { return this.base.getPrompts(); }
178
+ getThemes() { return this.base.getThemes(); }
179
+ getAgentsFiles() { return this.base.getAgentsFiles(); }
180
+ getSystemPrompt() { return this.systemPromptOverride ?? this.base.getSystemPrompt(); }
181
+ getAppendSystemPrompt() { return this.base.getAppendSystemPrompt(); }
182
+ extendResources(paths: Parameters<ResourceLoader["extendResources"]>[0]): void { this.base.extendResources(paths); }
183
+ reload(): Promise<void> { return this.base.reload(); }
184
+ }
185
+
186
+ function isLoaderWarm(cwd: string, agentDir: string, noExtensions: boolean): boolean {
187
+ const entry = _loaderPool.get(loaderPoolKey(cwd, agentDir, noExtensions));
188
+ return !!entry && entry.idle.length > 0;
189
+ }
190
+
191
+ async function allowUiPaint(coldLoader: boolean): Promise<void> {
192
+ await new Promise<void>((resolve) => setImmediate(resolve));
193
+ if (!coldLoader) return;
194
+ // Give pi's TUI render timer a real timers-phase turn before CPU-heavy extension loading.
195
+ await new Promise<void>((resolve) => setTimeout(resolve, 50));
196
+ await new Promise<void>((resolve) => setImmediate(resolve));
197
+ }
198
+
199
+ async function acquireResourceLoader(
200
+ cwd: string,
201
+ agentDir: string,
202
+ noExtensions: boolean,
203
+ systemPromptOverride: string | undefined,
204
+ ): Promise<LoaderLease> {
205
+ const entry = getLoaderPoolEntry(cwd, agentDir, noExtensions);
206
+
207
+ while (true) {
208
+ const cached = entry.idle.pop();
209
+ if (cached) {
210
+ entry.active.add(cached);
211
+ let released = false;
212
+ return {
213
+ loader: new AgentPromptResourceLoader(cached, systemPromptOverride),
214
+ release: () => {
215
+ if (released) return;
216
+ released = true;
217
+ entry.active.delete(cached);
218
+ entry.idle.push(cached);
219
+ },
220
+ };
221
+ }
222
+
223
+ const warming = entry.warming.values().next().value as Promise<void> | undefined;
224
+ if (warming) {
225
+ await warming;
226
+ continue;
227
+ }
228
+
229
+ const loader = new DefaultResourceLoader(makeLoaderOptions(cwd, agentDir, noExtensions));
230
+ const warmPromise = loader.reload()
231
+ .then(() => { entry.idle.push(loader); })
232
+ .finally(() => { entry.warming.delete(warmPromise); });
233
+ entry.warming.add(warmPromise);
234
+ await warmPromise;
235
+ }
236
+ }
237
+
238
+ function warmResourceLoader(cwd: string, agentDir: string, noExtensions: boolean): void {
239
+ const entry = getLoaderPoolEntry(cwd, agentDir, noExtensions);
240
+ if (entry.idle.length > 0 || entry.active.size > 0 || entry.warming.size > 0) return;
241
+ const loader = new DefaultResourceLoader(makeLoaderOptions(cwd, agentDir, noExtensions));
242
+ const warmPromise = loader.reload()
243
+ .then(() => { entry.idle.push(loader); })
244
+ .catch(() => { /* ignore warm failures; foreground call reports real error */ })
245
+ .finally(() => { entry.warming.delete(warmPromise); });
246
+ entry.warming.add(warmPromise);
247
+ }
248
+
128
249
  // ─── Foreground detach registry ───────────────────────────────────────────────
129
250
 
130
251
  interface ForegroundDetachEntry {
@@ -168,6 +289,7 @@ interface AgentRowStatus {
168
289
 
169
290
  interface SubagentDetails {
170
291
  mode?: "single" | "parallel";
292
+ agentName?: string;
171
293
  task?: string;
172
294
  // parallel
173
295
  parallelAgents?: AgentRowStatus[];
@@ -231,36 +353,59 @@ async function runAgent(
231
353
  };
232
354
  }
233
355
 
356
+ const bootStartedAt = Date.now();
234
357
  const { authStorage, modelRegistry } = getAuth();
235
358
  const agentDir = getAgentDir();
359
+ const noExtensions = !agentNeedsExtensions(agent.tools);
360
+ const coldLoader = !isLoaderWarm(cwd, agentDir, noExtensions);
361
+
362
+ // Fire an immediate "running" emit so the UI draws the agent header + prompt
363
+ // before the (potentially slow) extension/session load. Without this, pi looks
364
+ // frozen while `loader.reload()` and `createAgentSession()` are in flight.
365
+ onUpdate?.({
366
+ content: [{ type: "text", text: "" }],
367
+ details: {
368
+ agentName: agent.name,
369
+ task,
370
+ usage: { input: 0, output: 0, cost: 0, turns: 0 },
371
+ running: true,
372
+ elapsedMs: 0,
373
+ model: modelOverride ?? agent.model,
374
+ toolCalls: [],
375
+ } satisfies SubagentDetails,
376
+ });
377
+ // Yield through timers when loader is cold so pi's render loop paints before
378
+ // CPU-heavy extension loading runs.
379
+ await allowUiPaint(coldLoader);
236
380
 
237
- // Build resource loader — no extensions/context files to keep subagent lean.
238
- // Agents can opt in to extensions via `extensions: true` in frontmatter, which
239
- // makes tools like web_search / fetch_content / mcp / etc. available to the
240
- // subagent (subject to the optional `tools:` allowlist below).
241
- const loaderOptions: DefaultResourceLoaderOptions = {
381
+ const loaderLease = await acquireResourceLoader(
242
382
  cwd,
243
383
  agentDir,
244
- noExtensions: !agentNeedsExtensions(agent.tools),
245
- noContextFiles: true,
246
- noSkills: true,
247
- };
248
- if (agent.systemPrompt) {
249
- // Replace pi's base system prompt with the agent's own prompt
250
- loaderOptions.systemPromptOverride = () => agent.systemPrompt;
251
- }
252
-
253
- const loader = new DefaultResourceLoader(loaderOptions);
254
- await loader.reload();
384
+ noExtensions,
385
+ agent.systemPrompt || undefined,
386
+ );
255
387
 
256
- const { session } = await createAgentSession({
257
- cwd,
258
- agentDir,
259
- sessionManager: SessionManager.inMemory(cwd),
260
- authStorage,
261
- modelRegistry,
262
- resourceLoader: loader,
263
- });
388
+ let session: Awaited<ReturnType<typeof createAgentSession>>["session"];
389
+ try {
390
+ const created = await createAgentSession({
391
+ cwd,
392
+ agentDir,
393
+ sessionManager: SessionManager.inMemory(cwd),
394
+ authStorage,
395
+ modelRegistry,
396
+ resourceLoader: loaderLease.loader,
397
+ });
398
+ session = created.session;
399
+ } catch (e) {
400
+ loaderLease.release();
401
+ return {
402
+ output: "",
403
+ exitCode: 1,
404
+ error: e instanceof Error ? e.message : String(e),
405
+ toolCalls: [],
406
+ usage: { input: 0, output: 0, cost: 0, turns: 0 },
407
+ };
408
+ }
264
409
 
265
410
  // Resolve and apply model
266
411
  const modelStr = modelOverride ?? agent.model;
@@ -288,7 +433,7 @@ async function runAgent(
288
433
  let lastOutput = "";
289
434
  let currentDelta = "";
290
435
  let detectedModel: string | undefined;
291
- const startedAt = Date.now();
436
+ const startedAt = bootStartedAt;
292
437
  const configuredModel = modelOverride ?? agent.model;
293
438
  const toolCalls: ToolCallEntry[] = [];
294
439
  const toolStartTimes = new Map<string, number>();
@@ -300,6 +445,7 @@ async function runAgent(
300
445
  onUpdate?.({
301
446
  content: [{ type: "text", text: currentDelta || lastOutput || "" }],
302
447
  details: {
448
+ agentName: agent.name,
303
449
  task,
304
450
  usage,
305
451
  running: true,
@@ -423,6 +569,7 @@ async function runAgent(
423
569
  clearInterval(heartbeat);
424
570
  unsubscribe();
425
571
  session.dispose();
572
+ loaderLease.release();
426
573
  if (prevEnvDepth === undefined) delete process.env[DEPTH_ENV];
427
574
  else process.env[DEPTH_ENV] = prevEnvDepth;
428
575
  _currentDepth = depth;
@@ -558,12 +705,21 @@ export default function (pi: ExtensionAPI) {
558
705
 
559
706
  pi.on("session_start", async (_event, ctx) => {
560
707
  _setBgStatus = (text) => ctx.ui.setStatus(BG_STATUS_KEY, text);
708
+
709
+ // Warm one extension-capable loader after startup. First `tools: all` subagent
710
+ // call can then reuse loaded extensions instead of blocking before first stream.
711
+ if (process.env.PI_FAST_SUBAGENT_WARM !== "0") {
712
+ const warmCwd = ctx.cwd;
713
+ const warmAgentDir = getAgentDir();
714
+ setTimeout(() => warmResourceLoader(warmCwd, warmAgentDir, false), 1000);
715
+ }
561
716
  });
562
717
 
563
718
  pi.on("session_shutdown", async () => {
564
719
  getBgManager().shutdown();
565
720
  _bgManager = null;
566
721
  _setBgStatus = null;
722
+ _loaderPool.clear();
567
723
  });
568
724
 
569
725
  // ─── Ctrl+Shift+B — move foreground subagent to background ─────────────────────────
@@ -866,14 +1022,15 @@ export default function (pi: ExtensionAPI) {
866
1022
 
867
1023
  function statusLine(): string {
868
1024
  if (details.backgroundJobId) return `moved to background · ${details.backgroundJobId}`;
1025
+ const prefix = details.agentName ? `${theme.fg("toolTitle", details.agentName)} · ` : "";
869
1026
  if (details.running) {
870
1027
  const parts: string[] = ["running"];
871
1028
  if (details.usage?.turns) parts.push(`${details.usage.turns} turn${details.usage.turns > 1 ? "s" : ""}`);
872
1029
  if (details.elapsedMs != null) parts.push(formatDuration(details.elapsedMs));
873
1030
  if (details.model) parts.push(details.model);
874
- return parts.join(" · ");
1031
+ return prefix + parts.join(" · ");
875
1032
  }
876
- return formatUsage(details.usage ?? { input: 0, output: 0, cost: 0, turns: 0 }, details.model);
1033
+ return prefix + formatUsage(details.usage ?? { input: 0, output: 0, cost: 0, turns: 0 }, details.model);
877
1034
  }
878
1035
 
879
1036
  // Name(arg) ✓ 0.3s or Name(arg) (dim, still running)
@@ -904,6 +1061,8 @@ export default function (pi: ExtensionAPI) {
904
1061
  render(width: number): string[] {
905
1062
  const out: string[] = [];
906
1063
  const indent = " ";
1064
+ const ellipsisLine = (count: number) =>
1065
+ theme.fg("muted", `${indent}… (${count} more line${count === 1 ? "" : "s"})`);
907
1066
 
908
1067
  // ── Prompt ────────────────────────────────────────────────────
909
1068
  if (details.task) {
@@ -913,14 +1072,22 @@ export default function (pi: ExtensionAPI) {
913
1072
  for (const w of wrapLine(indent + line, width)) out.push(w);
914
1073
  }
915
1074
  } else {
916
- // Up to 8 visual lines in collapsed mode
1075
+ // Up to 8 visual lines from the HEAD of the prompt (keep opening, not tail).
917
1076
  const PROMPT_PREVIEW_LINES = 8;
918
1077
  if (cache.width !== width || cache.promptLines === undefined) {
919
- const preview = truncateToVisualLines(details.task, PROMPT_PREVIEW_LINES, width - indent.length);
920
- cache.promptLines = preview.visualLines.map((l) => truncateToWidth(indent + l, width, "..."));
921
- cache.promptSkipped = preview.skippedCount;
1078
+ const innerWidth = Math.max(1, width - indent.length);
1079
+ const allVisual: string[] = [];
1080
+ for (const raw of details.task.split("\n")) {
1081
+ for (const w of wrapLine(raw, innerWidth)) allVisual.push(w);
1082
+ }
1083
+ const head = allVisual.slice(0, PROMPT_PREVIEW_LINES);
1084
+ cache.promptLines = head.map((l) => truncateToWidth(indent + l, width, "..."));
1085
+ cache.promptSkipped = Math.max(0, allVisual.length - head.length);
922
1086
  }
923
1087
  out.push(...cache.promptLines);
1088
+ if ((cache.promptSkipped ?? 0) > 0) {
1089
+ out.push(truncateToWidth(ellipsisLine(cache.promptSkipped!), width, "..."));
1090
+ }
924
1091
  }
925
1092
  }
926
1093
 
@@ -950,6 +1117,10 @@ export default function (pi: ExtensionAPI) {
950
1117
  cache.skipped = preview.skippedCount;
951
1118
  cache.width = width;
952
1119
  }
1120
+ // truncateToVisualLines keeps the tail — show ellipsis BEFORE the visible lines.
1121
+ if ((cache.skipped ?? 0) > 0) {
1122
+ out.push(truncateToWidth(ellipsisLine(cache.skipped!), width, "..."));
1123
+ }
953
1124
  out.push(...(cache.responseLines ?? []));
954
1125
  }
955
1126
  }
@@ -1129,6 +1300,7 @@ export default function (pi: ExtensionAPI) {
1129
1300
  return {
1130
1301
  content: [{ type: "text", text: `Moved to background: ${bgJobId}. Completion will be announced automatically.` }],
1131
1302
  details: {
1303
+ agentName: params.agent,
1132
1304
  task: params.task,
1133
1305
  usage: { input: 0, output: 0, cost: 0, turns: 0 },
1134
1306
  running: false,
@@ -1142,6 +1314,7 @@ export default function (pi: ExtensionAPI) {
1142
1314
  return {
1143
1315
  content: [{ type: "text", text: getFinalText(result) }],
1144
1316
  details: {
1317
+ agentName: params.agent,
1145
1318
  task: params.task,
1146
1319
  usage: result.usage,
1147
1320
  running: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fast-subagent",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "In-process subagent delegation for pi with single, parallel, and background modes",
5
5
  "type": "module",
6
6
  "keywords": [