@tintinweb/pi-subagents 0.7.1 → 0.7.3

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/CHANGELOG.md CHANGED
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.3] - 2026-05-14
11
+
12
+ ### Added
13
+ - **`<active_agent name="…"/>` tag prepended to every child system prompt** ([#73](https://github.com/tintinweb/pi-subagents/pull/73) — thanks [@chris-lasher](https://github.com/chris-lasher)). `buildAgentPrompt` now emits `<active_agent name="${config.name}"/>` as the first line of the assembled prompt in both `replace` and `append` modes, before the env block. Downstream extensions (e.g. permission/policy systems) can parse it from inside the child session to resolve per-agent policy. The tag uses the agent's `config.name` verbatim — no escaping or normalization — and does not couple this extension to any specific downstream consumer; ignoring it is harmless.
14
+
15
+ ### Changed
16
+ - **Subagent sessions now get a stable, type-derived name with an id suffix for parallel spawns** ([#51](https://github.com/tintinweb/pi-subagents/pull/51) — thanks [@forcepushdev](https://github.com/forcepushdev)). `runAgent` calls `session.setSessionName(agentConfig?.name ?? type)`, and when the manager assigns an `agentId` (always, in production), the name is suffixed with an 8-char slice — e.g. `Explore#a1b2c3d4` — so concurrent spawns of the same agent type are distinguishable in the overlay instead of all collapsing onto the same bare name. Direct `runAgent` callers without an `agentId` (e.g. tests) get the bare name.
17
+
18
+ ### Fixed
19
+ - **Cross-extension spawn RPC now accepts a string `options.model`** ([#59](https://github.com/tintinweb/pi-subagents/pull/59), fixes [#60](https://github.com/tintinweb/pi-subagents/issues/60)). Cross-extension callers (e.g. `@tintinweb/pi-tasks@>=0.4.3`'s `TaskExecute`) naturally forward `model` as a serializable `"provider/modelId"` string. Previously the spawn handler passed strings straight through to `runAgent()`, which expects a `Model` object — the spawned agent then crashed with `No API key found for undefined`. The handler now resolves strings via the same `resolveModel(ctx.modelRegistry)` path the scheduler uses; `Model` objects pass through unchanged. Unresolved strings surface the human-readable `Model not found: "…"` error instead of the auth-lookup crash. Thanks @any-victor.
20
+
21
+ ## [0.7.2] - 2026-05-12
22
+
23
+ > **Heads-up — behavior changes in skill preloading:**
24
+ > - **`.txt` and extensionless flat skill files are no longer loaded.** Only `<name>.md` flat files and `<name>/SKILL.md` directory skills resolve now. Rename any `<name>.txt` or extensionless skill files to `<name>.md`.
25
+
26
+ ### Added
27
+ - **Pi-standard `<name>/SKILL.md` directory layout** is now discovered alongside flat `<name>.md` files. Top-level and nested matches both resolve via BFS — for skill `foo`, the loader checks `<root>/foo/SKILL.md`, then recursively descends looking for `*/.../foo/SKILL.md`. Recursion skips dotfile directories and `node_modules`; a directory that itself contains `SKILL.md` is treated as a single skill (Pi's "skills don't nest" rule).
28
+ - **Five discovery roots**, checked in precedence order:
29
+ - `<cwd>/.pi/skills/` (project, Pi)
30
+ - `<cwd>/.agents/skills/` (project, [Agent Skills spec](https://agentskills.io/integrate-skills))
31
+ - `$PI_CODING_AGENT_DIR/skills/` — default `~/.pi/agent/skills/` (user, Pi)
32
+ - `~/.agents/skills/` (user, Agent Skills spec)
33
+ - `~/.pi/skills/` (legacy global, kept for backward compatibility)
34
+ - **Symlink rejection broadened** to the new layouts: symlinked skill roots, nested skill directories, and `SKILL.md` files inside otherwise-real directories are all rejected (intentional deviation from Pi, which follows symlinks).
35
+ - **Deterministic traversal order** — entries are sorted byte-order so collisions resolve identically across filesystems. Pi's iteration order is `readdirSync`-dependent.
36
+ - **Resolved spawn args are now shown in the dedicated conversation viewer** ([#62](https://github.com/tintinweb/pi-subagents/issues/62)). Open `/subagent` → Running Agents → select an agent: a second header row displays the effective invocation — model override (when different from parent), `thinking: <level>`, `isolated`, `worktree`, `inherit context`, `background`, and `max turns: N`. Tags appear when the resolved value is notable (e.g. `isolated: true`), not just when the caller explicitly set it; `max turns` is the one exception and shows only when explicitly configured. Lets you verify the parent agent honored your spawn instructions without scrolling back through the chat. Snapshot stored on the new `AgentRecord.invocation` field. The same tag set is also surfaced on the `Agent` tool-call result render (which previously showed a narrower subset).
37
+ - **`Shift+↑` / `Shift+↓` scroll a full page in the conversation viewer** — same behavior as `PgUp` / `PgDn`. Note: some terminal emulators intercept Shift+arrows for text selection or tab switching, in which case `PgUp`/`PgDn` remain available.
38
+
39
+ ### Changed
40
+ - **`.txt` and extensionless flat skill files are no longer loaded.** Pi only supports `.md`; we now match. **Migration:** rename any `<name>.txt` / `<name>` skill files to `<name>.md`.
41
+ - **Conversation viewer no longer fills the full screen.** The overlay is now capped at 70% of terminal height (90% width unchanged), and the viewer's internal viewport mirrors that cap so the footer/scroll indicator can't be clipped.
42
+
10
43
  ## [0.7.1] - 2026-05-07
11
44
 
12
45
  > **Heads-up — behavior change:**
package/README.md CHANGED
@@ -25,7 +25,7 @@ https://github.com/user-attachments/assets/8685261b-9338-4fea-8dfe-1c590d5df543
25
25
  - **Context inheritance** — optionally fork the parent conversation into a sub-agent so it knows what's been discussed
26
26
  - **Persistent agent memory** — three scopes (project, local, user) with automatic read-only fallback for agents without write tools
27
27
  - **Git worktree isolation** — run agents in isolated repo copies; changes auto-committed to branches on completion
28
- - **Skill preloading** — inject named skill files from `.pi/skills/` into agent system prompts
28
+ - **Skill preloading** — inject named skills into agent system prompts, discovered from `.pi/skills/`, `.agents/skills/`, and global locations (Pi-standard `<name>/SKILL.md` directory layout supported)
29
29
  - **Tool denylist** — block specific tools via `disallowed_tools` frontmatter
30
30
  - **Styled completion notifications** — background agent results render as themed, compact notification boxes (icon, stats, result preview) instead of raw XML. Expandable to show full output. Group completions render each agent individually
31
31
  - **Event bus** — lifecycle events (`subagents:created`, `started`, `completed`, `failed`, `steered`, `compacted`) emitted via `pi.events`, enabling other extensions to react to sub-agent activity
@@ -195,7 +195,7 @@ All fields are optional — sensible defaults for everything.
195
195
  | `display_name` | — | Display name for UI (e.g. widget, agent list) |
196
196
  | `tools` | all 7 | Comma-separated built-in tools: read, bash, edit, write, grep, find, ls. `none` for no tools |
197
197
  | `extensions` | `true` | Inherit MCP/extension tools. `false` to disable |
198
- | `skills` | `true` | Inherit skills from parent. Can be a comma-separated list of skill names to preload from `.pi/skills/` |
198
+ | `skills` | `true` | Inherit skills from parent. Can be a comma-separated list of skill names to preload (see [Skill Preloading](#skill-preloading) for discovery locations) |
199
199
  | `memory` | — | Persistent agent memory scope: `project`, `local`, or `user`. Auto-detects read-only agents |
200
200
  | `disallowed_tools` | — | Comma-separated tools to deny even if extensions provide them |
201
201
  | `isolation` | — | Set to `worktree` to run in an isolated git worktree |
@@ -406,6 +406,8 @@ pi.events.emit("subagents:rpc:spawn", {
406
406
  });
407
407
  ```
408
408
 
409
+ `options.model` accepts either a `Model` object (e.g. `ctx.model`) or a `"provider/modelId"` string — strings are resolved against `ctx.modelRegistry` at the RPC boundary, so cross-extension callers can forward serializable values without losing auth context.
410
+
409
411
  ### Stop
410
412
 
411
413
  Stop a running agent by ID:
@@ -457,7 +459,7 @@ If the worktree cannot be created (not a git repo, no commits, or `git worktree
457
459
 
458
460
  ## Skill Preloading
459
461
 
460
- Skills can be preloaded as named files from `.pi/skills/` or `~/.pi/skills/`:
462
+ Skills can be preloaded by name and injected into the agent's system prompt:
461
463
 
462
464
  ```yaml
463
465
  ---
@@ -465,7 +467,25 @@ skills: api-conventions, error-handling
465
467
  ---
466
468
  ```
467
469
 
468
- Skill files (`.md`, `.txt`, or extensionless) are read and injected into the agent's system prompt. Project-level skills take priority over global ones. Symlinked skill files are rejected for security.
470
+ **Discovery roots** (checked in this order, first match wins):
471
+
472
+ | Scope | Path | Source |
473
+ |---|---|---|
474
+ | Project | `<cwd>/.pi/skills/` | Pi-standard |
475
+ | Project | `<cwd>/.agents/skills/` | [Agent Skills spec](https://agentskills.io/integrate-skills) |
476
+ | User | `$PI_CODING_AGENT_DIR/skills/` (default `~/.pi/agent/skills/`) | Pi-standard |
477
+ | User | `~/.agents/skills/` | [Agent Skills spec](https://agentskills.io/integrate-skills) |
478
+ | User | `~/.pi/skills/` | Legacy (pre-Pi) |
479
+
480
+ **Per root, a skill named `foo` resolves to the first of:**
481
+
482
+ - `<root>/foo.md` — flat file at the top level
483
+ - `<root>/foo/SKILL.md` — directory skill (top-level)
484
+ - `<root>/*/.../foo/SKILL.md` — directory skill, found by recursive descent
485
+
486
+ Recursion skips dotfile directories and `node_modules`. A directory that itself contains a `SKILL.md` is treated as a single skill — we don't descend into it. Traversal is byte-order sorted for deterministic resolution across filesystems.
487
+
488
+ **Security:** symlinks are rejected at every layer (root, flat file, skill directory, `SKILL.md` inside a skill directory) — intentional deviation from Pi, which follows symlinks. Skill names with path-traversal characters (`..`, `/`, `\`, spaces, leading dot, >128 chars) are rejected.
469
489
 
470
490
  ## Tool Denylist
471
491
 
@@ -494,7 +514,7 @@ src/
494
514
  group-join.ts # Group join manager: batched completion notifications with timeout
495
515
  custom-agents.ts # Load user-defined agents from .pi/agents/*.md
496
516
  memory.ts # Persistent agent memory (resolve, read, build prompt blocks)
497
- skill-loader.ts # Preload skill files from .pi/skills/
517
+ skill-loader.ts # Preload skills (Pi-standard + Agent Skills spec layouts)
498
518
  output-file.ts # Streaming output file transcripts for agent sessions
499
519
  worktree.ts # Git worktree isolation (create, cleanup, prune)
500
520
  prompts.ts # Config-driven system prompt builder
@@ -8,7 +8,7 @@
8
8
  import type { Model } from "@mariozechner/pi-ai";
9
9
  import type { AgentSession, ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
10
10
  import { type ToolActivity } from "./agent-runner.js";
11
- import type { AgentRecord, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
11
+ import type { AgentInvocation, AgentRecord, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
12
12
  export type OnAgentComplete = (record: AgentRecord) => void;
13
13
  export type OnAgentStart = (record: AgentRecord) => void;
14
14
  export type OnAgentCompact = (record: AgentRecord, info: CompactionInfo) => void;
@@ -32,6 +32,8 @@ interface SpawnOptions {
32
32
  bypassQueue?: boolean;
33
33
  /** Isolation mode — "worktree" creates a temp git worktree for the agent. */
34
34
  isolation?: IsolationMode;
35
+ /** Resolved invocation snapshot captured for UI display. */
36
+ invocation?: AgentInvocation;
35
37
  /** Parent abort signal — when aborted, the subagent is also stopped. */
36
38
  signal?: AbortSignal;
37
39
  /** Called on tool start/end with activity info (for streaming progress to UI). */
@@ -57,6 +57,7 @@ export class AgentManager {
57
57
  abortController,
58
58
  lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
59
59
  compactionCount: 0,
60
+ invocation: options.invocation,
60
61
  };
61
62
  this.agents.set(id, record);
62
63
  const args = { pi, ctx, type, prompt, options };
@@ -106,6 +107,7 @@ export class AgentManager {
106
107
  const detach = () => { detachParentSignal?.(); detachParentSignal = undefined; };
107
108
  const promise = runAgent(ctx, type, prompt, {
108
109
  pi,
110
+ agentId: id,
109
111
  model: options.model,
110
112
  maxTurns: options.maxTurns,
111
113
  isolated: options.isolated,
@@ -23,6 +23,8 @@ export interface ToolActivity {
23
23
  export interface RunOptions {
24
24
  /** ExtensionAPI instance — used for pi.exec() instead of execSync. */
25
25
  pi: ExtensionAPI;
26
+ /** Manager-assigned id; suffixes session name to disambiguate parallel spawns (e.g. `Explore#a1b2c3d4`). */
27
+ agentId?: string;
26
28
  model?: Model<any>;
27
29
  maxTurns?: number;
28
30
  signal?: AbortSignal;
@@ -187,6 +187,8 @@ export async function runAgent(ctx, type, prompt, options) {
187
187
  sessionOpts.thinkingLevel = thinkingLevel;
188
188
  }
189
189
  const { session } = await createAgentSession(sessionOpts);
190
+ const baseSessionName = agentConfig?.name ?? type;
191
+ session.setSessionName(options.agentId ? `${baseSessionName}#${options.agentId.slice(0, 8)}` : baseSessionName);
190
192
  // Build disallowed tools set from agent config
191
193
  const disallowedSet = agentConfig?.disallowedTools
192
194
  ? new Set(agentConfig.disallowedTools)
@@ -8,6 +8,7 @@
8
8
  * success → { success: true, data?: T }
9
9
  * error → { success: false, error: string }
10
10
  */
11
+ import { resolveModel } from "./model-resolver.js";
11
12
  /** RPC protocol version — bumped when the envelope or method contracts change. */
12
13
  export const PROTOCOL_VERSION = 2;
13
14
  /**
@@ -44,7 +45,28 @@ export function registerRpcHandlers(deps) {
44
45
  const ctx = getCtx();
45
46
  if (!ctx)
46
47
  throw new Error("No active session");
47
- return { id: manager.spawn(pi, ctx, type, prompt, options ?? {}) };
48
+ // Cross-extension RPC callers (e.g. pi-tasks TaskExecute) naturally
49
+ // forward serializable values, so options.model can be a string like
50
+ // "openai-codex/gpt-5.5". Resolve it to a real Model instance here
51
+ // — same pattern the scheduler path already uses — so the spawned
52
+ // agent's auth lookup doesn't crash with "No API key found for
53
+ // undefined".
54
+ let normalizedOptions = options ?? {};
55
+ if (typeof normalizedOptions.model === "string") {
56
+ const registry = ctx.modelRegistry;
57
+ if (!registry) {
58
+ throw new Error(`Model override "${normalizedOptions.model}" provided but ctx.modelRegistry is unavailable`);
59
+ }
60
+ const resolved = resolveModel(normalizedOptions.model, registry);
61
+ if (typeof resolved === "string") {
62
+ // resolveModel returns a human-readable error string when the
63
+ // input doesn't match any available model. Surface it instead of
64
+ // silently falling back so the caller sees the auth/typo issue.
65
+ throw new Error(resolved);
66
+ }
67
+ normalizedOptions = { ...normalizedOptions, model: resolved };
68
+ }
69
+ return { id: manager.spawn(pi, ctx, type, prompt, normalizedOptions) };
48
70
  });
49
71
  const unsubStop = handleRpc(events, "subagents:rpc:stop", ({ agentId }) => {
50
72
  if (!manager.abort(agentId))
package/dist/index.js CHANGED
@@ -26,7 +26,7 @@ import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./o
26
26
  import { SubagentScheduler } from "./schedule.js";
27
27
  import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
28
28
  import { applyAndEmitLoaded, saveAndEmitChanged } from "./settings.js";
29
- import { AgentWidget, describeActivity, formatDuration, formatMs, formatTokens, formatTurns, getDisplayName, getPromptModeLabel, SPINNER, } from "./ui/agent-widget.js";
29
+ import { AgentWidget, buildInvocationTags, describeActivity, formatDuration, formatMs, formatTokens, formatTurns, getDisplayName, getPromptModeLabel, SPINNER, } from "./ui/agent-widget.js";
30
30
  import { showSchedulesMenu } from "./ui/schedule-menu.js";
31
31
  import { addUsage, getLifetimeTotal, getSessionContextPercent } from "./usage.js";
32
32
  // ---- Shared helpers ----
@@ -739,29 +739,32 @@ Guidelines:
739
739
  const runInBackground = resolvedConfig.runInBackground;
740
740
  const isolated = resolvedConfig.isolated;
741
741
  const isolation = resolvedConfig.isolation;
742
- // Build display tags for non-default config
743
742
  const parentModelId = ctx.model?.id;
744
743
  const effectiveModelId = model?.id;
745
- const agentModelName = effectiveModelId && effectiveModelId !== parentModelId
744
+ const modelName = effectiveModelId && effectiveModelId !== parentModelId
746
745
  ? (model?.name ?? effectiveModelId).replace(/^Claude\s+/i, "").toLowerCase()
747
746
  : undefined;
748
- const agentTags = [];
749
- const modeLabel = getPromptModeLabel(subagentType);
750
- if (modeLabel)
751
- agentTags.push(modeLabel);
752
- if (thinking)
753
- agentTags.push(`thinking: ${thinking}`);
754
- if (isolated)
755
- agentTags.push("isolated");
756
- if (isolation === "worktree")
757
- agentTags.push("worktree");
758
747
  const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? getDefaultMaxTurns());
759
- // Shared base fields for all AgentDetails in this call
748
+ const agentInvocation = {
749
+ modelName,
750
+ thinking,
751
+ // Explicit value only — the default fallback would just add noise.
752
+ // Normalize so `0` (unlimited) doesn't surface as a misleading "max turns: 0".
753
+ maxTurns: normalizeMaxTurns(resolvedConfig.maxTurns),
754
+ isolated,
755
+ inheritContext,
756
+ runInBackground,
757
+ isolation,
758
+ };
759
+ // Tool-result render shows the mode label too; viewer's header already does.
760
+ const modeLabel = getPromptModeLabel(subagentType);
761
+ const { tags: invocationTags } = buildInvocationTags(agentInvocation);
762
+ const agentTags = modeLabel ? [modeLabel, ...invocationTags] : invocationTags;
760
763
  const detailBase = {
761
764
  displayName,
762
765
  description: params.description,
763
766
  subagentType,
764
- modelName: agentModelName,
767
+ modelName,
765
768
  tags: agentTags.length > 0 ? agentTags : undefined,
766
769
  };
767
770
  // ---- Schedule: register a job, don't spawn now ----
@@ -843,6 +846,7 @@ Guidelines:
843
846
  thinkingLevel: thinking,
844
847
  isBackground: true,
845
848
  isolation,
849
+ invocation: agentInvocation,
846
850
  ...bgCallbacks,
847
851
  });
848
852
  }
@@ -943,6 +947,7 @@ Guidelines:
943
947
  inheritContext,
944
948
  thinkingLevel: thinking,
945
949
  isolation,
950
+ invocation: agentInvocation,
946
951
  signal,
947
952
  ...fgCallbacks,
948
953
  });
@@ -1250,14 +1255,14 @@ Guidelines:
1250
1255
  ctx.ui.notify(`Agent is ${record.status === "queued" ? "queued" : "expired"} — no session available.`, "info");
1251
1256
  return;
1252
1257
  }
1253
- const { ConversationViewer } = await import("./ui/conversation-viewer.js");
1258
+ const { ConversationViewer, VIEWPORT_HEIGHT_PCT } = await import("./ui/conversation-viewer.js");
1254
1259
  const session = record.session;
1255
1260
  const activity = agentActivity.get(record.id);
1256
1261
  await ctx.ui.custom((tui, theme, _keybindings, done) => {
1257
1262
  return new ConversationViewer(tui, session, record, activity, theme, done);
1258
1263
  }, {
1259
1264
  overlay: true,
1260
- overlayOptions: { anchor: "center", width: "90%" },
1265
+ overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
1261
1266
  });
1262
1267
  }
1263
1268
  async function showAgentDetail(ctx, name) {
package/dist/prompts.d.ts CHANGED
@@ -19,6 +19,10 @@ export interface PromptExtras {
19
19
  * - "append" mode: env header + parent system prompt + sub-agent context + config.systemPrompt
20
20
  * - "append" with empty systemPrompt: pure parent clone
21
21
  *
22
+ * Both modes prepend an `<active_agent name="${config.name}"/>` tag so downstream
23
+ * extensions (e.g. permission/policy systems) can resolve per-agent policy
24
+ * inside the child session by parsing the system prompt.
25
+ *
22
26
  * @param parentSystemPrompt The parent agent's effective system prompt (for append mode).
23
27
  * @param extras Optional extra sections to inject (memory, preloaded skills).
24
28
  */
package/dist/prompts.js CHANGED
@@ -8,10 +8,15 @@
8
8
  * - "append" mode: env header + parent system prompt + sub-agent context + config.systemPrompt
9
9
  * - "append" with empty systemPrompt: pure parent clone
10
10
  *
11
+ * Both modes prepend an `<active_agent name="${config.name}"/>` tag so downstream
12
+ * extensions (e.g. permission/policy systems) can resolve per-agent policy
13
+ * inside the child session by parsing the system prompt.
14
+ *
11
15
  * @param parentSystemPrompt The parent agent's effective system prompt (for append mode).
12
16
  * @param extras Optional extra sections to inject (memory, preloaded skills).
13
17
  */
14
18
  export function buildAgentPrompt(config, cwd, env, parentSystemPrompt, extras) {
19
+ const activeAgentTag = `<active_agent name="${config.name}"/>\n\n`;
15
20
  const envBlock = `# Environment
16
21
  Working directory: ${cwd}
17
22
  ${env.isGitRepo ? `Git repository: yes\nBranch: ${env.branch}` : "Not a git repository"}
@@ -44,14 +49,14 @@ You are operating as a sub-agent invoked to handle a specific task.
44
49
  const customSection = config.systemPrompt?.trim()
45
50
  ? `\n\n<agent_instructions>\n${config.systemPrompt}\n</agent_instructions>`
46
51
  : "";
47
- return envBlock + "\n\n<inherited_system_prompt>\n" + identity + "\n</inherited_system_prompt>\n\n" + bridge + customSection + extrasSuffix;
52
+ return activeAgentTag + envBlock + "\n\n<inherited_system_prompt>\n" + identity + "\n</inherited_system_prompt>\n\n" + bridge + customSection + extrasSuffix;
48
53
  }
49
54
  // "replace" mode — env header + the config's full system prompt
50
55
  const replaceHeader = `You are a pi coding agent sub-agent.
51
56
  You have been invoked to handle a specific task autonomously.
52
57
 
53
58
  ${envBlock}`;
54
- return replaceHeader + "\n\n" + config.systemPrompt + extrasSuffix;
59
+ return activeAgentTag + replaceHeader + "\n\n" + config.systemPrompt + extrasSuffix;
55
60
  }
56
61
  /** Fallback base prompt when parent system prompt is unavailable in append mode. */
57
62
  const genericBase = `# Role
@@ -1,19 +1,24 @@
1
1
  /**
2
- * skill-loader.ts — Preload specific skill files and inject their content into the system prompt.
2
+ * skill-loader.ts — Preload named skills.
3
3
  *
4
- * When skills is a string[], reads each named skill from .pi/skills/ or ~/.pi/skills/
5
- * and returns their content for injection into the agent's system prompt.
4
+ * Roots, in precedence order:
5
+ * - <cwd>/.pi/skills (project, Pi's standard)
6
+ * - <cwd>/.agents/skills (project, cross-tool Agent Skills spec — https://agentskills.io)
7
+ * - getAgentDir()/skills (user, default ~/.pi/agent/skills — Pi's standard)
8
+ * - ~/.agents/skills (user, cross-tool Agent Skills spec)
9
+ * - ~/.pi/skills (legacy global, pre-Pi)
10
+ *
11
+ * Layout per root:
12
+ * - <root>/<name>.md (flat file at the top level)
13
+ * - <root>/.../<name>/SKILL.md (directory skill, may be nested — Pi's standard)
14
+ *
15
+ * Recursion skips dotfile entries and node_modules. A directory that itself contains
16
+ * SKILL.md is a skill — we don't descend into it (Pi: skills don't nest).
17
+ *
18
+ * Symlinks are rejected for security (deviation from Pi, which follows them).
6
19
  */
7
20
  export interface PreloadedSkill {
8
21
  name: string;
9
22
  content: string;
10
23
  }
11
- /**
12
- * Attempt to load named skills from project and global skill directories.
13
- * Looks for: <dir>/<name>.md, <dir>/<name>.txt, <dir>/<name>
14
- *
15
- * @param skillNames List of skill names to preload.
16
- * @param cwd Working directory for project-level skills.
17
- * @returns Array of loaded skills (missing skills are skipped with a warning comment).
18
- */
19
24
  export declare function preloadSkills(skillNames: string[], cwd: string): PreloadedSkill[];
@@ -1,67 +1,93 @@
1
1
  /**
2
- * skill-loader.ts — Preload specific skill files and inject their content into the system prompt.
2
+ * skill-loader.ts — Preload named skills.
3
3
  *
4
- * When skills is a string[], reads each named skill from .pi/skills/ or ~/.pi/skills/
5
- * and returns their content for injection into the agent's system prompt.
4
+ * Roots, in precedence order:
5
+ * - <cwd>/.pi/skills (project, Pi's standard)
6
+ * - <cwd>/.agents/skills (project, cross-tool Agent Skills spec — https://agentskills.io)
7
+ * - getAgentDir()/skills (user, default ~/.pi/agent/skills — Pi's standard)
8
+ * - ~/.agents/skills (user, cross-tool Agent Skills spec)
9
+ * - ~/.pi/skills (legacy global, pre-Pi)
10
+ *
11
+ * Layout per root:
12
+ * - <root>/<name>.md (flat file at the top level)
13
+ * - <root>/.../<name>/SKILL.md (directory skill, may be nested — Pi's standard)
14
+ *
15
+ * Recursion skips dotfile entries and node_modules. A directory that itself contains
16
+ * SKILL.md is a skill — we don't descend into it (Pi: skills don't nest).
17
+ *
18
+ * Symlinks are rejected for security (deviation from Pi, which follows them).
6
19
  */
20
+ import { existsSync, readdirSync } from "node:fs";
7
21
  import { homedir } from "node:os";
8
22
  import { join } from "node:path";
9
- import { isUnsafeName, safeReadFile } from "./memory.js";
10
- /**
11
- * Attempt to load named skills from project and global skill directories.
12
- * Looks for: <dir>/<name>.md, <dir>/<name>.txt, <dir>/<name>
13
- *
14
- * @param skillNames List of skill names to preload.
15
- * @param cwd Working directory for project-level skills.
16
- * @returns Array of loaded skills (missing skills are skipped with a warning comment).
17
- */
23
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
24
+ import { isSymlink, isUnsafeName, safeReadFile } from "./memory.js";
18
25
  export function preloadSkills(skillNames, cwd) {
19
- const results = [];
20
- for (const name of skillNames) {
21
- // Unlike memory (which throws on unsafe names because it's part of agent setup),
22
- // skills are optional — skip gracefully to avoid blocking agent startup.
23
- if (isUnsafeName(name)) {
24
- results.push({ name, content: `(Skill "${name}" skipped: name contains path traversal characters)` });
25
- continue;
26
- }
27
- const content = findAndReadSkill(name, cwd);
28
- if (content !== undefined) {
29
- results.push({ name, content });
30
- }
31
- else {
32
- // Include a note about missing skills so the agent knows it was requested but not found
33
- results.push({ name, content: `(Skill "${name}" not found in .pi/skills/ or ~/.pi/skills/)` });
34
- }
35
- }
36
- return results;
26
+ return skillNames.map((name) => ({ name, content: loadSkillContent(name, cwd) }));
37
27
  }
38
- /**
39
- * Search for a skill file in project and global directories.
40
- * Project-level takes priority over global.
41
- */
42
- function findAndReadSkill(name, cwd) {
43
- const projectDir = join(cwd, ".pi", "skills");
44
- const globalDir = join(homedir(), ".pi", "skills");
45
- // Try project first, then global
46
- for (const dir of [projectDir, globalDir]) {
47
- const content = tryReadSkillFile(dir, name);
28
+ function loadSkillContent(name, cwd) {
29
+ if (isUnsafeName(name)) {
30
+ return `(Skill "${name}" skipped: name contains path traversal characters)`;
31
+ }
32
+ const roots = [
33
+ join(cwd, ".pi", "skills"), // project — Pi standard
34
+ join(cwd, ".agents", "skills"), // project — Agent Skills spec
35
+ join(getAgentDir(), "skills"), // user Pi standard
36
+ join(homedir(), ".agents", "skills"), // user — Agent Skills spec
37
+ join(homedir(), ".pi", "skills"), // legacy global, pre-Pi
38
+ ];
39
+ for (const root of roots) {
40
+ const content = findInRoot(root, name);
48
41
  if (content !== undefined)
49
42
  return content;
50
43
  }
51
- return undefined;
44
+ return `(Skill "${name}" not found in .pi/skills/, .agents/skills/, or global skill locations)`;
52
45
  }
53
- /**
54
- * Try to read a skill file from a directory.
55
- * Tries extensions in order: .md, .txt, (no extension)
56
- */
57
- function tryReadSkillFile(dir, name) {
58
- const extensions = [".md", ".txt", ""];
59
- for (const ext of extensions) {
60
- const path = join(dir, name + ext);
61
- // safeReadFile rejects symlinks to prevent reading arbitrary files
62
- const content = safeReadFile(path);
63
- if (content !== undefined)
64
- return content.trim();
46
+ function findInRoot(root, name) {
47
+ if (isSymlink(root))
48
+ return undefined; // reject symlinked roots entirely
49
+ const flat = safeReadFile(join(root, `${name}.md`))?.trim();
50
+ if (flat !== undefined)
51
+ return flat;
52
+ return findSkillDirectory(root, name);
53
+ }
54
+ /** BFS under `root` for a directory named `name` containing `SKILL.md`. Pi-conforming filters. */
55
+ function findSkillDirectory(root, name) {
56
+ if (!existsSync(root))
57
+ return undefined;
58
+ const queue = [root];
59
+ while (queue.length > 0) {
60
+ const current = queue.shift();
61
+ if (current === undefined)
62
+ continue;
63
+ let entries;
64
+ try {
65
+ entries = readdirSync(current, { withFileTypes: true });
66
+ }
67
+ catch {
68
+ continue;
69
+ }
70
+ // Deterministic byte-order traversal — locale-independent.
71
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
72
+ for (const entry of entries) {
73
+ if (!entry.isDirectory())
74
+ continue;
75
+ if (entry.name.startsWith(".") || entry.name === "node_modules")
76
+ continue;
77
+ // Symlinked dirs already filtered by entry.isDirectory() — Dirent uses lstat semantics.
78
+ const path = join(current, entry.name);
79
+ const skillMd = join(path, "SKILL.md");
80
+ const isSkillDir = existsSync(skillMd);
81
+ if (isSkillDir) {
82
+ if (entry.name === name) {
83
+ const content = safeReadFile(skillMd)?.trim();
84
+ if (content !== undefined)
85
+ return content;
86
+ }
87
+ continue; // Pi rule: skills don't nest — don't descend into a skill dir
88
+ }
89
+ queue.push(path);
90
+ }
65
91
  }
66
92
  return undefined;
67
93
  }
package/dist/types.d.ts CHANGED
@@ -91,6 +91,18 @@ export interface AgentRecord {
91
91
  lifetimeUsage: LifetimeUsage;
92
92
  /** Number of times this agent's session has compacted. Initialized to 0 at spawn. */
93
93
  compactionCount: number;
94
+ /** Resolved spawn params, captured for UI display. Fixed at spawn time. */
95
+ invocation?: AgentInvocation;
96
+ }
97
+ export interface AgentInvocation {
98
+ /** Short display name, e.g. "haiku" — only set when different from parent. */
99
+ modelName?: string;
100
+ thinking?: ThinkingLevel;
101
+ maxTurns?: number;
102
+ isolated?: boolean;
103
+ inheritContext?: boolean;
104
+ runInBackground?: boolean;
105
+ isolation?: IsolationMode;
94
106
  }
95
107
  /** Details attached to custom notification messages for visual rendering. */
96
108
  export interface NotificationDetails {
@@ -5,7 +5,7 @@
5
5
  * Uses the callback form of setWidget for themed rendering.
6
6
  */
7
7
  import type { AgentManager } from "../agent-manager.js";
8
- import type { SubagentType } from "../types.js";
8
+ import type { AgentInvocation, SubagentType } from "../types.js";
9
9
  import { type LifetimeUsage, type SessionLike } from "../usage.js";
10
10
  /** Braille spinner frames for animated running indicator. */
11
11
  export declare const SPINNER: string[];
@@ -84,6 +84,11 @@ export declare function formatDuration(startedAt: number, completedAt?: number):
84
84
  export declare function getDisplayName(type: SubagentType): string;
85
85
  /** Short label for prompt mode: "twin" for append, nothing for replace (the default). */
86
86
  export declare function getPromptModeLabel(type: SubagentType): string | undefined;
87
+ /** Mode label is not included — callers add it where they want it. */
88
+ export declare function buildInvocationTags(invocation: AgentInvocation | undefined): {
89
+ modelName?: string;
90
+ tags: string[];
91
+ };
87
92
  /** Build a human-readable activity string from currently-running tools or response text. */
88
93
  export declare function describeActivity(activeTools: Map<string, string>, responseText?: string): string;
89
94
  export declare class AgentWidget {
@@ -80,6 +80,25 @@ export function getPromptModeLabel(type) {
80
80
  const config = getConfig(type);
81
81
  return config.promptMode === "append" ? "twin" : undefined;
82
82
  }
83
+ /** Mode label is not included — callers add it where they want it. */
84
+ export function buildInvocationTags(invocation) {
85
+ const tags = [];
86
+ if (!invocation)
87
+ return { tags };
88
+ if (invocation.thinking)
89
+ tags.push(`thinking: ${invocation.thinking}`);
90
+ if (invocation.isolated)
91
+ tags.push("isolated");
92
+ if (invocation.isolation === "worktree")
93
+ tags.push("worktree");
94
+ if (invocation.inheritContext)
95
+ tags.push("inherit context");
96
+ if (invocation.runInBackground)
97
+ tags.push("background");
98
+ if (invocation.maxTurns != null)
99
+ tags.push(`max turns: ${invocation.maxTurns}`);
100
+ return { modelName: invocation.modelName, tags };
101
+ }
83
102
  /** Truncate text to a single line, max `len` chars. */
84
103
  function truncateLine(text, len = 60) {
85
104
  const line = text.split("\n").find(l => l.trim())?.trim() ?? "";
@@ -9,6 +9,8 @@ import { type Component, type TUI } from "@mariozechner/pi-tui";
9
9
  import type { AgentRecord } from "../types.js";
10
10
  import type { Theme } from "./agent-widget.js";
11
11
  import { type AgentActivity } from "./agent-widget.js";
12
+ /** Height ceiling shared by the overlay's `maxHeight` and the viewer's internal viewport cap. */
13
+ export declare const VIEWPORT_HEIGHT_PCT = 70;
12
14
  export declare class ConversationViewer implements Component {
13
15
  private tui;
14
16
  private session;
@@ -27,5 +29,7 @@ export declare class ConversationViewer implements Component {
27
29
  invalidate(): void;
28
30
  dispose(): void;
29
31
  private viewportHeight;
32
+ private chromeLines;
33
+ private invocationLine;
30
34
  private buildContentLines;
31
35
  }
@@ -7,10 +7,12 @@
7
7
  import { matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
8
8
  import { extractText } from "../context.js";
9
9
  import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
10
- import { describeActivity, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
11
- /** Lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
12
- const CHROME_LINES = 6;
10
+ import { buildInvocationTags, describeActivity, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
11
+ /** Base lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
12
+ const CHROME_LINES_BASE = 6;
13
13
  const MIN_VIEWPORT = 3;
14
+ /** Height ceiling shared by the overlay's `maxHeight` and the viewer's internal viewport cap. */
15
+ export const VIEWPORT_HEIGHT_PCT = 70;
14
16
  export class ConversationViewer {
15
17
  tui;
16
18
  session;
@@ -53,11 +55,11 @@ export class ConversationViewer {
53
55
  this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
54
56
  this.autoScroll = this.scrollOffset >= maxScroll;
55
57
  }
56
- else if (matchesKey(data, "pageUp")) {
58
+ else if (matchesKey(data, "pageUp") || matchesKey(data, "shift+up")) {
57
59
  this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
58
60
  this.autoScroll = false;
59
61
  }
60
- else if (matchesKey(data, "pageDown")) {
62
+ else if (matchesKey(data, "pageDown") || matchesKey(data, "shift+down")) {
61
63
  this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
62
64
  this.autoScroll = this.scrollOffset >= maxScroll;
63
65
  }
@@ -108,6 +110,9 @@ export class ConversationViewer {
108
110
  headerParts.push(formatSessionTokens(tokens, percent, th, this.record.compactionCount));
109
111
  }
110
112
  lines.push(row(`${statusIcon} ${th.bold(name)}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${th.fg("dim", headerParts.join(" · "))}`));
113
+ const invocationLine = this.invocationLine();
114
+ if (invocationLine)
115
+ lines.push(row(invocationLine));
111
116
  lines.push(hrMid);
112
117
  // Content area — rebuild every render (live data, no cache needed)
113
118
  const contentLines = this.buildContentLines(innerW);
@@ -127,7 +132,7 @@ export class ConversationViewer {
127
132
  ? "100%"
128
133
  : `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
129
134
  const footerLeft = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
130
- const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn · Esc close");
135
+ const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close");
131
136
  const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
132
137
  lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
133
138
  lines.push(hrBot);
@@ -143,7 +148,20 @@ export class ConversationViewer {
143
148
  }
144
149
  // ---- Private ----
145
150
  viewportHeight() {
146
- return Math.max(MIN_VIEWPORT, this.tui.terminal.rows - CHROME_LINES);
151
+ // Cap mirrors the overlay's maxHeight — otherwise the viewer would render
152
+ // more lines than the overlay shows and clip the footer.
153
+ const maxRows = Math.floor((this.tui.terminal.rows * VIEWPORT_HEIGHT_PCT) / 100);
154
+ return Math.max(MIN_VIEWPORT, maxRows - this.chromeLines());
155
+ }
156
+ chromeLines() {
157
+ return CHROME_LINES_BASE + (this.invocationLine() ? 1 : 0);
158
+ }
159
+ invocationLine() {
160
+ const { modelName, tags } = buildInvocationTags(this.record.invocation);
161
+ const parts = modelName ? [modelName, ...tags] : tags;
162
+ if (parts.length === 0)
163
+ return undefined;
164
+ return this.theme.fg("dim", ` ↳ ${parts.join(" · ")}`);
147
165
  }
148
166
  buildContentLines(width) {
149
167
  if (width <= 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tintinweb/pi-subagents",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "A pi extension extension that brings smart Claude Code-style autonomous sub-agents to pi.",
5
5
  "author": "tintinweb",
6
6
  "license": "MIT",
@@ -10,7 +10,7 @@ import { randomUUID } from "node:crypto";
10
10
  import type { Model } from "@mariozechner/pi-ai";
11
11
  import type { AgentSession, ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
12
12
  import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
13
- import type { AgentRecord, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
13
+ import type { AgentInvocation, AgentRecord, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
14
14
  import { addUsage } from "./usage.js";
15
15
  import { cleanupWorktree, createWorktree, pruneWorktrees, } from "./worktree.js";
16
16
 
@@ -46,6 +46,8 @@ interface SpawnOptions {
46
46
  bypassQueue?: boolean;
47
47
  /** Isolation mode — "worktree" creates a temp git worktree for the agent. */
48
48
  isolation?: IsolationMode;
49
+ /** Resolved invocation snapshot captured for UI display. */
50
+ invocation?: AgentInvocation;
49
51
  /** Parent abort signal — when aborted, the subagent is also stopped. */
50
52
  signal?: AbortSignal;
51
53
  /** Called on tool start/end with activity info (for streaming progress to UI). */
@@ -124,6 +126,7 @@ export class AgentManager {
124
126
  abortController,
125
127
  lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
126
128
  compactionCount: 0,
129
+ invocation: options.invocation,
127
130
  };
128
131
  this.agents.set(id, record);
129
132
 
@@ -180,6 +183,7 @@ export class AgentManager {
180
183
 
181
184
  const promise = runAgent(ctx, type, prompt, {
182
185
  pi,
186
+ agentId: id,
183
187
  model: options.model,
184
188
  maxTurns: options.maxTurns,
185
189
  isolated: options.isolated,
@@ -88,6 +88,8 @@ export interface ToolActivity {
88
88
  export interface RunOptions {
89
89
  /** ExtensionAPI instance — used for pi.exec() instead of execSync. */
90
90
  pi: ExtensionAPI;
91
+ /** Manager-assigned id; suffixes session name to disambiguate parallel spawns (e.g. `Explore#a1b2c3d4`). */
92
+ agentId?: string;
91
93
  model?: Model<any>;
92
94
  maxTurns?: number;
93
95
  signal?: AbortSignal;
@@ -280,6 +282,11 @@ export async function runAgent(
280
282
 
281
283
  const { session } = await createAgentSession(sessionOpts);
282
284
 
285
+ const baseSessionName = agentConfig?.name ?? type;
286
+ session.setSessionName(
287
+ options.agentId ? `${baseSessionName}#${options.agentId.slice(0, 8)}` : baseSessionName,
288
+ );
289
+
283
290
  // Build disallowed tools set from agent config
284
291
  const disallowedSet = agentConfig?.disallowedTools
285
292
  ? new Set(agentConfig.disallowedTools)
@@ -9,6 +9,8 @@
9
9
  * error → { success: false, error: string }
10
10
  */
11
11
 
12
+ import { type ModelRegistry, resolveModel } from "./model-resolver.js";
13
+
12
14
  /** Minimal event bus interface needed by the RPC handlers. */
13
15
  export interface EventBus {
14
16
  on(event: string, handler: (data: unknown) => void): () => void;
@@ -81,7 +83,32 @@ export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
81
83
  events, "subagents:rpc:spawn", ({ type, prompt, options }) => {
82
84
  const ctx = getCtx();
83
85
  if (!ctx) throw new Error("No active session");
84
- return { id: manager.spawn(pi, ctx, type, prompt, options ?? {}) };
86
+
87
+ // Cross-extension RPC callers (e.g. pi-tasks TaskExecute) naturally
88
+ // forward serializable values, so options.model can be a string like
89
+ // "openai-codex/gpt-5.5". Resolve it to a real Model instance here
90
+ // — same pattern the scheduler path already uses — so the spawned
91
+ // agent's auth lookup doesn't crash with "No API key found for
92
+ // undefined".
93
+ let normalizedOptions = options ?? {};
94
+ if (typeof normalizedOptions.model === "string") {
95
+ const registry = (ctx as { modelRegistry?: ModelRegistry }).modelRegistry;
96
+ if (!registry) {
97
+ throw new Error(
98
+ `Model override "${normalizedOptions.model}" provided but ctx.modelRegistry is unavailable`,
99
+ );
100
+ }
101
+ const resolved = resolveModel(normalizedOptions.model, registry);
102
+ if (typeof resolved === "string") {
103
+ // resolveModel returns a human-readable error string when the
104
+ // input doesn't match any available model. Surface it instead of
105
+ // silently falling back so the caller sees the auth/typo issue.
106
+ throw new Error(resolved);
107
+ }
108
+ normalizedOptions = { ...normalizedOptions, model: resolved };
109
+ }
110
+
111
+ return { id: manager.spawn(pi, ctx, type, prompt, normalizedOptions) };
85
112
  },
86
113
  );
87
114
 
package/src/index.ts CHANGED
@@ -27,11 +27,12 @@ import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./o
27
27
  import { SubagentScheduler } from "./schedule.js";
28
28
  import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
29
29
  import { applyAndEmitLoaded, type SubagentsSettings, saveAndEmitChanged } from "./settings.js";
30
- import { type AgentConfig, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType } from "./types.js";
30
+ import { type AgentConfig, type AgentInvocation, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType } from "./types.js";
31
31
  import {
32
32
  type AgentActivity,
33
33
  type AgentDetails,
34
34
  AgentWidget,
35
+ buildInvocationTags,
35
36
  describeActivity,
36
37
  formatDuration,
37
38
  formatMs,
@@ -847,25 +848,32 @@ Guidelines:
847
848
  const isolated = resolvedConfig.isolated;
848
849
  const isolation = resolvedConfig.isolation;
849
850
 
850
- // Build display tags for non-default config
851
851
  const parentModelId = ctx.model?.id;
852
852
  const effectiveModelId = model?.id;
853
- const agentModelName = effectiveModelId && effectiveModelId !== parentModelId
853
+ const modelName = effectiveModelId && effectiveModelId !== parentModelId
854
854
  ? (model?.name ?? effectiveModelId).replace(/^Claude\s+/i, "").toLowerCase()
855
855
  : undefined;
856
- const agentTags: string[] = [];
857
- const modeLabel = getPromptModeLabel(subagentType);
858
- if (modeLabel) agentTags.push(modeLabel);
859
- if (thinking) agentTags.push(`thinking: ${thinking}`);
860
- if (isolated) agentTags.push("isolated");
861
- if (isolation === "worktree") agentTags.push("worktree");
862
856
  const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? getDefaultMaxTurns());
863
- // Shared base fields for all AgentDetails in this call
857
+ const agentInvocation: AgentInvocation = {
858
+ modelName,
859
+ thinking,
860
+ // Explicit value only — the default fallback would just add noise.
861
+ // Normalize so `0` (unlimited) doesn't surface as a misleading "max turns: 0".
862
+ maxTurns: normalizeMaxTurns(resolvedConfig.maxTurns),
863
+ isolated,
864
+ inheritContext,
865
+ runInBackground,
866
+ isolation,
867
+ };
868
+ // Tool-result render shows the mode label too; viewer's header already does.
869
+ const modeLabel = getPromptModeLabel(subagentType);
870
+ const { tags: invocationTags } = buildInvocationTags(agentInvocation);
871
+ const agentTags = modeLabel ? [modeLabel, ...invocationTags] : invocationTags;
864
872
  const detailBase = {
865
873
  displayName,
866
874
  description: params.description,
867
875
  subagentType,
868
- modelName: agentModelName,
876
+ modelName,
869
877
  tags: agentTags.length > 0 ? agentTags : undefined,
870
878
  };
871
879
 
@@ -956,6 +964,7 @@ Guidelines:
956
964
  thinkingLevel: thinking,
957
965
  isBackground: true,
958
966
  isolation,
967
+ invocation: agentInvocation,
959
968
  ...bgCallbacks,
960
969
  });
961
970
  } catch (err) {
@@ -1068,6 +1077,7 @@ Guidelines:
1068
1077
  inheritContext,
1069
1078
  thinkingLevel: thinking,
1070
1079
  isolation,
1080
+ invocation: agentInvocation,
1071
1081
  signal,
1072
1082
  ...fgCallbacks,
1073
1083
  });
@@ -1406,7 +1416,7 @@ Guidelines:
1406
1416
  return;
1407
1417
  }
1408
1418
 
1409
- const { ConversationViewer } = await import("./ui/conversation-viewer.js");
1419
+ const { ConversationViewer, VIEWPORT_HEIGHT_PCT } = await import("./ui/conversation-viewer.js");
1410
1420
  const session = record.session;
1411
1421
  const activity = agentActivity.get(record.id);
1412
1422
 
@@ -1416,7 +1426,7 @@ Guidelines:
1416
1426
  },
1417
1427
  {
1418
1428
  overlay: true,
1419
- overlayOptions: { anchor: "center", width: "90%" },
1429
+ overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
1420
1430
  },
1421
1431
  );
1422
1432
  }
package/src/prompts.ts CHANGED
@@ -19,6 +19,10 @@ export interface PromptExtras {
19
19
  * - "append" mode: env header + parent system prompt + sub-agent context + config.systemPrompt
20
20
  * - "append" with empty systemPrompt: pure parent clone
21
21
  *
22
+ * Both modes prepend an `<active_agent name="${config.name}"/>` tag so downstream
23
+ * extensions (e.g. permission/policy systems) can resolve per-agent policy
24
+ * inside the child session by parsing the system prompt.
25
+ *
22
26
  * @param parentSystemPrompt The parent agent's effective system prompt (for append mode).
23
27
  * @param extras Optional extra sections to inject (memory, preloaded skills).
24
28
  */
@@ -29,6 +33,8 @@ export function buildAgentPrompt(
29
33
  parentSystemPrompt?: string,
30
34
  extras?: PromptExtras,
31
35
  ): string {
36
+ const activeAgentTag = `<active_agent name="${config.name}"/>\n\n`;
37
+
32
38
  const envBlock = `# Environment
33
39
  Working directory: ${cwd}
34
40
  ${env.isGitRepo ? `Git repository: yes\nBranch: ${env.branch}` : "Not a git repository"}
@@ -66,7 +72,7 @@ You are operating as a sub-agent invoked to handle a specific task.
66
72
  ? `\n\n<agent_instructions>\n${config.systemPrompt}\n</agent_instructions>`
67
73
  : "";
68
74
 
69
- return envBlock + "\n\n<inherited_system_prompt>\n" + identity + "\n</inherited_system_prompt>\n\n" + bridge + customSection + extrasSuffix;
75
+ return activeAgentTag + envBlock + "\n\n<inherited_system_prompt>\n" + identity + "\n</inherited_system_prompt>\n\n" + bridge + customSection + extrasSuffix;
70
76
  }
71
77
 
72
78
  // "replace" mode — env header + the config's full system prompt
@@ -75,7 +81,7 @@ You have been invoked to handle a specific task autonomously.
75
81
 
76
82
  ${envBlock}`;
77
83
 
78
- return replaceHeader + "\n\n" + config.systemPrompt + extrasSuffix;
84
+ return activeAgentTag + replaceHeader + "\n\n" + config.systemPrompt + extrasSuffix;
79
85
  }
80
86
 
81
87
  /** Fallback base prompt when parent system prompt is unavailable in append mode. */
@@ -1,79 +1,102 @@
1
1
  /**
2
- * skill-loader.ts — Preload specific skill files and inject their content into the system prompt.
2
+ * skill-loader.ts — Preload named skills.
3
3
  *
4
- * When skills is a string[], reads each named skill from .pi/skills/ or ~/.pi/skills/
5
- * and returns their content for injection into the agent's system prompt.
4
+ * Roots, in precedence order:
5
+ * - <cwd>/.pi/skills (project, Pi's standard)
6
+ * - <cwd>/.agents/skills (project, cross-tool Agent Skills spec — https://agentskills.io)
7
+ * - getAgentDir()/skills (user, default ~/.pi/agent/skills — Pi's standard)
8
+ * - ~/.agents/skills (user, cross-tool Agent Skills spec)
9
+ * - ~/.pi/skills (legacy global, pre-Pi)
10
+ *
11
+ * Layout per root:
12
+ * - <root>/<name>.md (flat file at the top level)
13
+ * - <root>/.../<name>/SKILL.md (directory skill, may be nested — Pi's standard)
14
+ *
15
+ * Recursion skips dotfile entries and node_modules. A directory that itself contains
16
+ * SKILL.md is a skill — we don't descend into it (Pi: skills don't nest).
17
+ *
18
+ * Symlinks are rejected for security (deviation from Pi, which follows them).
6
19
  */
7
20
 
21
+ import type { Dirent } from "node:fs";
22
+ import { existsSync, readdirSync } from "node:fs";
8
23
  import { homedir } from "node:os";
9
24
  import { join } from "node:path";
10
- import { isUnsafeName, safeReadFile } from "./memory.js";
25
+ import { getAgentDir } from "@mariozechner/pi-coding-agent";
26
+ import { isSymlink, isUnsafeName, safeReadFile } from "./memory.js";
11
27
 
12
28
  export interface PreloadedSkill {
13
29
  name: string;
14
30
  content: string;
15
31
  }
16
32
 
17
- /**
18
- * Attempt to load named skills from project and global skill directories.
19
- * Looks for: <dir>/<name>.md, <dir>/<name>.txt, <dir>/<name>
20
- *
21
- * @param skillNames List of skill names to preload.
22
- * @param cwd Working directory for project-level skills.
23
- * @returns Array of loaded skills (missing skills are skipped with a warning comment).
24
- */
25
33
  export function preloadSkills(skillNames: string[], cwd: string): PreloadedSkill[] {
26
- const results: PreloadedSkill[] = [];
34
+ return skillNames.map((name) => ({ name, content: loadSkillContent(name, cwd) }));
35
+ }
27
36
 
28
- for (const name of skillNames) {
29
- // Unlike memory (which throws on unsafe names because it's part of agent setup),
30
- // skills are optional skip gracefully to avoid blocking agent startup.
31
- if (isUnsafeName(name)) {
32
- results.push({ name, content: `(Skill "${name}" skipped: name contains path traversal characters)` });
33
- continue;
34
- }
35
- const content = findAndReadSkill(name, cwd);
36
- if (content !== undefined) {
37
- results.push({ name, content });
38
- } else {
39
- // Include a note about missing skills so the agent knows it was requested but not found
40
- results.push({ name, content: `(Skill "${name}" not found in .pi/skills/ or ~/.pi/skills/)` });
41
- }
37
+ function loadSkillContent(name: string, cwd: string): string {
38
+ if (isUnsafeName(name)) {
39
+ return `(Skill "${name}" skipped: name contains path traversal characters)`;
42
40
  }
41
+ const roots = [
42
+ join(cwd, ".pi", "skills"), // project — Pi standard
43
+ join(cwd, ".agents", "skills"), // project — Agent Skills spec
44
+ join(getAgentDir(), "skills"), // user — Pi standard
45
+ join(homedir(), ".agents", "skills"), // user — Agent Skills spec
46
+ join(homedir(), ".pi", "skills"), // legacy global, pre-Pi
47
+ ];
48
+ for (const root of roots) {
49
+ const content = findInRoot(root, name);
50
+ if (content !== undefined) return content;
51
+ }
52
+ return `(Skill "${name}" not found in .pi/skills/, .agents/skills/, or global skill locations)`;
53
+ }
43
54
 
44
- return results;
55
+ function findInRoot(root: string, name: string): string | undefined {
56
+ if (isSymlink(root)) return undefined; // reject symlinked roots entirely
57
+ const flat = safeReadFile(join(root, `${name}.md`))?.trim();
58
+ if (flat !== undefined) return flat;
59
+ return findSkillDirectory(root, name);
45
60
  }
46
61
 
47
- /**
48
- * Search for a skill file in project and global directories.
49
- * Project-level takes priority over global.
50
- */
51
- function findAndReadSkill(name: string, cwd: string): string | undefined {
52
- const projectDir = join(cwd, ".pi", "skills");
53
- const globalDir = join(homedir(), ".pi", "skills");
62
+ /** BFS under `root` for a directory named `name` containing `SKILL.md`. Pi-conforming filters. */
63
+ function findSkillDirectory(root: string, name: string): string | undefined {
64
+ if (!existsSync(root)) return undefined;
65
+ const queue: string[] = [root];
54
66
 
55
- // Try project first, then global
56
- for (const dir of [projectDir, globalDir]) {
57
- const content = tryReadSkillFile(dir, name);
58
- if (content !== undefined) return content;
59
- }
67
+ while (queue.length > 0) {
68
+ const current = queue.shift();
69
+ if (current === undefined) continue;
60
70
 
61
- return undefined;
62
- }
71
+ let entries: Dirent<string>[];
72
+ try {
73
+ entries = readdirSync(current, { withFileTypes: true });
74
+ } catch {
75
+ continue;
76
+ }
63
77
 
64
- /**
65
- * Try to read a skill file from a directory.
66
- * Tries extensions in order: .md, .txt, (no extension)
67
- */
68
- function tryReadSkillFile(dir: string, name: string): string | undefined {
69
- const extensions = [".md", ".txt", ""];
78
+ // Deterministic byte-order traversal — locale-independent.
79
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
70
80
 
71
- for (const ext of extensions) {
72
- const path = join(dir, name + ext);
73
- // safeReadFile rejects symlinks to prevent reading arbitrary files
74
- const content = safeReadFile(path);
75
- if (content !== undefined) return content.trim();
76
- }
81
+ for (const entry of entries) {
82
+ if (!entry.isDirectory()) continue;
83
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
84
+
85
+ // Symlinked dirs already filtered by entry.isDirectory() — Dirent uses lstat semantics.
86
+ const path = join(current, entry.name);
87
+ const skillMd = join(path, "SKILL.md");
88
+ const isSkillDir = existsSync(skillMd);
77
89
 
90
+ if (isSkillDir) {
91
+ if (entry.name === name) {
92
+ const content = safeReadFile(skillMd)?.trim();
93
+ if (content !== undefined) return content;
94
+ }
95
+ continue; // Pi rule: skills don't nest — don't descend into a skill dir
96
+ }
97
+
98
+ queue.push(path);
99
+ }
100
+ }
78
101
  return undefined;
79
102
  }
package/src/types.ts CHANGED
@@ -94,6 +94,19 @@ export interface AgentRecord {
94
94
  lifetimeUsage: LifetimeUsage;
95
95
  /** Number of times this agent's session has compacted. Initialized to 0 at spawn. */
96
96
  compactionCount: number;
97
+ /** Resolved spawn params, captured for UI display. Fixed at spawn time. */
98
+ invocation?: AgentInvocation;
99
+ }
100
+
101
+ export interface AgentInvocation {
102
+ /** Short display name, e.g. "haiku" — only set when different from parent. */
103
+ modelName?: string;
104
+ thinking?: ThinkingLevel;
105
+ maxTurns?: number;
106
+ isolated?: boolean;
107
+ inheritContext?: boolean;
108
+ runInBackground?: boolean;
109
+ isolation?: IsolationMode;
97
110
  }
98
111
 
99
112
  /** Details attached to custom notification messages for visual rendering. */
@@ -8,7 +8,7 @@
8
8
  import { truncateToWidth } from "@mariozechner/pi-tui";
9
9
  import type { AgentManager } from "../agent-manager.js";
10
10
  import { getConfig } from "../agent-types.js";
11
- import type { SubagentType } from "../types.js";
11
+ import type { AgentInvocation, SubagentType } from "../types.js";
12
12
  import { getLifetimeTotal, getSessionContextPercent, type LifetimeUsage, type SessionLike } from "../usage.js";
13
13
 
14
14
  // ---- Constants ----
@@ -153,6 +153,21 @@ export function getPromptModeLabel(type: SubagentType): string | undefined {
153
153
  return config.promptMode === "append" ? "twin" : undefined;
154
154
  }
155
155
 
156
+ /** Mode label is not included — callers add it where they want it. */
157
+ export function buildInvocationTags(
158
+ invocation: AgentInvocation | undefined,
159
+ ): { modelName?: string; tags: string[] } {
160
+ const tags: string[] = [];
161
+ if (!invocation) return { tags };
162
+ if (invocation.thinking) tags.push(`thinking: ${invocation.thinking}`);
163
+ if (invocation.isolated) tags.push("isolated");
164
+ if (invocation.isolation === "worktree") tags.push("worktree");
165
+ if (invocation.inheritContext) tags.push("inherit context");
166
+ if (invocation.runInBackground) tags.push("background");
167
+ if (invocation.maxTurns != null) tags.push(`max turns: ${invocation.maxTurns}`);
168
+ return { modelName: invocation.modelName, tags };
169
+ }
170
+
156
171
  /** Truncate text to a single line, max `len` chars. */
157
172
  function truncateLine(text: string, len = 60): string {
158
173
  const line = text.split("\n").find(l => l.trim())?.trim() ?? "";
@@ -11,11 +11,13 @@ import { extractText } from "../context.js";
11
11
  import type { AgentRecord } from "../types.js";
12
12
  import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
13
13
  import type { Theme } from "./agent-widget.js";
14
- import { type AgentActivity, describeActivity, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
14
+ import { type AgentActivity, buildInvocationTags, describeActivity, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
15
15
 
16
- /** Lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
17
- const CHROME_LINES = 6;
16
+ /** Base lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
17
+ const CHROME_LINES_BASE = 6;
18
18
  const MIN_VIEWPORT = 3;
19
+ /** Height ceiling shared by the overlay's `maxHeight` and the viewer's internal viewport cap. */
20
+ export const VIEWPORT_HEIGHT_PCT = 70;
19
21
 
20
22
  export class ConversationViewer implements Component {
21
23
  private scrollOffset = 0;
@@ -55,10 +57,10 @@ export class ConversationViewer implements Component {
55
57
  } else if (matchesKey(data, "down") || matchesKey(data, "j")) {
56
58
  this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
57
59
  this.autoScroll = this.scrollOffset >= maxScroll;
58
- } else if (matchesKey(data, "pageUp")) {
60
+ } else if (matchesKey(data, "pageUp") || matchesKey(data, "shift+up")) {
59
61
  this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
60
62
  this.autoScroll = false;
61
- } else if (matchesKey(data, "pageDown")) {
63
+ } else if (matchesKey(data, "pageDown") || matchesKey(data, "shift+down")) {
62
64
  this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
63
65
  this.autoScroll = this.scrollOffset >= maxScroll;
64
66
  } else if (matchesKey(data, "home")) {
@@ -113,6 +115,8 @@ export class ConversationViewer implements Component {
113
115
  lines.push(row(
114
116
  `${statusIcon} ${th.bold(name)}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${th.fg("dim", headerParts.join(" · "))}`,
115
117
  ));
118
+ const invocationLine = this.invocationLine();
119
+ if (invocationLine) lines.push(row(invocationLine));
116
120
  lines.push(hrMid);
117
121
 
118
122
  // Content area — rebuild every render (live data, no cache needed)
@@ -137,7 +141,7 @@ export class ConversationViewer implements Component {
137
141
  ? "100%"
138
142
  : `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
139
143
  const footerLeft = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
140
- const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn · Esc close");
144
+ const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close");
141
145
  const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
142
146
  lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
143
147
  lines.push(hrBot);
@@ -158,7 +162,21 @@ export class ConversationViewer implements Component {
158
162
  // ---- Private ----
159
163
 
160
164
  private viewportHeight(): number {
161
- return Math.max(MIN_VIEWPORT, this.tui.terminal.rows - CHROME_LINES);
165
+ // Cap mirrors the overlay's maxHeight — otherwise the viewer would render
166
+ // more lines than the overlay shows and clip the footer.
167
+ const maxRows = Math.floor((this.tui.terminal.rows * VIEWPORT_HEIGHT_PCT) / 100);
168
+ return Math.max(MIN_VIEWPORT, maxRows - this.chromeLines());
169
+ }
170
+
171
+ private chromeLines(): number {
172
+ return CHROME_LINES_BASE + (this.invocationLine() ? 1 : 0);
173
+ }
174
+
175
+ private invocationLine(): string | undefined {
176
+ const { modelName, tags } = buildInvocationTags(this.record.invocation);
177
+ const parts = modelName ? [modelName, ...tags] : tags;
178
+ if (parts.length === 0) return undefined;
179
+ return this.theme.fg("dim", ` ↳ ${parts.join(" · ")}`);
162
180
  }
163
181
 
164
182
  private buildContentLines(width: number): string[] {