Skip to content

feat(agents): discover subagents and workflows declared by installed pi packages - #262

Open
tintinweb wants to merge 4 commits into
masterfrom
feat/package-subagents
Open

feat(agents): discover subagents and workflows declared by installed pi packages#262
tintinweb wants to merge 4 commits into
masterfrom
feat/package-subagents

Conversation

@tintinweb

@tintinweb tintinweb commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Closes #109, except for the package-scoped runtime names that issue also proposes (basic-package.package-researcher) — see Behavior and compatibility. Every number and state below re-verified against this branch's head (ce776bd) on 2026-08-28.

Summary

A reusable subagent cannot travel with the pi package that needs it. Agents are discovered from .pi/agents/, .agents/agents/ and the global agent dir only, so a package that ships an agents/ directory is inert until something copies those files into one of the three. That is a real setup step, not a formality:

  • Every machine, container and CI image needs the copy re-run before the agent exists.
  • The copies are indistinguishable from hand-written project agents, so git status and /agents both present package content as project content.
  • A pi update changes the package's agents and the stale copies keep loading.
  • Two packages that ship a similarly named agent collide in one flat directory, and the copy step is what has to resolve it.

The same gap exists for saved workflow scripts, which resolve names against the same three roots.

What changed

One idea: a package declares its agent and workflow paths in package.json, and this extension reads those paths for packages pi itself has installed — nothing else.

{ "pi": { "subagents": { "agents": ["./agents"], "workflows": ["./workflows"] } } }

Then pi install is the whole setup, at either scope:

pi install npm:my-subagents          # published
pi install git:github.com/me/repo    # straight from git
pi install /path/to/my-subagents     # local checkout, for development
pi install ./my-subagents -l         # project scope, into .pi/settings.json

-l (--local) is pi's own install flag; without it the package lands in the user's global settings.json. A local-path install is not copied, so an edit to an agent file is live on the next Agent call — the fastest authoring loop. Project scope is the shape a team distributes agents in: commit the package to .pi/settings.json and everyone who trusts the repo gets them.

Consequences:

  • New module src/package-resources.ts. Enumerates installed packages via pi's own DefaultPackageManager.listConfiguredPackages() (feature-detected — a host without it contributes no packages instead of failing activation), reads each package.json, and resolves declared entries to absolute paths. A directory is scanned, a file path is taken as that file, !entry excludes, and anything resolving outside the package root is refused. No globs.
  • node_modules is never scanned. Only packages[] from pi's global and project settings.json. A transitive dependency of the user's project contributes nothing, matching pi. A package configured only by an untrusted project stays invisible until the project is trusted — session_start reads ctx.isProjectTrusted() and reloads, because agents register at activation, long before any context exists.
  • No convention-directory fallback. Pi scans skills/ for a package with no pi key at all; this never scans an undeclared agents/. Declaration is the package author's half of the opt-in.
  • Four accepted spellings, all equivalent: pi.subagents as an object or an array shorthand, and the top-level pi-subagents key in both shapes. The top-level key is what nicobailon/pi-subagents reads, so one manifest serves both extensions. Adding pi.subagents is inert for pi itself, whose readPiManifest ignores unknown keys.
  • Package agents load first, so everything local outranks them (custom-agents.ts). Same tier pi gives package-provided skills and Claude Code gives plugin agents. Package workflow dirs are appended last to savedWorkflowRoots.
  • Two settings, packageAgents and packageWorkflows, each true (default), false, or a list of package names matched case-insensitively against the unscoped short name, the full name, or the settings source string. Separate because an agent is markdown handed to a model and a workflow is .js this extension executes. Both appear in /agents → Settings; the row is on/off, and toggling it says in the toast that it replaced a hand-written allowlist.
  • Read-only in /agents. Package agents carry a badge and offer only Eject and Disable — their files live under pi's install root, where an edit is lost on the next pi update. Both write a local file that shadows the original. locateAgentFile now takes the agent's source and refuses to hand back a package path. An agent its own author shipped with enabled: false offers Eject alone: only the package can take that back, and the ejected copy carries no enabled: line, so it lands enabled.
  • 1 MB per-file ceiling on package agent files, matching Claude Code's plugin-agent limit; a non-regular file or a stat failure is skipped with a warning. Local files stay unbounded.
  • Docs: a 209-line guide at docs/packages.md — authoring, entry syntax, precedence, the /agents behaviour, the gate and troubleshooting. README.md's Shipping agents in a pi package section is a summary that links it, and docs/workflows.md documents the package root in the saved-workflow resolution order. CHANGELOG.md has an Unreleased → Added entry.

Related work

States re-checked 2026-08-28.

