@tintinweb/pi-subagents 0.7.0 → 0.7.2

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,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.2] - 2026-05-12
11
+
12
+ > **Heads-up — behavior changes in skill preloading:**
13
+ > - **`.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`.
14
+
15
+ ### Added
16
+ - **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).
17
+ - **Five discovery roots**, checked in precedence order:
18
+ - `<cwd>/.pi/skills/` (project, Pi)
19
+ - `<cwd>/.agents/skills/` (project, [Agent Skills spec](https://agentskills.io/integrate-skills))
20
+ - `$PI_CODING_AGENT_DIR/skills/` — default `~/.pi/agent/skills/` (user, Pi)
21
+ - `~/.agents/skills/` (user, Agent Skills spec)
22
+ - `~/.pi/skills/` (legacy global, kept for backward compatibility)
23
+ - **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).
24
+ - **Deterministic traversal order** — entries are sorted byte-order so collisions resolve identically across filesystems. Pi's iteration order is `readdirSync`-dependent.
25
+ - **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).
26
+ - **`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.
27
+
28
+ ### Changed
29
+ - **`.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`.
30
+ - **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.
31
+
32
+ ## [0.7.1] - 2026-05-07
33
+
34
+ > **Heads-up — behavior change:**
35
+ > - `isolation: "worktree"` now fails loud (returns an error) instead of silently falling back to the main tree. Affects users running pi in a non-git directory or a fresh repo with no commits.
36
+
37
+ ### Changed
38
+ - **`isolation: "worktree"` now fails loud instead of silently falling back.** Previously when `createWorktree` returned undefined (not a git repo, no commits yet, or `git worktree add` failed), the agent ran in the main `cwd` with a `[WARNING: ...]` block prepended to its prompt — visible only to the LLM, never surfaced to the caller. Now the failure throws a structured error that propagates back to the `Agent` tool response; no agent record is created. Failed scheduled fires are recorded as `lastStatus: "error"` with the reason in the `subagents:scheduled` error event. Queued background spawns whose worktree creation fails when they dequeue are marked terminal-error and don't block the rest of the queue.
39
+
40
+ ### Fixed
41
+
42
+ - **Headless `pi --print` runs no longer hang or crash after background
43
+ subagents complete.** Cleanup timers no longer keep the process alive, and
44
+ stale completion notifications are treated as best-effort shutdown side
45
+ effects.
46
+
10
47
  ## [0.7.0] - 2026-05-04
11
48
 
12
49
  > **Heads-up — behavior changes:**
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 |
@@ -453,11 +453,11 @@ The agent gets a full, isolated copy of the repository. On completion:
453
453
  - **No changes:** worktree is cleaned up automatically
454
454
  - **Changes made:** changes are committed to a new branch (`pi-agent-<id>`) and returned in the result
455
455
 
456
- If the worktree cannot be created (not a git repo, no commits), the agent falls back to the main working directory with a warning.
456
+ If the worktree cannot be created (not a git repo, no commits, or `git worktree add` fails), the `Agent` tool returns a clear error instead of running unisolated — `isolation: "worktree"` is a strict guarantee, not a hint. Initialize git and commit at least once, or omit `isolation`.
457
457
 
458
458
  ## Skill Preloading
459
459
 
460
- Skills can be preloaded as named files from `.pi/skills/` or `~/.pi/skills/`:
460
+ Skills can be preloaded by name and injected into the agent's system prompt:
461
461
 
462
462
  ```yaml
463
463
  ---
@@ -465,7 +465,25 @@ skills: api-conventions, error-handling
465
465
  ---
466
466
  ```
467
467
 
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.
468
+ **Discovery roots** (checked in this order, first match wins):
469
+
470
+ | Scope | Path | Source |
471
+ |---|---|---|
472
+ | Project | `<cwd>/.pi/skills/` | Pi-standard |
473
+ | Project | `<cwd>/.agents/skills/` | [Agent Skills spec](https://agentskills.io/integrate-skills) |
474
+ | User | `$PI_CODING_AGENT_DIR/skills/` (default `~/.pi/agent/skills/`) | Pi-standard |
475
+ | User | `~/.agents/skills/` | [Agent Skills spec](https://agentskills.io/integrate-skills) |
476
+ | User | `~/.pi/skills/` | Legacy (pre-Pi) |
477
+
478
+ **Per root, a skill named `foo` resolves to the first of:**
479
+
480
+ - `<root>/foo.md` — flat file at the top level
481
+ - `<root>/foo/SKILL.md` — directory skill (top-level)
482
+ - `<root>/*/.../foo/SKILL.md` — directory skill, found by recursive descent
483
+
484
+ 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.
485
+
486
+ **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
487
 
470
488
  ## Tool Denylist
471
489
 
@@ -494,7 +512,7 @@ src/
494
512
  group-join.ts # Group join manager: batched completion notifications with timeout
495
513
  custom-agents.ts # Load user-defined agents from .pi/agents/*.md
496
514
  memory.ts # Persistent agent memory (resolve, read, build prompt blocks)
497
- skill-loader.ts # Preload skill files from .pi/skills/
515
+ skill-loader.ts # Preload skills (Pi-standard + Agent Skills spec layouts)
498
516
  output-file.ts # Streaming output file transcripts for agent sessions
499
517
  worktree.ts # Git worktree isolation (create, cleanup, prune)
500
518
  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). */
