pi-subagents-lite 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,14 +13,14 @@ Every tool the LLM sees costs tokens — in the system prompt, and in every turn
13
13
 
14
14
  | Standard | Schema-first |
15
15
  |---|---|
16
- | `description: "Spawn a sub-agent"` | `description: "."` |
16
+ | `description: "Spawn a sub-agent"` | _(removed)_ |
17
17
  | `promptSnippet` with usage examples | _(none)_ |
18
18
  | `promptGuidelines` with rules | _(none)_ |
19
19
  | Parameters with `.description()` | Bare `Type.String()` |
20
20
 
21
21
  Tool names like `Agent` and `StopAgent`, and parameter names like `prompt`, `description`, `run_in_background` are self-documenting. The LLM infers usage from the schema — no verbose descriptions needed. Tool results reinforce correct usage with clear success/error messages.
22
22
 
23
- **Result:** foreground and background agents, custom agent types, per-model concurrency, cost tracking, steering, resume, model overrides — all with minimal token overhead.
23
+ **Result:** foreground and background agents, custom agent types, per-model concurrency, cost tracking, steering, model overrides — all with minimal token overhead.
24
24
 
25
25
  ## Features
26
26
 
@@ -32,7 +32,7 @@ Tool names like `Agent` and `StopAgent`, and parameter names like `prompt`, `des
32
32
  - **Cost tracking** — input/output/cache tokens and dollar cost per agent
33
33
  - **Live widget** — persistent status bar above the editor showing running/completed agents
34
34
  - **Result viewer** — fullscreen markdown viewer with stats
35
- - **Steer & resume** — inject mid-execution guidance or continue a previous conversation
35
+ - **Steer** — inject mid-execution guidance into running agents
36
36
  - **Output logs** — human-readable, `tail -f` friendly
37
37
 
38
38
  ## Install
@@ -95,7 +95,6 @@ Stop a running agent at any time via /agents command
95
95
  | `description` | ✅ | Brief description for the LLM caller |
96
96
  | `agent` | | Type name — `general-purpose`, `Explore`, or any custom type you define (see [Custom Agent Types](#custom-agent-types)). The available values are **auto-populated** from `.md` files in your agent directories — drop a file, it appears in the enum. Set `enabled: false` in frontmatter to remove a type from this list. |
97
97
  | `run_in_background` | | Fire-and-forget; result delivered automatically when done |
98
- | `resume` | | Agent ID to continue a previous conversation |
99
98
 
100
99
  > `model`, `max_turns`, `isolated`, and `thinking` are **not visible to the LLM** through tool introspection — the extension injects them at call time from agent config and frontmatter. `model` is resolved via the [Model Resolution](#model-resolution) chain; `max_turns`/`isolated`/`thinking` come from the agent's config. See [Custom Agent Types](#custom-agent-types) to set them.
101
100
 
@@ -134,7 +133,8 @@ focusing on injection flaws, auth bypasses, and insecure defaults.
134
133
  | `tools` | string[] | Built-in tool allowlist: `read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`. If omitted, inherits all. |
135
134
  | `disallowed_tools` | string[] | Tool denylist — removes these from the agent's toolset even if allowlisted by `tools` or extensions. |
136
135
  | `extensions` | bool \| string[] | `false` = no extension tools; `true` = inherit all; `["ext-a"]` = allowlist. |
137
- | `skills` | bool \| string[] | `false` = no skill prompts; `true` = inherit all; `["skill-a"]` = only these. |
136
+ | `skills` | bool \| string[] | `false` = no skills; `true` = inherit all; `["skill-a"]` = metadata-only injection (agent reads full content on-demand). |
137
+ | `preload_skills` | string[] \| false | `["skill-a"]` = dump full SKILL.md content into system prompt (old `skills` behavior). `false`/omitted = none. |
138
138
  | `model` | string | Default model as `"provider/model-id"`. Override via `/agents` or `subagents-lite.json`. |
139
139
  | `thinking` | string | Default thinking level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`. |
140
140
  | `max_turns` | number | Turn limit (soft stop with steer). |
@@ -150,8 +150,9 @@ Every tool schema and every skill snippet you inject costs tokens — in every t
150
150
  | `tools: [a, b, c]` | Which built-in tools the LLM sees | High — each tool has a schema (name, params, description) injected every turn. Fewer tools = fewer tokens. |
151
151
  | `extensions: false` | Disables all extension-provided tools | Medium — extensions can register many tools (linters, browsers, etc.). Each adds schema tokens. |
152
152
  | `extensions: ["my-ext"]` | Allowlist only specific extensions | Medium — pick only what the agent needs. |
153
- | `skills: false` | Prevents skill content from being injected into the system prompt | **Highest**skill prompts are prose, not schemas. A verbose skill can be 10-50x the token cost of a tool schema. |
154
- | `skills: ["skill-a"]` | Inject only listed skills (preloaded inline) | Medium you control exactly which skills appear. |
153
+ | `skills: ["skill-a"]` | Whitelist skills injects metadata only (name, description, location) | Lowagent reads full content on-demand via `read` tool. No prose in system prompt. |
154
+ | `skills: false` | Disables all skills | Zero skill tokens. |
155
+ | `preload_skills: ["skill-a"]` | Dump full SKILL.md content into system prompt | **Highest** — skill prompts are prose, not schemas. A verbose skill can be 10-50x the token cost of a tool schema. |
155
156
  | `isolated: true` | Shorthand for `extensions: false` + `skills: false` | High — zero extension tools, zero skill prompts. Useful for fast, focused agents. |
156
157
 
157
158
  **Practical example:** An `Explore` agent that only reads files doesn't need write tools, browser extensions, or git skills. Setting `tools: [read, bash, grep, find, ls]` + `extensions: false` + `skills: false` saves thousands of tokens per turn compared to inheriting everything.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-subagents-lite",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Lightweight sub-agents for pi — spawn specialized agents with isolated sessions, tools, and models.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -28,6 +28,7 @@ export interface AgentConfigFromMd {
28
28
  tools?: string[];
29
29
  extensions?: boolean | string[];
30
30
  skills?: boolean | string[];