PR / issue Title State Relation
#109 Support package-owned subagent discovery open The request this implements. Stays open only if package-scoped names are wanted.
#236 Workflow tool — deterministic scripts that orchestrate many subagents merged Introduced savedWorkflowRoots, which this appends a package tier to.
nicobailon/pi-subagents Independent extension reading the top-level pi-subagents manifest key; accepted here for manifest compatibility. No code shared.

#248 (requiredExtensions) and #239 (isolationBackend) — both still open — each add a settings key in the same four regions of src/settings.ts this PR edits: the interface, SettingsAppliers, sanitize and applySettings, plus test/settings.test.ts. Textual overlap only, a rebase and not a design conflict: the three keys are independent. #261 (open) shares src/index.ts and src/types.ts but not the regions touched here. Nothing open touches custom-agents.ts, package-resources.ts or workflow/saved.ts.

Behavior and compatibility

  • No breaking change. With no installed package declaring anything — the state of every existing setup — discovery returns empty lists and every load path behaves as before. Nothing is read from node_modules, and no existing agent or workflow changes precedence relative to another.
  • On by default. Installing a package is already the trust decision: pi executes its extensions/ and injects its skills/ into the system prompt with no further prompt, so a declared .md agent is strictly less privileged than what the same package already runs. The gate exists for narrowing that, not as a second install step. Set packageAgents / packageWorkflows to false to opt out entirely.
  • A package agent can override a built-in default. Package agents rank below every user and project file, but they still sit above the three embedded defaults, so a package declaring Explore replaces it. That matches the priority order Support package-owned subagent discovery #109 proposes and the existing rule for custom agents, but it is a name a package can take.
  • No package namespacing. Names are flat: two packages declaring reviewer collide, and the later load wins, as it already does for two local files claiming one name. The basic-package.package-researcher form in Support package-owned subagent discovery #109 is not implemented, and : remains rejected in a name:.
  • New settings keys packageAgents and packageWorkflows in subagents.json; AgentConfig.source gains "package". An empty allowlist ([]) is kept rather than dropped, and matches nothing — dropping it would silently restore the true default.
  • session_start now calls reloadCustomAgents() and drops the package cache unconditionally, so a pi install in another terminal is picked up by the next session or /reload without restarting pi.
  • Only the root list is cached (keyed by cwd, trust and gate). Agent files themselves are re-read on every load, so editing an agent in a locally linked package still takes effect immediately.

Performance

npm run bench:ab -- 221df02, 3 rounds, fastest round's median sample, on a machine with no packages configured — the path every existing setup takes. The base is this branch's merge base, not current master: master has since picked up #264 (conversation-viewer render cost), which would show in the delta as if it were this PR's doing.

benchmark                                            221df02    working    delta
loadCustomAgents (runs on every Agent call) > 5      273.21us   275.42us   +0.8%
loadCustomAgents (runs on every Agent call) > 50       2.292ms    2.311ms   +0.8%
loadCustomAgents (runs on every Agent call) > 200      9.184ms    9.184ms   +0.0%
Agent tool — background spawn > general-purpose      245.65us   231.40us   -5.8%

loadCustomAgents is the path this PR actually changes, and it is flat. Across all 38 benchmarks only two moved more than 2%: the spawn row above at -5.8%, and FleetList.update > 1 agents at -16.4% — which is 250ns → 209ns, at the timer's resolution rather than a real effect. Both are improvements and neither is on a path this branch touches; the harness's own guidance is to treat anything under ~5% as noise unless it reproduces.

Not measured: the cost with packages actually installed. By construction it is one settings read plus one package.json per package on the first load per (cwd, trust, gate) key, then cached for the session — but no benchmark covers it, and the bench fixture has no packages to configure.

Testing

At ce776bd:

npm run lint       1 warning — see below
npm run typecheck  clean
npm run test       108 files, 2246 passed, 4 skipped (2250)

The lint warning is test/workflow-tool-description.test.ts:155 (noTemplateCurlyInString), a file this branch does not touch. It is not pre-existing on master any more: master silenced it with a biome-ignore in 4f572ea, which this branch has not picked up — it is 7 commits behind master (0.18.2 here vs 0.19.0 there). A rebase clears it; npm run lint on master is clean.