@@ -29,6 +29,7 @@ export class AgentManager {
29
29
  this.maxConcurrent = maxConcurrent;
30
30
  // Cleanup completed agents after 10 minutes (but keep sessions for resume)
31
31
  this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
32
+ this.cleanupInterval.unref();
32
33
  }
33
34
  /** Update the max concurrent background agents limit. */
34
35
  setMaxConcurrent(n) {
@@ -56,6 +57,7 @@ export class AgentManager {
56
57
  abortController,
57
58
  lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
58
59
  compactionCount: 0,
60
+ invocation: options.invocation,
59
61
  };
60
62
  this.agents.set(id, record);
61
63
  const args = { pi, ctx, type, prompt, options };
@@ -64,11 +66,32 @@ export class AgentManager {
64
66
  this.queue.push({ id, args });
65
67
  return id;
66
68
  }
67
- this.startAgent(id, record, args);
69
+ // startAgent can throw (e.g. strict worktree-isolation failure) — clean
70
+ // up the record so callers don't see an orphan in `listAgents()`.
71
+ try {
72
+ this.startAgent(id, record, args);
73
+ }
74
+ catch (err) {
75
+ this.agents.delete(id);
76
+ throw err;
77
+ }
68
78
  return id;
69
79
  }
70
80
  /** Actually start an agent (called immediately or from queue drain). */
71
81
  startAgent(id, record, { pi, ctx, type, prompt, options }) {
82
+ // Worktree isolation: try to create a temporary git worktree. Strict —
83
+ // fail loud if not possible (no silent fallback to main tree). Done
84
+ // BEFORE state mutation so a throw doesn't leave the record half-running.
85
+ let worktreeCwd;
86
+ if (options.isolation === "worktree") {
87
+ const wt = createWorktree(ctx.cwd, id);
88
+ if (!wt) {
89
+ throw new Error('Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
90
+ 'Initialize git and commit at least once, or omit `isolation`.');
91
+ }
92
+ record.worktree = wt;
93
+ worktreeCwd = wt.path;
94
+ }
72
95
  record.status = "running";
73
96
  record.startedAt = Date.now();
74
97
  if (options.isBackground)
@@ -82,22 +105,7 @@ export class AgentManager {
82
105
  detachParentSignal = () => options.signal.removeEventListener("abort", onParentAbort);
83
106
  }
84
107
  const detach = () => { detachParentSignal?.(); detachParentSignal = undefined; };
85
- // Worktree isolation: create a temporary git worktree if requested
86
- let worktreeCwd;
87
- let worktreeWarning = "";
88
- if (options.isolation === "worktree") {
89
- const wt = createWorktree(ctx.cwd, id);
90
- if (wt) {
91
- record.worktree = wt;
92
- worktreeCwd = wt.path;
93
- }
94
- else {
95
- worktreeWarning = "\n\n[WARNING: Worktree isolation was requested but failed (not a git repo, or no commits yet). Running in the main working directory instead.]";
96
- }
97
- }
98
- // Prepend worktree warning to prompt if isolation failed
99
- const effectivePrompt = worktreeWarning ? worktreeWarning + "\n\n" + prompt : prompt;
100
- const promise = runAgent(ctx, type, effectivePrompt, {
108
+ const promise = runAgent(ctx, type, prompt, {
101
109
  pi,
102
110
  model: options.model,
103
111
  maxTurns: options.maxTurns,
@@ -162,7 +170,10 @@ export class AgentManager {
162
170
  }
163
171
  if (options.isBackground) {
164
172
  this.runningBackground--;
165
- this.onComplete?.(record);
173
+ try {
174
+ this.onComplete?.(record);
175
+ }
176
+ catch { /* ignore completion side-effect errors */ }
166
177
  this.drainQueue();
167
178
  }
168
179
  return responseText;
@@ -207,7 +218,17 @@ export class AgentManager {
207
218
  const record = this.agents.get(next.id);
208
219
  if (!record || record.status !== "queued")
209
220
  continue;
210
- this.startAgent(next.id, record, next.args);
221
+ try {
222
+ this.startAgent(next.id, record, next.args);
223
+ }
224
+ catch (err) {
225
+ // Late failure (e.g. strict worktree-isolation) — surface on the record
226
+ // so the user/agent can see it via /agents, then keep draining.
227
+ record.status = "error";
228
+ record.error = err instanceof Error ? err.message : String(err);
229
+ record.completedAt = Date.now();
230
+ this.onComplete?.(record);
231
+ }
211
232
  }
212
233
  }
213
234
  /**
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 ----
@@ -235,7 +235,10 @@ export default function (pi) {
235
235
  cancelNudge(key);
236
236
  pendingNudges.set(key, setTimeout(() => {
237
237
  pendingNudges.delete(key);
238
- send();
238
+ try {
239
+ send();
240
+ }
241
+ catch { /* ignore stale completion side-effect errors */ }
239
242
  }, delay));