31
+ preload_skills?: string[] | false;
31
32
  model?: string;
32
33
  thinking?: ThinkingLevel;
33
34
  max_turns?: number;
@@ -163,6 +164,26 @@ export function parseExtensions(
163
164
  return undefined;
164
165
  }
165
166
 
167
+ /**
168
+ * Parse the preload_skills field from frontmatter.
169
+ * Unlike parseExtensions, does NOT accept true/"true"/"all" —
170
+ * preload requires an explicit list of skill names.
171
+ */
172
+ export function parsePreloadSkills(
173
+ raw: unknown,
174
+ ): string[] | false | undefined {
175
+ if (raw === false || raw === "false" || raw === "none") {
176
+ return false;
177
+ }
178
+ if (typeof raw === "string" && raw.length > 0) {
179
+ return splitCommaList(raw);
180
+ }
181
+ if (Array.isArray(raw)) {
182
+ return raw.map(String);
183
+ }
184
+ return undefined; // true/"true"/"all" not supported
185
+ }
186
+
166
187
  /* ------------------------------------------------------------------ */
167
188
  /* Frontmatter value helpers */
168
189
  /* ------------------------------------------------------------------ */
@@ -253,6 +274,7 @@ export function parseAgentFile(
253
274
  tools: parseStringArray(frontmatter, "tools"),
254
275
  extensions: parseExtensions(frontmatter.extensions),
255
276
  skills: parseExtensions(frontmatter.skills),
277
+ preload_skills: parsePreloadSkills(frontmatter.preload_skills),
256
278
  model: parseString(frontmatter, "model"),
257
279
  thinking: parseThinkingLevel(parseString(frontmatter, "thinking")),
258
280
  max_turns: parseNumber(frontmatter, "max_turns"),
@@ -377,6 +399,7 @@ function fromMd(md: AgentConfigFromMd): Partial<AgentConfig> {
377
399
  builtinToolNames: md.tools,
378
400
  extensions: md.extensions,
379
401
  skills: md.skills,
402
+ preloadSkills: md.preload_skills,
380
403
  model: md.model,
381
404
  thinking: md.thinking,
382
405
  maxTurns: md.max_turns,
@@ -15,14 +15,15 @@
15
15
  import { randomUUID } from "node:crypto";
16
16
  import type { Model } from "@earendil-works/pi-ai";
17
17
  import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
18
- import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
18
+ import { runAgent, type ToolActivity } from "./agent-runner.js";
19
19
  import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./output-file.js";
20
- import type {
21
- AgentInvocation,
22
- AgentRecord,
23
- CompactionInfo,
24
- SubagentType,
25
- ThinkingLevel,
20
+ import {
21
+ type AgentInvocation,
22
+ type AgentRecord,
23
+ type CompactionInfo,
24
+ SHORT_ID_LENGTH,
25
+ type SubagentType,
26
+ type ThinkingLevel,
26
27
  } from "./types.js";
27
28
  import { addUsage, getLifetimeTotal, type LifetimeUsage } from "./usage.js";
28
29
  import { errorMessage } from "./utils.js";
@@ -33,8 +34,10 @@ const CLEANUP_INTERVAL_MS = 60_000;
33
34
  /** Age after which a completed agent record is evicted (milliseconds). */
34
35
  const CLEANUP_AGE_CUTOFF_MS = 10 * 60_000;
35
36
 
36
- /** Length of short agent ID (UUID prefix). */
37
- const AGENT_ID_LENGTH = 17;
37
+ /** UUID prefix length for agent IDs stored in the agents map (uniqueness). */
38
+ const AGENT_ID_PREFIX_LENGTH = 17;
39
+
40
+
38
41
 
39
42
  /** Default per-model concurrency limit when not specified in config. */
40
43
  const DEFAULT_CONCURRENCY_LIMIT = 4;
@@ -233,7 +236,7 @@ export class AgentManager {
233
236
  prompt: string,
234
237
  options: SpawnOptions,
235
238
  ): string {
236
- const id = randomUUID().slice(0, AGENT_ID_LENGTH);
239
+ const id = randomUUID().slice(0, AGENT_ID_PREFIX_LENGTH);
237
240
  const abortController = new AbortController();
238
241
  const args: SpawnArgs = { pi, ctx, type, prompt, options };
239
242
 
@@ -381,7 +384,7 @@ export class AgentManager {
381
384
  }
382
385
 
383
386
  /**
384
- * Build common record-tracking callbacks shared by startAgent and resume.
387
+ * Build common record-tracking callbacks shared by startAgent.
385
388
  * Updates the record's toolUses, lifetimeUsage, and compactionCount.
386
389
  * When options are provided, also forwards events to the caller.
387
390
  */
@@ -434,39 +437,6 @@ export class AgentManager {
434
437
  this.queue = this.queue.filter(e => !started.has(e.id));
435
438
  }
436
439
 
437
- /**
438
- * Resume an existing agent session with a new prompt.
439
- */
440
- async resume(
441
- id: string,
442
- prompt: string,
443
- signal?: AbortSignal,
444
- ): Promise<AgentRecord | undefined> {
445
- const record = this.agents.get(id);
446
- if (!record?.session) return undefined;
447
-
448
- record.status = "running";
449
- record.startedAt = Date.now();
450
- record.completedAt = undefined;
451
- record.result = undefined;
452
- record.error = undefined;
453
-
454
- try {
455
- const responseText = await resumeAgent(record.session, prompt, {
456
- ...this.createRecordCallbacks(record),
457
- signal,
458
- });
459
- record.status = "completed";
460
- record.result = responseText;
461
- record.completedAt = Date.now();
462
- } catch (err) {
463
- record.status = "error";
464
- record.error = errorMessage(err);
465
- record.completedAt = Date.now();
466
- }
467
-
468
- return record;
469
- }
470
440
 
471
441
  /**
472
442
  * Send a steering message to a running agent.
@@ -28,9 +28,9 @@ import { extractText } from "./context.js";
28
28
  import type { LifetimeUsage } from "./usage.js";
29
29
  import { findModelInRegistry } from "./utils.js";
30
30
  import { DEFAULT_AGENTS } from "./default-agents.js";
31
- import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
32
- import { preloadSkills } from "./skill-loader.js";
33
- import type { CompactionInfo, EnvInfo, SubagentType, ThinkingLevel } from "./types.js";
31
+ import { buildAgentPrompt, type PromptExtras, type SkillMeta } from "./prompts.js";
32
+ import { preloadSkills, loadSkillMeta } from "./skill-loader.js";
33
+ import { type CompactionInfo, type EnvInfo, SHORT_ID_LENGTH, type SubagentType, type ThinkingLevel } from "./types.js";
34
34
 
35
35
  /** Names of tools registered by this extension that subagents must NOT inherit. */
36
36
  export const EXCLUDED_TOOL_NAMES = ["Agent"];
@@ -157,7 +157,7 @@ function usageFromAssistantMessage(msg: Record<string, unknown>): LifetimeUsage
157
157
 
158
158
  /**
159
159
  * Subscribe to shared session events (tool activity, usage, compaction)
160
- * used by both runAgent and resumeAgent. Returns an unsubscribe function.
160
+ * used by runAgent. Returns an unsubscribe function.
161
161
  */
162
162
  export function subscribeToSessionEvents(
163
163
  session: AgentSession,
@@ -264,12 +264,18 @@ export async function runAgent(
264
264
  const effectiveIsolated = options.isolated ?? agentConfig?.isolated;
265
265
  const extensions = effectiveIsolated ? false : config.extensions;
266
266
  const skills = effectiveIsolated ? false : config.skills;
267
+ const preloadSkillsList = effectiveIsolated ? false : agentConfig?.preloadSkills;
267
268
 
268
269
  // Build prompt extras (no memoryBlock — skills only).
269
- // When skills is string[], preload their content into the prompt.
270
- const extras: PromptExtras = Array.isArray(skills)
271
- ? { skillBlocks: preloadSkills(skills, effectiveCwd) }
272
- : {};
270
+ // - preloadSkills: force full content into system prompt
271
+ // - skills: metadata only (whitelist), agent reads on-demand
272
+ const extras: PromptExtras = {};
273
+ if (Array.isArray(preloadSkillsList)) {
274
+ extras.skillBlocks = preloadSkills(preloadSkillsList, effectiveCwd);
275
+ }
276
+ if (Array.isArray(skills)) {
277
+ extras.skillMetas = loadSkillMeta(skills, effectiveCwd);
278
+ }
273
279
 
274
280
  const toolNames = getToolNamesForType(type);
275
281
 
@@ -284,9 +290,11 @@ export async function runAgent(
284
290
  systemPrompt = buildAgentPrompt({ ...fallback, name: type }, effectiveCwd, env, extras);
285
291
  }
286
292
 
287
- // When skills is string[], they're already preloaded into the prompt.
288
- // Pass noSkills: true to prevent the skill loader from loading them again.
289
- const skipSkillLoader = skills === false || Array.isArray(skills);
293
+ // Skip the built-in skill loader when:
294
+ // - skills is false (no skills)
295
+ // - preloadSkills is string[] (we handle preloading ourselves)
296
+ // - skills is string[] (we handle metadata ourselves)
297
+ const skipSkillLoader = skills === false || Array.isArray(skills) || Array.isArray(preloadSkillsList);
290
298
 
291
299
  const agentDir = getAgentDir();
292
300
 
@@ -330,7 +338,7 @@ export async function runAgent(
330
338
 
331
339
  const baseSessionName = agentConfig?.name ?? type;
332
340
  session.setSessionName(
333
- options.agentId ? `${baseSessionName}#${options.agentId.slice(0, 8)}` : baseSessionName,
341
+ options.agentId ? `${baseSessionName}#${options.agentId.slice(0, SHORT_ID_LENGTH)}` : baseSessionName,
334
342
  );
335
343
 
336
344
  // Filter active tools: remove our own tools to prevent nesting,
@@ -395,32 +403,3 @@ export async function runAgent(
395
403
  const responseText = collector.getText().trim() || getLastAssistantText(session);
396
404
  return { responseText, session, aborted, steered: softLimitReached };
397
405
  }
398
-
399
- /**
400
- * Send a new prompt to an existing session (resume).
401
- */
402
- export async function resumeAgent(
403
- session: AgentSession,
404
- prompt: string,
405
- options: Pick<RunOptions, "onToolActivity" | "onAssistantUsage" | "onCompaction"> & { signal?: AbortSignal } = {},
406
- ): Promise<string> {
407
- const collector = collectResponseText(session);
408
- const cleanupAbort = forwardAbortSignal(session, options.signal);
409
- const unsubEvents = subscribeToSessionEvents(session, options);
410
-
411
- try {
412
- await session.prompt(prompt);
413
- } finally {
414
- collector.unsubscribe();
415
- unsubEvents();
416
- cleanupAbort();
417
- }
418
-
419
- return collector.getText().trim() || getLastAssistantText(session);
420
- }
421
-
422
-
423
-
424
-
425
-
426
-
package/src/config-io.ts CHANGED
@@ -12,6 +12,12 @@ import type { SubagentsConfig } from "./model-precedence.js";
12
12
  const CONFIG_DIR = path.join(process.env.HOME || "", ".pi", "agent");
13
13
  const CONFIG_PATH = path.join(CONFIG_DIR, "subagents-lite.json");
14
14
 
15
+ /** Default configuration — used when config file doesn't exist or is invalid. */
16
+ export const DEFAULT_CONFIG: SubagentsConfig = {
17
+ agent: { default: null, forceBackground: false },
18
+ concurrency: { default: 4 },
19
+ };
20
+
15
21
  /** Read config from disk. Returns defaults if file doesn't exist or is invalid. */
16
22
  export function loadConfig(): SubagentsConfig {
17
23
  try {
@@ -21,10 +27,7 @@ export function loadConfig(): SubagentsConfig {
21
27
  // File doesn't exist or is invalid — return defaults
22
28
  }
23
29
 
24
- return {
25
- agent: { default: null, forceBackground: false },
26
- concurrency: { default: 4 },
27
- };
30
+ return { ...DEFAULT_CONFIG, agent: { ...DEFAULT_CONFIG.agent }, concurrency: { ...DEFAULT_CONFIG.concurrency } };
28
31
  }
29
32
 
30
33
  /** Write config to disk with atomic rename. */
package/src/index.ts CHANGED
@@ -5,8 +5,7 @@
5
5
  *
6
6
  * Stealth tool registration:
7
7
  * - All tools register at extension init (not runtime)
8
- * - description: "." (single character — tells LLM nothing)
9
- * - No promptSnippet, no promptGuidelines
8
+ * - No description, no promptSnippet, no promptGuidelines
10
9
  * - Parameters without .description()
11
10
  * - Model parameter removed from schema — injected via tool_call listener
12
11
  *
@@ -39,7 +38,7 @@ import { scanAgentFilesInDir, mergeAgents } from "./agent-discovery.js";
39
38
  import { AgentManager } from "./agent-manager.js";
40
39
  import { AgentWidget, buildStatsParts, formatMs, getDisplayName, type AgentActivity, type UICtx } from "./ui/agent-widget.js";
41
40
  import { showAgentsMainMenu } from "./menus.js";
42
- import { loadConfig } from "./config-io.js";
41
+ import { loadConfig, DEFAULT_CONFIG } from "./config-io.js";
43
42
  import { executeAgentTool, toolCallListener, backgroundAgentIds, scheduleNudge } from "./tool-execution.js";
44
43
  import { executeStopAgentTool } from "./stop-agent-tool.js";
45
44
 
@@ -51,10 +50,7 @@ import { executeStopAgentTool } from "./stop-agent-tool.js";
51
50
  export let sessionOverrides: SessionModelOverrides = { default: null };
52
51
 
53
52
  /** Config cache — loaded at session_start, updated by /agents menu mutations. */
54
- export let __config: SubagentsConfig = {
55
- agent: { default: null, forceBackground: false },
56
- concurrency: { default: 4 },
57
- };
53
+ export let __config: SubagentsConfig = { ...DEFAULT_CONFIG, agent: { ...DEFAULT_CONFIG.agent }, concurrency: { ...DEFAULT_CONFIG.concurrency } };
58
54
 
59
55
  /** Agent manager singleton — module-level, no globalThis access. */
60
56
  export let manager: AgentManager;
@@ -162,19 +158,21 @@ function buildStatsLine(d: Record<string, unknown>, theme: any): string {
162
158
  */
163
159
  function registerAgentTool(pi: ExtensionAPI): void {
164
160
  const types = getAvailableTypes();
161
+ // Use plain string to avoid verbose anyOf in prompt.
162
+ // Available types are listed in description for discoverability.
165
163
  const agentParam = types.length > 0
166
- ? Type.Optional(Type.Union(types.map(t => Type.Literal(t))))
164
+ ? Type.Optional(Type.String({ description: types.join(",") }))
167
165
  : Type.Optional(Type.String());
166
+ // @ts-expect-error — description removed to save prompt tokens
168
167
  pi.registerTool({
169
168
  name: "Agent",
170
169
  label: "Agent",
171
- description: ".",
172
170
  parameters: Type.Object({
173
171
  prompt: Type.String(),
174
172
  description: Type.String(),
175
173
  agent: agentParam,
176
174
  run_in_background: Type.Optional(Type.Boolean()),
177
- resume: Type.Optional(Type.String()),
175
+
178
176
  }),
179
177
  execute: executeAgentTool,
180
178
 
@@ -241,10 +239,10 @@ export default function (pi: ExtensionAPI) {
241
239
  registerAgentTool(pi);
242
240
 
243
241
  // StopAgent tool — stealth schema, stop a running agent by ID
242
+ // @ts-expect-error — description removed to save prompt tokens
244
243
  pi.registerTool({
245
244
  name: "StopAgent",
246
245
  label: "StopAgent",
247
- description: ".",
248
246
  parameters: Type.Object({
249
247
  agent_id: Type.String(),
250
248
  }),
package/src/menus.ts CHANGED
@@ -8,12 +8,13 @@
8
8
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
9
9
  import { getAgentConfig, getAvailableTypes, getAllTypes } from "./agent-types.js";
10
10
  import type { AgentRecord } from "./types.js";
11
+ import { SHORT_ID_LENGTH } from "./types.js";
11
12
  import { ModelSelectorDialog, type ModelOption } from "./model-selector.js";
12
13
  import { ResultViewer, type ResultViewerStats } from "./result-viewer.js";
13
14
  import { getDisplayName } from "./ui/agent-widget.js";
14
15
  import { buildSnapshotMarkdown } from "./context.js";
15
16
 
16
- import { parseModelKey, errorMessage } from "./utils.js";
17
+ import { parseModelKey } from "./utils.js";
17
18
  import {
18
19
  __config,
19
20
  sessionOverrides,
@@ -21,7 +22,7 @@ import {
21
22
  piInstance,
22
23
  } from "./index.js";
23
24
  import { resolveModel } from "./model-precedence.js";
24
- import { saveConfigAtomic } from "./config-io.js";
25
+ import { saveConfigAtomic, DEFAULT_CONFIG } from "./config-io.js";
25
26
 
26
27
  // ============================================================================
27
28
  // Helpers
@@ -473,7 +474,6 @@ async function handleAgentBriefing(ctx: ExtensionCommandContext): Promise<void>
473
474
  lines.push("| `agent` | Which agent type to use (default: general-purpose) |");
474
475
  lines.push("| `thinking` | Optional thinking mode override (e.g., `off`, `minimal`, `low`, `medium`, `high`, `xhigh`) |");
475
476
  lines.push("| `run_in_background` | When `true`, result is auto-delivered — do NOT poll. Continue working while waiting. |");
476
- lines.push("| `resume` | Agent ID to resume from; when set, `prompt` is appended to the previous conversation |");
477
477
  lines.push("");
478
478
 
479
479
  // Usage guidelines
@@ -481,7 +481,6 @@ async function handleAgentBriefing(ctx: ExtensionCommandContext): Promise<void>
481
481
  lines.push("- Agents start fresh with their config — they do NOT inherit the parent conversation");
482
482
  lines.push("- For parallel tasks, spawn multiple `run_in_background: true` agents in one turn");
483
483
  lines.push(" → Results are auto-delivered — do NOT poll, the result will arrive when ready");
484
- lines.push("- Use `resume` to continue an incomplete agent's conversation");
485
484
  piInstance.sendUserMessage(lines.join("\n"));
486
485
  ctx.ui.notify("Agent briefing sent to LLM", "info");
487
486
  }
@@ -542,7 +541,7 @@ export async function showConcurrencySettingsMenu(
542
541
  // Reset all to defaults
543
542
  items.push("Reset all to defaults");
544
543
  actions.push(async () => {
545
- __config.concurrency = { default: 4 };
544
+ __config.concurrency = { ...DEFAULT_CONFIG.concurrency };
546
545
  applyConcurrencyConfig();
547
546
  ctx.ui.notify("Concurrency reset to defaults", "info");
548
547
  });
@@ -672,7 +671,7 @@ async function showRunningAgentsMenu(
672
671
  record.status === "queued" ? "⏳" :
673
672
  record.status === "error" ? "✗" : "•";
674
673
  items.push(
675
- `${statusIcon} ${record.id.slice(0, 8)} ${record.type} ${record.status} ${elapsed}s`,
674
+ `${statusIcon} ${record.id.slice(0, SHORT_ID_LENGTH)} ${record.type} ${record.status} ${elapsed}s`,
676
675
  );
677
676
 
678
677
  actions.push(async () => {
@@ -710,9 +709,9 @@ async function showResultViewer(
710
709
  text: string,
711
710
  ): Promise<void> {
712
711
  const titleSuffix = kind === "result"
713
- ? record.id.slice(0, 8)
712
+ ? record.id.slice(0, SHORT_ID_LENGTH)
714
713
  : kind === "snapshot"
715
- ? `snapshot \u00b7 ${record.id.slice(0, 8)}`
714
+ ? `snapshot \u00b7 ${record.id.slice(0, SHORT_ID_LENGTH)}`
716
715
  : "Error";
717
716
  const stats: ResultViewerStats = {
718
717
  lifetimeUsage: record.lifetimeUsage,
@@ -753,19 +752,11 @@ async function steerAgentById(
753
752
  const message = await ctx.ui.input(`Steer ${record.type}`);
754
753
  if (!message?.trim()) return;
755
754
 
756
- try {
757
- if (!record.session) {
758
- if (!record.pendingSteers) {
759
- record.pendingSteers = [];
760
- }
761
- record.pendingSteers.push(message.trim());
762
- ctx.ui.notify(`Steer message queued for ${record.id.slice(0, 8)}…`, "info");
763
- } else {
764
- await record.session.steer(message.trim());
765
- ctx.ui.notify(`Steer sent to ${record.id.slice(0, 8)}…`, "info");
766
- }
767
- } catch (err) {
768
- ctx.ui.notify(`Steer failed: ${errorMessage(err)}`, "error");
755
+ const sent = await manager.steer(agentId, message.trim());
756
+ if (sent) {
757
+ ctx.ui.notify(`Steer sent to ${record.id.slice(0, SHORT_ID_LENGTH)}…`, "info");
758
+ } else {
759
+ ctx.ui.notify(`Steer failed for ${record.id.slice(0, SHORT_ID_LENGTH)}`, "error");
769
760
  }
770
761
  }
771
762
 
@@ -819,12 +810,12 @@ export async function showAgentActions(
819
810
  items.push("Stop");
820
811
  actions.push(async () => {
821
812
  manager?.abort(record.id);
822
- ctx.ui.notify(`Stopped ${record.id.slice(0, 8)}`, "info");
813
+ ctx.ui.notify(`Stopped ${record.id.slice(0, SHORT_ID_LENGTH)}`, "info");
823
814
  });
824
815
  }
825
816
 
826
817
  if (items.length === 0) {
827
- ctx.ui.notify(`Agent ${record.id.slice(0, 8)} — no actions available`, "info");
818
+ ctx.ui.notify(`Agent ${record.id.slice(0, SHORT_ID_LENGTH)} — no actions available`, "info");
828
819
  return;
829
820
  }
830
821
 
@@ -834,7 +825,7 @@ export async function showAgentActions(
834
825
  items.push("Back");
835
826
  actions.push(async () => {});
836
827
 
837
- await runMenu(ctx, `Agent ${record.id.slice(0, 8)}`, items, actions);
828
+ await runMenu(ctx, `Agent ${record.id.slice(0, SHORT_ID_LENGTH)}`, items, actions);
838
829
  }
839
830
 
840
831
  async function showAgentTypes(ctx: ExtensionCommandContext): Promise<void> {
@@ -36,7 +36,6 @@ export interface SessionModelOverrides {
36
36
  [agentType: string]: string | null | undefined;
37
37
  }
38
38
 
39
- /**
40
39
  /** Options for resolveModel. */
41
40
  export interface ResolveModelOptions {
42
41
  /** The type of subagent being spawned. */
@@ -12,6 +12,7 @@
12
12
  import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
13
13
  import { join } from "node:path";
14
14
  import type { AgentSession, AgentSessionEvent } from "@earendil-works/pi-coding-agent";
15
+ import { formatTokens } from "./usage.js";
15
16
 
16
17
  /** Max length for a truncated command in tool arg summaries. */
17
18
  const MAX_COMMAND_DISPLAY_LENGTH = 100;
@@ -254,9 +255,7 @@ export function streamToOutputFile(
254
255
 
255
256
  // Write DONE line
256
257
  const { turnCount = 0, toolUseCount = 0, totalTokens = 0, cost = 0 } = stats ?? {};
257
- const tokensStr = totalTokens >= 1000
258
- ? `${(totalTokens / 1000).toFixed(1)}k tokens`
259
- : `${totalTokens} tokens`;
258
+ const tokensStr = `${formatTokens(totalTokens)} tokens`;
260
259
  const costStr = `$${cost.toFixed(3)}`;
261
260
  safeAppend(path, `${timestamp()} [DONE] ${turnCount} turns, ${toolUseCount} tool uses, ${tokensStr}, ${costStr}\n`);
262
261
 
package/src/prompts.ts CHANGED
@@ -6,11 +6,15 @@
6
6
  */
7
7
 
8
8
  import type { AgentConfig, EnvInfo } from "./types.js";
9
+ import type { SkillMeta } from "./skill-loader.js";
10
+ export type { SkillMeta };
9
11
 
10
12
  /** Extra sections to inject into the system prompt (skills only — no memoryBlock). */
11
13
  export interface PromptExtras {
12
- /** Preloaded skill contents to inject. */
14
+ /** Preloaded skill contents to inject (full content). */
13
15
  skillBlocks?: { name: string; content: string }[];
16
+ /** Skill metadata for whitelist display (name, description, location only). */
17
+ skillMetas?: SkillMeta[];
14
18
  }
15
19
 
16
20
  /**
@@ -43,11 +47,34 @@ export function buildAgentPrompt(
43
47
 
44
48
  // Build optional extras suffix (skills only — no memoryBlock)
45
49
  const extraSections: string[] = [];
50
+
51
+ // Skill metadata whitelist (like Pi's available_skills format)
52
+ if (extras?.skillMetas?.length) {
53
+ const lines = [
54
+ "The following skills provide specialized instructions for specific tasks.",
55
+ "Use the read tool to load a skill's file when the task matches its description.",
56
+ "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.",
57
+ "",
58
+ "<available_skills>",
59
+ ];
60
+ for (const skill of extras.skillMetas) {
61
+ lines.push(" <skill>");
62
+ lines.push(` <name>${escapeXml(skill.name)}</name>`);
63
+ lines.push(` <description>${escapeXml(skill.description)}</description>`);
64
+ lines.push(` <location>${escapeXml(skill.location)}</location>`);
65
+ lines.push(" </skill>");
66
+ }
67
+ lines.push("</available_skills>");
68
+ extraSections.push(lines.join("\n"));
69
+ }
70
+
71
+ // Preloaded skill contents (full dump into system prompt)
46
72
  if (extras?.skillBlocks?.length) {
47
73
  for (const skill of extras.skillBlocks) {
48
74
  extraSections.push(`\n# Preloaded Skill: ${skill.name}\n${skill.content}`);
49
75
  }
50
76
  }
77
+
51
78
  const extrasSuffix = extraSections.length > 0 ? `\n\n${extraSections.join("\n")}` : "";
52
79
 
53
80
  const header = `You are a pi coding agent sub-agent.
@@ -58,4 +85,11 @@ ${envBlock}`;
58
85
  return `${activeAgentTag}${header}\n\n${config.systemPrompt}${extrasSuffix}`;
59
86
  }
60
87
 
88
+ function escapeXml(value: string): string {
89
+ // Only escape < and > — enough for XML-like tags, keeps text readable for LLMs
90
+ return value
91
+ .replace(/</g, "&lt;")
92
+ .replace(/>/g, "&gt;");
93
+ }
94
+
61
95
 
@@ -45,6 +45,9 @@ export interface ResultViewerStats {
45
45
  /** Lines scrolled per PageUp/PageDown (kept at a fixed, comfortable amount). */
46
46
  const PAGE_STEP = 14;
47
47
 
48
+ /** Render width for markdown content and separator line. */
49
+ const RENDER_WIDTH = 78;
50
+
48
51
  /** Fixed non-viewport lines in the component (borders, title, spacers, hints, etc.). */
49
52
  const BASE_OVERHEAD = 10;
50
53
 
@@ -141,7 +144,7 @@ export class ResultViewer extends Container implements Component {
141
144
  // Build markdown renderer (pre-render to get total lines)
142
145
  const mdTheme = buildMarkdownTheme(theme);
143
146
  this.markdown = new Markdown(text, 0, 0, mdTheme);
144
- this.renderedLines = this.markdown.render(78);
147
+ this.renderedLines = this.markdown.render(RENDER_WIDTH);
145
148
 
146
149
  this.buildUI(title, stats);
147
150
  this.updateViewport();
@@ -169,7 +172,7 @@ export class ResultViewer extends Container implements Component {
169
172
 
170
173
  // Separator
171
174
  this.addChild(
172
- new Text(this.theme.fg("muted", "─".repeat(78)), 0, 0),
175
+ new Text(this.theme.fg("muted", "─".repeat(RENDER_WIDTH)), 0, 0),
173
176
  );
174
177
  this.addChild(new Spacer(1));
175
178
 
@@ -272,7 +275,7 @@ export class ResultViewer extends Container implements Component {
272
275
  this.textRef.text = newText;
273
276
  const mdTheme = buildMarkdownTheme(this.theme);
274
277
  this.markdown = new Markdown(newText, 0, 0, mdTheme);
275
- this.renderedLines = this.markdown.render(78);
278
+ this.renderedLines = this.markdown.render(RENDER_WIDTH);
276
279
  // Preserve scroll position, clamped to new content bounds
277
280
  this.scrollOffset = Math.min(oldOffset, this.renderedLines.length - 1);
278
281
  this.updateViewport();
@@ -32,37 +32,73 @@ interface PreloadedSkill {
32
32
  content: string;
33
33
  }
34
34
 
35
+ export interface SkillMeta {
36
+ name: string;
37
+ description: string;
38
+ location: string;
39
+ }
40
+
41
+ /**
42
+ * Skill search roots in precedence order (project → user → legacy).
43
+ * Shared by preloadSkills and loadSkillMeta.
44
+ */
45
+ function getSkillRoots(cwd: string): string[] {
46
+ return [
47
+ join(cwd, ".pi", "skills"), // project — Pi standard
48
+ join(cwd, ".agents", "skills"), // project — Agent Skills spec
49
+ join(getAgentDir(), "skills"), // user — Pi standard
50
+ join(homedir(), ".agents", "skills"), // user — Agent Skills spec
51
+ join(homedir(), ".pi", "skills"), // legacy global, pre-Pi
52
+ ];
53
+ }
54
+
35
55
  export function preloadSkills(skillNames: string[], cwd: string): PreloadedSkill[] {
36
56
  return skillNames.map((name) => ({ name, content: loadSkillContent(name, cwd) }));
37
57
  }
38
58
 
59
+ /**
60
+ * Load skill metadata only (name, description, location) without full content.
61
+ * Used for the skills whitelist — agent can read full content on-demand.
62
+ */
63
+ export function loadSkillMeta(skillNames: string[], cwd: string): SkillMeta[] {
64
+ return skillNames.map((name) => {
65
+ const location = findSkillLocation(name, cwd);
66
+ if (!location) {
67
+ return { name, description: `(Skill "${name}" not found)`, location: "" };
68
+ }
69
+ const description = extractDescription(location);
70
+ return { name, description, location };
71
+ });
72
+ }
73
+
39
74
  function loadSkillContent(name: string, cwd: string): string {
40
75
  if (isUnsafeName(name)) {
41
76
  return `(Skill "${name}" skipped: name contains path traversal characters)`;
42
77
  }
43
- const roots = [
44
- join(cwd, ".pi", "skills"), // project — Pi standard
45
- join(cwd, ".agents", "skills"), // project — Agent Skills spec
46
- join(getAgentDir(), "skills"), // user — Pi standard
47
- join(homedir(), ".agents", "skills"), // user — Agent Skills spec
48
- join(homedir(), ".pi", "skills"), // legacy global, pre-Pi
49
- ];
50
- for (const root of roots) {
51
- const content = findInRoot(root, name);
78
+ for (const root of getSkillRoots(cwd)) {
79
+ const content = findInRoot(root, name, "content");
52
80
  if (content !== undefined) return content;
53
81
  }
54
82
  return `(Skill "${name}" not found in .pi/skills/, .agents/skills/, or global skill locations)`;
55
83
  }
56
84
 
57
- function findInRoot(root: string, name: string): string | undefined {
58
- if (isSymlink(root)) return undefined; // reject symlinked roots entirely
59
- const flat = safeReadFile(join(root, `${name}.md`))?.trim();
60
- if (flat !== undefined) return flat;
61
- return findSkillDirectory(root, name);
85
+ function findInRoot(root: string, name: string, mode: "content" | "location"): string | undefined {
86
+ if (isSymlink(root)) return undefined;
87
+ const flatPath = join(root, `${name}.md`);
88
+ if (mode === "location") {
89
+ if (existsSync(flatPath)) return flatPath;
90
+ } else {
91
+ const content = safeReadFile(flatPath)?.trim();
92
+ if (content !== undefined) return content;
93
+ }
94
+ return findSkillDirectory(root, name, mode);
62
95
  }
63
96
 
64
- /** BFS under `root` for a directory named `name` containing `SKILL.md`. Pi-conforming filters. */
65
- function findSkillDirectory(root: string, name: string): string | undefined {
97
+ /**
98
+ * BFS under `root` for a directory named `name` containing `SKILL.md`.
99
+ * Pi-conforming filters. Returns either the file content or the file path.
100
+ */
101
+ function findSkillDirectory(root: string, name: string, mode: "content" | "location"): string | undefined {
66
102
  if (!existsSync(root)) return undefined;
67
103
  const queue: string[] = [root];
68
104
 
@@ -91,6 +127,7 @@ function findSkillDirectory(root: string, name: string): string | undefined {
91
127
 
92
128
  if (isSkillDir) {
93
129
  if (entry.name === name) {
130
+ if (mode === "location") return skillMd;
94
131
  const content = safeReadFile(skillMd)?.trim();
95
132
  if (content !== undefined) return content;
96
133
  }
@@ -102,3 +139,42 @@ function findSkillDirectory(root: string, name: string): string | undefined {
102
139
  }
103
140
  return undefined;
104
141
  }
142
+
143
+ /**
144
+ * Find skill file location without reading content.
145
+ * Returns the full path to the SKILL.md or .md file, or undefined if not found.
146
+ */
147
+ function findSkillLocation(name: string, cwd: string): string | undefined {
148
+ if (isUnsafeName(name)) return undefined;
149
+ for (const root of getSkillRoots(cwd)) {
150
+ const location = findInRoot(root, name, "location");
151
+ if (location !== undefined) return location;
152
+ }
153
+ return undefined;
154
+ }
155
+
156
+ /** Extract description from SKILL.md frontmatter. */
157
+ function extractDescription(filePath: string): string {
158
+ try {
159
+ const content = safeReadFile(filePath);
160
+ if (!content) return "(no description)";
161
+
162
+ // Simple frontmatter extraction
163
+ const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
164
+ if (!normalized.startsWith("---\n")) return "(no description)";
165
+ const endIndex = normalized.indexOf("\n---\n", 4);
166
+ if (endIndex === -1) return "(no description)";
167
+
168
+ const yamlString = normalized.slice(4, endIndex);
169
+ // Simple extraction of description field
170
+ const descMatch = yamlString.match(/^description:\s*["']?(.+?)["']?\s*$/m);
171
+ if (descMatch && descMatch[1]) {
172
+ // Truncate long descriptions
173
+ const desc = descMatch[1].trim();
174
+ return desc.length > 200 ? desc.slice(0, 197) + "..." : desc;
175
+ }
176
+ return "(no description)";
177
+ } catch {
178
+ return "(error reading description)";
179
+ }
180
+ }
@@ -13,6 +13,7 @@
13
13
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
14
14
  import { successResult, errorResult } from "./tool-execution.js";
15
15
  import { manager } from "./index.js";
16
+ import { SHORT_ID_LENGTH } from "./types.js";
16
17
 
17
18
  // ============================================================================
18
19
  // Running agents list helper
@@ -30,7 +31,7 @@ function formatRunningAgents(): string {
30
31
  if (agents.length === 0) return "none";
31
32
 
32
33
  return agents
33
- .map((a) => `${a.type}·${a.id.slice(0, 5)}`)
34
+ .map((a) => `${a.type}·${a.id.slice(0, SHORT_ID_LENGTH)}`)
34
35
  .join(", ");
35
36
  }
36
37
 
@@ -69,7 +70,7 @@ export async function executeStopAgentTool(
69
70
 
70
71
  // Attempt to stop the running/queued agent
71
72
  if (manager.abort(agentId)) {
72
- return successResult(`Stopped agent ${agentId.slice(0, 5)}`);
73
+ return successResult(`Stopped agent ${agentId.slice(0, SHORT_ID_LENGTH)}`);
73
74
  }
74
75
 
75
76
  return errorResult(`Failed to stop agent ${agentId}`);
@@ -6,7 +6,6 @@
6
6
  */
7
7
 
8
8
  import type { ExtensionContext, ToolCallEvent } from "@earendil-works/pi-coding-agent";
9
- import type { Model } from "@earendil-works/pi-ai";
10
9
 
11
10
  import type { AgentRecord } from "./types.js";
12
11
  import type { SpawnOptions as AgentManagerSpawnOptions } from "./agent-manager.js";
@@ -151,7 +150,7 @@ function emitIndividualNudge(agentId: string, record?: AgentRecord): void {
151
150
  piInstance.sendMessage(
152
151
  {
153
152
  customType: "subagent-result",
154
- content: record.result ?? "",
153
+ content: `[Subagent "${record.type}" completed]\n\n${record.result ?? ""}`,
155
154
  details,
156
155
  display: true,
157
156
  },
@@ -181,7 +180,6 @@ export async function executeAgentTool(
181
180
 
182
181
  const prompt = params.prompt as string;
183
182
  const description = params.description as string;
184
- const resume = params.resume as string | undefined;
185
183
  const runInBackground = params.run_in_background as boolean | undefined;
186
184
  const isolated = params.isolated as boolean | undefined;
187
185
  const maxTurns = params.max_turns as number | undefined ?? getAgentConfig(resolvedType)?.maxTurns;
@@ -199,10 +197,6 @@ export async function executeAgentTool(
199
197
  const thinkingLevel = parseThinkingLevel(params.thinking as string | undefined)
200
198
  ?? getAgentConfig(resolvedType)?.thinking;
201
199
 
202
- if (resume) {
203
- return executeResumeAgent(resume, prompt);
204
- }
205
-
206
200
  const spawnOptions: AgentManagerSpawnOptions = {
207
201
  description,
208
202
  model,
@@ -220,34 +214,23 @@ export async function executeAgentTool(
220
214
  return executeSpawnForeground(resolvedType, prompt, ctx, spawnOptions);
221
215
  }
222
216
 
223
- async function executeResumeAgent(
224
- resume: string,
225
- prompt: string,
226
- ): Promise<any> {
227
- const record = await manager.resume(resume, prompt);
228
- if (!record) {
229
- return errorResult(`Agent not found: ${resume}`);
230
- }
231
- return successResult(record.result ?? "");
232
- }
233
-
234
217
  async function executeSpawnBackground(
235
218
  resolvedType: string,
236
219
  prompt: string,
237
220
  ctx: ExtensionContext,
238
221
  spawnOptions: AgentManagerSpawnOptions,
239
222
  ): Promise<any> {
240
- const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(
223
+ const { state, callbacks } = createActivityTracker(
241
224
  spawnOptions.maxTurns,
242
225
  );
243
226
 
244
227
  const agentId = manager.spawn(piInstance, ctx, resolvedType, prompt, {
245
228
  ...spawnOptions,
246
229
  isBackground: true,
247
- ...bgCallbacks,
230
+ ...callbacks,
248
231
  });
249
232
  backgroundAgentIds.add(agentId);
250
- agentActivity.set(agentId, bgState);
233
+ agentActivity.set(agentId, state);
251
234
  widget?.ensureTimer();
252
235
  widget?.update();
253
236
 
@@ -255,16 +238,11 @@ async function executeSpawnBackground(
255
238
  if (!record) {
256
239
  return errorResult("Failed to create agent");
257
240
  }
258
- const bgDetails: Record<string, unknown> = { type: resolvedType, description: spawnOptions.description };
259
- if (record.status === "queued") {
260
- return successResult(`[Agent queued] Concurrency limit reached. It will start automatically when a slot frees up. A notification will arrive when done — User asks you not to poll, but wait for nudge.
241
+ const details: Record<string, unknown> = { type: resolvedType, description: spawnOptions.description };
242
+ const suffix = `A notification will arrive when done - User asks you not to poll or duplicate the delegated work.\n\nAgent ID: ${agentId}`;
243
+ const label = record.status === "queued" ? "Agent queued" : "Agent running";
261
244
 
262
- Agent ID: ${agentId}`, bgDetails);
263
- }
264
- return successResult(
265
- `Agent running in background. A notification will arrive when done — User asks you not to poll, but wait for nudge.\n\nAgent ID: ${agentId}`,
266
- bgDetails,
267
- );
245
+ return successResult(`[${label}] ${suffix}`, details);
268
246
  }
269
247
 
270
248
  async function executeSpawnForeground(
package/src/types.ts CHANGED
@@ -27,8 +27,10 @@ export interface AgentConfig {
27
27
  disallowedTools?: string[];
28
28
  /** true = inherit all, string[] = only listed, false = none */
29
29
  extensions: true | string[] | false;
30
- /** true = inherit all, string[] = only listed, false = none */
30
+ /** Whitelist of allowed skills (metadata only in system prompt). true = all, string[] = listed, false = none */
31
31
  skills: true | string[] | false;
32
+ /** Skills to preload with full content into system prompt. string[] = listed, false/undefined = none */
33
+ preloadSkills?: string[] | false;
32
34
  model?: string;
33
35
  thinking?: ThinkingLevel;
34
36
  maxTurns?: number;
@@ -95,6 +97,9 @@ export interface EnvInfo {
95
97
  platform: string;
96
98
  }
97
99
 
100
+ /** How many characters of agent ID to show in display. */
101
+ export const SHORT_ID_LENGTH = 8;
102
+
98
103
  /** Reason for a context compaction event. */
99
104
  export type CompactionReason = "manual" | "threshold" | "overflow";
100
105