New coverage — 121 cases, all passing (npx vitest run on the six files: 6 files, 341 passed):

  • test/package-resources.test.ts (new, 43) — the four manifest spellings and their precedence, non-string and malformed input, directory vs file vs ! exclusion, path escape outside the package root and via symlink, allowlist matching on all three name forms, the gate/trust cache keys.
  • test/package-agents-wiring.test.ts (new, 25) — boots the real src/index.ts against a package on disk declared in a real pi settings.json, covering the activation-then-trust ordering: a project-configured package is invisible until session_start reports the project trusted, plus the saved-trust and defaultProjectTrust paths.
  • test/workflow-saved.test.ts (new, 25) — resolution order with package roots appended, and that a package script still needs export const meta = to count.
  • test/custom-agents.test.ts (79 → 97) — package tier loads lowest, local files take the name back, the 1 MB ceiling and non-regular-file skip, ! exclusions at load time.
  • test/settings.test.ts (87 → 94) — gate sanitizing, including [] kept rather than dropped.
  • test/agent-file-toggle.test.ts (54 → 57) — locateAgentFile refuses a package path and falls through to the shadowing local file.

Not covered:

  • No test installs a real package from npm or git. The wiring test writes package roots into pi's settings.json and lets pi's own package manager resolve them, so registry download, version resolution and pnpm/legacy global roots are exercised only by pi's code, not ours.
  • The /agents menu paths are not driven through the UI: Eject and Disable on a package agent are covered at the locateAgentFile level, and the settings-row toggle at the applier level, not through ctx.ui.
  • The DefaultPackageManager feature-detection fallback is unit-tested, but no CI job runs against a peer version that actually lacks listConfiguredPackages().

@tintinweb tintinweb changed the title feat(agents): discover subagents and workflows declared by installed … feat(agents): discover subagents and workflows declared by installed pi packages Aug 25, 2026
…nt the feature

Follow-up to the feature commit; all of it is still unreleased.

- package-resources.ts held two literal NUL bytes as cache-key separators, so
  the whole module read as binary and ripgrep silently skipped it.
- Eject dropped `ext:` tool selectors, `persist_session` and `session_dir` from
  a package agent. The ejected copy shadows the package file, so the running
  agent silently lost tools. Only reachable since package agents became
  ejectable — a built-in default carries none of those fields.
- `strictAgentFiles` aborted activation over an unparseable `.md` inside an
  installed package. Strict mode now covers only files the user owns.
- `!` manifest exclusions were inert against a file inside an included
  directory, which is the only form that does anything. Now applied at load
  time, for agents and workflows.
- `"packageAgents": "my-pkg"` was dropped as malformed and fell back to the
  `true` default, silently admitting every package. Read as a one-entry list.
- A symlinked `agents/` pointing out of the package passed the root check.
  Containment now compares canonical paths.
- A workflow declared as a single file promoted its parent directory to a
  name-resolution root, exposing scripts the package never declared. Declared
  files now resolve through an exact name map.
- Project trust is seeded at activation from pi's saved decision. The Agent
  tool description is built before `session_start`, so a package named in a
  committed `.pi/settings.json` was dispatchable but never advertised to the
  model — the team-distribution case from #109.
- Carry the manifest declaration on PiPackage instead of re-reading it.

Adds docs/packages.md and trims the README section to a summary that links it.
@tintinweb tintinweb added feature New feature or request <📍> labels Aug 25, 2026
@tintinweb
tintinweb marked this pull request as ready for review August 27, 2026 13:26
@Brambovich

Copy link
Copy Markdown
image

I was testing this branch and noticed there is no indication for package based agents. Should there be?

@tintinweb

tintinweb commented Aug 28, 2026

Copy link
Copy Markdown
Owner Author

the square should be package based but I see it is not in the agent types list 👍

edit: should probably also list which package it's coming from :D

@tintinweb

Copy link
Copy Markdown
Owner Author
image

The `▪` package badge shipped without a legend key, so a package agent
rendered a glyph nothing on screen explained — and the badge says only
that *some* package won, not which one, which is also the string a user
needs for a `packageAgents` allowlist.

- package-resources: the memoized scan now records each admitted
  package's normalized root and display name, longest root first, so a
  package nested in another's node_modules claims its own files. New
  `packageNameForPath()` traces a loaded `sourcePath` back to it. No
  extra fs work; inherits the existing gate/trust keying and cache
  invalidation.
- agent-source-badge (new): `sourceIndicator`/`sourceLegend`/
  `rowDescription`, extracted from the `/agents` closure the way
  agent-file-toggle.ts was, since `registerCommand` is mocked in every
  wiring test. The legend is now derived from the badges actually on
  screen, so a glyph can't ship without its key again — and a roster
  whose only custom agent is disabled no longer renders `✕•` with no
  key, nor a global-only roster a `•` that appears nowhere.
- /agents: a package row prefixes the highlighted-row description with
  the full `@scope/name`; the detail menu is titled `<name> — <package>`.
  No new column, row, or grouping.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

<📍> feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support package-owned subagent discovery

2 participants