240
243
  }
241
244
  function cancelNudge(key) {
@@ -736,29 +739,32 @@ Guidelines:
736
739
  const runInBackground = resolvedConfig.runInBackground;
737
740
  const isolated = resolvedConfig.isolated;
738
741
  const isolation = resolvedConfig.isolation;
739
- // Build display tags for non-default config
740
742
  const parentModelId = ctx.model?.id;
741
743
  const effectiveModelId = model?.id;
742
- const agentModelName = effectiveModelId && effectiveModelId !== parentModelId
744
+ const modelName = effectiveModelId && effectiveModelId !== parentModelId
743
745
  ? (model?.name ?? effectiveModelId).replace(/^Claude\s+/i, "").toLowerCase()
744
746
  : undefined;
745
- const agentTags = [];
746
- const modeLabel = getPromptModeLabel(subagentType);
747
- if (modeLabel)
748
- agentTags.push(modeLabel);
749
- if (thinking)
750
- agentTags.push(`thinking: ${thinking}`);
751
- if (isolated)
752
- agentTags.push("isolated");
753
- if (isolation === "worktree")
754
- agentTags.push("worktree");
755
747
  const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? getDefaultMaxTurns());
756
- // 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;
757
763
  const detailBase = {
758
764
  displayName,
759
765
  description: params.description,
760
766
  subagentType,
761
- modelName: agentModelName,
767
+ modelName,
762
768
  tags: agentTags.length > 0 ? agentTags : undefined,
763
769
  };
764
770
  // ---- Schedule: register a job, don't spawn now ----
@@ -830,17 +836,23 @@ Guidelines:
830
836
  rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, ctx.cwd);
831
837
  }
832
838
  };
833
- id = manager.spawn(pi, ctx, subagentType, params.prompt, {
834
- description: params.description,
835
- model,
836
- maxTurns: effectiveMaxTurns,
837
- isolated,
838
- inheritContext,
839
- thinkingLevel: thinking,
840
- isBackground: true,
841
- isolation,
842
- ...bgCallbacks,
843
- });
839
+ try {
840
+ id = manager.spawn(pi, ctx, subagentType, params.prompt, {
841
+ description: params.description,
842
+ model,
843
+ maxTurns: effectiveMaxTurns,
844
+ isolated,
845
+ inheritContext,
846
+ thinkingLevel: thinking,
847
+ isBackground: true,
848
+ isolation,
849
+ invocation: agentInvocation,
850
+ ...bgCallbacks,
851
+ });
852
+ }
853
+ catch (err) {
854
+ return textResult(err instanceof Error ? err.message : String(err));
855
+ }
844
856
  // Set output file + join mode synchronously after spawn, before the
845
857
  // event loop yields — onSessionCreated is async so this is safe.
846
858
  const joinMode = resolveJoinMode(defaultJoinMode, true);
@@ -925,17 +937,25 @@ Guidelines:
925
937
  streamUpdate();
926
938
  }, 80);
927
939
  streamUpdate();
928
- const record = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
929
- description: params.description,
930
- model,
931
- maxTurns: effectiveMaxTurns,
932
- isolated,
933
- inheritContext,
934
- thinkingLevel: thinking,
935
- isolation,
936
- signal,
937
- ...fgCallbacks,
938
- });
940
+ let record;
941
+ try {
942
+ record = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, {
943
+ description: params.description,
944
+ model,
945
+ maxTurns: effectiveMaxTurns,
946
+ isolated,
947
+ inheritContext,
948
+ thinkingLevel: thinking,
949
+ isolation,
950
+ invocation: agentInvocation,
951
+ signal,
952
+ ...fgCallbacks,
953
+ });
954
+ }
955
+ catch (err) {
956
+ clearInterval(spinnerInterval);
957
+ return textResult(err instanceof Error ? err.message : String(err));
958
+ }
939
959
  clearInterval(spinnerInterval);
940
960
  // Clean up foreground agent from widget
941
961
  if (fgId) {
@@ -1235,14 +1255,14 @@ Guidelines:
1235
1255
  ctx.ui.notify(`Agent is ${record.status === "queued" ? "queued" : "expired"} — no session available.`, "info");
1236
1256
  return;
1237
1257
  }
1238
- const { ConversationViewer } = await import("./ui/conversation-viewer.js");
1258
+ const { ConversationViewer, VIEWPORT_HEIGHT_PCT } = await import("./ui/conversation-viewer.js");
1239
1259
  const session = record.session;
1240
1260
  const activity = agentActivity.get(record.id);
1241
1261
  await ctx.ui.custom((tui, theme, _keybindings, done) => {
1242
1262
  return new ConversationViewer(tui, session, record, activity, theme, done);
1243
1263
  }, {
1244
1264
  overlay: true,
1245
- overlayOptions: { anchor: "center", width: "90%" },
1265
+ overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` },
1246
1266
  });
1247
1267
  }
1248
1268
  async function showAgentDetail(ctx, name) {
@@ -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() ?? "";