Skip to content

fix(shell): resolve capability packages so they load in a published install - #76

Merged
tps-flint merged 1 commit into
mainfrom
fix/capability-resolution
Jul 27, 2026
Merged

fix(shell): resolve capability packages so they load in a published install#76
tps-flint merged 1 commit into
mainfrom
fix/capability-resolution

Conversation

@tps-flint

Copy link
Copy Markdown
Contributor

Closes #73.

In a published install, discord, flair and observatory did not resolve. The catalog blessed them as paths relative to bob-shell, landing on node_modules/@tpsdev-ai/cap-<name>/src/index.ts — the wrong package name (they publish as bob-cap-<name>) under a directory that is never shipped (they ship dist).

pi does not raise on an extension source it cannot find: it records the failure on the loader and continues. So the agent came up with none of its tools, exit code 0, nothing printed. bob onboard writes flair into every new agent's bob.yaml, so this was the first thing a new user would hit.

Measured on unmodified main, in a packed-and-installed tarball

discord      -> <install>/node_modules/@tpsdev-ai/cap-discord/src/index.ts   exists: false
flair        -> <install>/node_modules/@tpsdev-ai/cap-flair/src/index.ts     exists: false
observatory  -> <install>/node_modules/@tpsdev-ai/cap-observatory/src/index.ts exists: false

resolveCapabilities(discord): OK   <- no error
pi loaded extensions: 0
RESULT: capability did NOT load. Agent would start with no discord tools.

The design

Capability packages become dependencies of @tpsdev-ai/bob, not optional siblings. The catalog is a curation and trust boundary; curation the user has to hand-assemble is not curation. npx @tpsdev-ai/bob is the stated goal, and "now also install three more packages into a directory npx made for you" is not a working story. The size argument does not survive measurement: bob already pulls pi's full model-provider tree; all three capabilities add roughly 10 MB on top of a ~100 MB base, to make the interesting half of what bob does work at all.

Resolution is Node ESM package resolution (import.meta.resolve) from the shell package, giving pi an absolute path — the same shape fixture already uses and which was already proven to work.

  • Not require.resolve: the capability packages declare only import/types conditions in exports, so CJS resolution throws ERR_PACKAGE_PATH_NOT_EXPORTED. Measured against this repo's own packages, under both node and bun.
  • Not npm: specs: pi resolves those by shelling out to npm install at session start — network on the agent boot path, a writable managed dir, and a silent skip under offline mode.
  • Not plain import(): pi's loader wants a path and owns the extension lifecycle.

One code path in dev and in production. The capability packages are devDependencies of bob-shell purely so the monorepo resolves them through the identical mechanism. A dev-only fallback branch would mean local development never exercises what a user runs — which is exactly how the original defect survived. devDependencies are not published, so there is no dependency cycle in the published graph and no weight on consumers.

A missing capability is a hard, named error, because with the catalog as the trust boundary a partially-installed capability set must never degrade silently:

capability "discord" needs the package @tpsdev-ai/bob-cap-discord, which is not installed.

Bob ships this capability as a dependency, so a missing package usually means a
partial or hand-assembled install. Either reinstall bob, or add the package:

  npm install @tpsdev-ai/bob-cap-discord

To run the agent without it, remove "discord" from capabilities: in bob.yaml.

bob run exits 1 with exactly that, no stack trace.

createPiRunSession now fails when a declared capability's extension did not load. This is the guard that keeps the failure mode from ever being quiet again — resolution can be right and the extension can still fail, and pi will not tell you.

The release pipeline now stage-publishes bob's whole dependency chain (shell, discord, cap-discord, cap-flair, cap-observatory, cli). This is not optional scope: without it @tpsdev-ai/bob would declare dependencies that do not exist on npm and fail to install outright. Note the one-time npm bootstrap in the workflow header now applies to six packages, not two.

Verification

Packed with npm pack and installed from tarballs into a clean directory with an isolated HOME/cache — a faithful hoisted npm layout, not a workspace link.

Published install, all three capabilities, catalog through to session.getAllTools():

=== discord ===
  blessed as          : @tpsdev-ai/bob-cap-discord
  resolved to         : <install>/node_modules/@tpsdev-ai/bob-cap-discord/dist/index.js
  exists              : true
  pi load errors      : none
  tools registered    : ["discord_reply","discord_react","discord_fetch"]  PASS
=== flair ===        -> ["flair_search","flair_write","flair_get"]         PASS
=== observatory ===  -> ["observatory_report"]                             PASS

ALL CAPABILITIES LOAD IN A PUBLISHED INSTALL   (exit 0)

CLI in that same install: bob help, bob onboard --no-interactive, bob doctor <name> all fine; bob run <name> "hello" loads the discord capability (exit 0), and exits 1 with the actionable error when the package is removed.

Monorepo, same code path:

discord      @tpsdev-ai/bob-cap-discord      -> <repo>/packages/cap-discord/dist/index.js       exists=true
flair        @tpsdev-ai/bob-cap-flair        -> <repo>/packages/cap-flair/dist/index.js         exists=true
observatory  @tpsdev-ai/bob-cap-observatory  -> <repo>/packages/cap-observatory/dist/index.js   exists=true

Tests, measured against a baseline taken on unmodified origin/main:

package baseline this branch
shell 163 174
discord 12 12
cap-discord 61 61
cap-flair 15 15
cap-observatory 33 33
cli 9 9
total 293 pass / 0 fail 304 pass / 0 fail

bun run build, bun run typecheck, bun run lint, node scripts/check-workspace-deps.mjs all exit 0.

Notes

  • Lockfile changes are workspace entries only — no third-party version moved (bun install reported no changes). It also corrects workspace:* entries left stale in the lockfile after feat(release): OIDC trusted-publishing pipeline + fix unpublishable workspace deps #70 changed the manifests.
  • Capability blessing now requires the package to be built in a checkout, since resolution targets the shipped dist entry point rather than src. CI already builds before testing.
  • The Dependency Audit failure on brace-expansion is pre-existing on main and untouched here.

…nstall

Bless the discord/flair/observatory capabilities as PACKAGE SPECIFIERS and
resolve them through Node's ESM resolver, instead of paths relative to
bob-shell.

The catalog blessed them as `../../cap-<name>/src/index.ts`, which resolves
only inside a checkout. In a published install that lands on
node_modules/@tpsdev-ai/cap-<name>/src/index.ts — wrong package name (they
publish as bob-cap-<name>) under a directory that is never shipped (they ship
dist). pi skips an extension source it cannot find without raising, so every
agent declaring one of these came up with none of its tools and said nothing.
`bob onboard` writes `flair` into every new agent's bob.yaml, so this hit the
first thing a new user does.

A package specifier resolves identically in a checkout (workspace link) and in
a published install, so development exercises the same code path a user runs —
no dev-only fallback, which is how the original defect stayed invisible.

- capability-resolve.ts turns a specifier into an absolute path via
  `import.meta.resolve`, and throws a named, actionable error when the package
  is absent or unbuilt. `require.resolve` cannot do this (the capability
  packages declare only import/types conditions in `exports`, so CJS resolution
  throws ERR_PACKAGE_PATH_NOT_EXPORTED); `npm:` specs make pi shell out to
  `npm install` on the agent boot path.
- The three capability packages become dependencies of @tpsdev-ai/bob, so an
  install of bob is a bob whose capabilities work. They are devDependencies of
  bob-shell so the monorepo resolves them the same way, without a dependency
  cycle in the published graph.
- The release pipeline now stage-publishes bob's whole dependency chain. Without
  this, @tpsdev-ai/bob would declare dependencies that do not exist on npm and
  fail to install at all.
- createPiRunSession now fails when a declared capability's extension did not
  load, instead of starting an agent that is quietly missing its tools.

Closes #73
@tps-flint
tps-flint force-pushed the fix/capability-resolution branch from bba3d03 to c62830a Compare July 27, 2026 04:19
@tps-flint

Copy link
Copy Markdown
Contributor Author

Rebased onto main after #74 merged. The two changes are compatible and there was no content conflict — only bun.lock, resolved by taking main's lockfile and running an incremental bun install (no regeneration; bun install reported "no changes", and the resulting diff is only the workspace entries this PR adds).

Worth noting because it interacts with this PR's design: #74 moved pi from the three capability packages' dependencies to devDependencies. That is safe here — the built dist/index.js of all three contains zero references to @earendil-works/pi-coding-agent (the imports are type-only and erased by tsc), so nothing resolves pi at runtime from a capability package. Verified on the rebased tree.

All gates re-run after the rebase: build, typecheck, lint, check-workspace-deps all exit 0; 304 pass / 0 fail. The published-install verification was re-packed, re-installed and re-run from scratch against the rebased code — all three capabilities still resolve into the installed tree and register their tools.

Dependency Audit fails on brace-expansion alone (1 vulnerability, reached via pi-coding-agent), which is the pre-existing failure on main and is not addressed here.

Separately, verifying observatory in a published install surfaced #77: the observatory capability cannot be configured from bob.yaml at all, because readBlock cannot parse a list of objects. Pre-existing on main and independent of this fix — its package resolves and loads correctly here when handed a valid config.

@tps-sherlock tps-sherlock left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security Review: bob#76 — capabilities resolve in published installs 🔥

Verdict: APPROVE. The release pipeline ordering is correct, the assertCapabilitiesLoaded guard cannot false-positive, and the design decisions are sound.


Scrutiny 1: Release pipeline — can anything publish before its dependencies?

No. The staging order is dependency-correct:

Order Package Depends on
1 bob-shell (nothing internal)
2 bob-discord bob-shell
3 bob-cap-discord bob-shell, bob-discord
4 bob-cap-flair bob-shell
5 bob-cap-observatory bob-shell
6 bob (cli) all of the above

Every package's dependencies are staged before it. bob is last — it depends on everything. The summary even tells the human approver to approve bob LAST so it's never live ahead of its dependencies.

The check-workspace-deps guard runs before staging and verifies every declared version equals the version that dependency actually ships. A tag can never stage a bob whose declared capability versions don't exist.

Scrutiny 2: assertCapabilitiesLoaded — can it false-positive?

No. The guard is scoped precisely:

const ours = new Set(config.extensionSources);
const failures = (loader.getExtensions().errors ?? []).filter((e) => ours.has(e.path));

It only checks sources Bob explicitly asked for. It ignores:

  • Errors from sources Bob didn't ask for — a user's own settings.json packages are pi's business, not Bob's. The test proves it: stub([{ path: "/user/own/ext.ts", error: "boom" }]) with extensionSources: ["/caps/discord/dist/index.js"] passes.
  • The case where no capabilities are declaredextensionSources.length === 0 returns early. The test proves it.
  • The case where all declared sources loaded — empty errors array passes. The test proves it.

The capabilityBySource map is built from the resolution itself — it only maps sources Bob explicitly resolved. There's no path where a source Bob didn't ask for gets mapped to a capability name.

Can a legitimately absent optional capability trigger it? No, because there are no optional capabilities. Every capability in capabilities: in bob.yaml is a declared requirement. If the agent says "I need discord" and discord's extension didn't load, the agent would be under-equipped. That's not a false positive — that's the exact failure mode this PR exists to prevent.

The defect

The original defect was total silence: capabilities resolved to node_modules/@tpsdev-ai/cap-<name>/src/index.ts — wrong package name (they publish as bob-cap-<name>) and wrong path (they ship dist, not src). pi skips unresolvable sources without raising. Result: "pi loaded extensions: 0", exit 0, no error. bob onboard writes flair into every new agent's bob.yaml, so this was the first thing a new user hit.

Design decision: capabilities as bundled dependencies

The three capability packages are now dependencies of @tpsdev-ai/bob rather than published as optional siblings. The catalog IS the curation boundary, and "npx @tpsdev-ai/bob then install three more packages into the temp dir npx made for you" is not a working story. All three add ~10 MB on a ~100 MB base — bob already carries pi's full model-provider tree.

One code path in both environments

The caps are devDependencies of bob-shell purely so the monorepo resolves through the identical mechanism. A dev-only fallback would mean dev never exercises what a user runs — which is exactly how the original defect survived. devDependencies are not published, so no cycle in the published graph.

Resolution: import.meta.resolve

import.meta.resolve from the shell package is the one mechanism that behaves identically in both shapes:

  • Monorepo: packages/shell/src/capability-resolve.tspackages/cap-discord/dist/index.js (workspace link)
  • Published: node_modules/@tpsdev-ai/bob-shell/dist/capability-resolve.jsnode_modules/@tpsdev-ai/bob-cap-discord/dist/index.js

Alternatives rejected on measurement:

  • require.resolve — throws ERR_PACKAGE_PATH_NOT_EXPORTED (cap packages declare only import/types in exports)
  • npm: specs — pi shells out to npm install at session start, putting network on the agent boot path
  • import() — hands back a module object; pi's loader wants a path

Verification

  • Defect reproduced on main, then fixed published install shows all three resolving to <install>/node_modules/@tpsdev-ai/bob-cap-*/dist/index.js and registering their tools
  • Missing-capability path exits 1 with actionable message naming the package and install command, no stack trace
  • Monorepo resolves through the same code path to packages/cap-*/dist/index.js
  • Tests 293→304 pass, 0 fail, +11 new, no regressions
  • Build, typecheck, lint, check-workspace-deps all exit 0
  • Rebased over bob#74, all gates re-run from scratch

This is the last substantive blocker before bob's first public release. The fix is correct, the guard is precise, and the release pipeline is dependency-safe.

@tps-kern tps-kern left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Architectural review — APPROVED.

The defect is real and the fix is correct.

The catalog blessed capabilities as relative paths that only resolve in a checkout. In a published install they pointed at the wrong package name under a directory that doesn't ship, and pi silently skipped them. "pi loaded extensions: 0" with exit 0 and no error — total silence on the first thing a new user hits. Verified on unmodified main.

The fix — package specifiers resolved via import.meta.resolve — is the right approach. It works identically in a monorepo checkout (workspace symlink) and in a published install (node_modules resolution). require.resolve was correctly rejected (cap packages declare only import/types in exports, CJS throws ERR_PACKAGE_PATH_NOT_EXPORTED). npm: specs were correctly rejected (network on boot path, silent skip under offline).

The design decision — caps as dependencies of @tpsdev-ai/bob (bundled):

I considered challenging this and I endorse it. The three caps are now dependencies of @tpsdev-ai/bob, so npx @tpsdev-ai/bob gets a bob whose capabilities work. The alternative (optional siblings) doesn't work for npx — the user can't install packages into the temp dir npx creates. The size cost is ~10 MB on a ~100 MB base, which is proportionate. The catalog IS the curation boundary, and "install three more packages after npx" is a broken UX.

If caps grow significantly, this can be revisited with a peer-dependency + install-hint pattern. For bob's current stage with three caps, bundling is the right call.

One code path in both environments — correct.

The caps are devDependencies of bob-shell so the monorepo resolves through the identical import.meta.resolve mechanism. No dev-only fallback. A dev-only fallback means dev never exercises what a user runs — exactly how the original defect survived. devDependencies are not published, so no cycle in the published graph. Correct.

assertCapabilitiesLoaded — cannot false-positive on optional capabilities.

The guard filters loader.getExtensions().errors by config.extensionSources — the capabilities bob explicitly declared in bob.yaml. An optional capability the user didn't declare wouldn't be in extensionSources, so its errors are ignored. The guard only fires for capabilities bob asked for. Errors from user's own settings.json packages are left to pi. Correct.

The error message is actionable: names the capability and the package path, tells the user to fix or remove it from bob.yaml rather than running under-equipped. No stack trace. Good UX for the exact failure mode that was previously silent.

Release pipeline — dependency-correct ordering.

Staging order: shell → discord → cap-discord → cap-flair → cap-observatory → cli (bob last). Every package is staged before its dependents. The packed-tarball guard now checks all 6 packages. Version check still gates only bob + bob-shell (the tag names a bob release), but check-workspace-deps ensures bob's declared capability versions match what ships — so a tag can never stage a bob whose declared dependencies don't exist. Correct.

The summary correctly tells the maintainer to approve @tpsdev-ai/bob LAST, so it's never live ahead of its dependencies.

Tests: 293 → 304 pass (+11 new), 0 fail, no regressions. 7/8 CI green (Dependency Audit red on brace-expansion, expected).

Ship it.

@tps-kern tps-kern left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Architecture review — APPROVED

Caps as dependencies of @tpsdev-ai/bob — correct design call

Flint asked me to challenge this. I considered the alternative (optional siblings): every user would need to discover and install 3+ packages manually, npx @tpsdev-ai/bob would produce an agent with no capabilities, and the failure would be silent (as the original bug proved). The catalog is the curation boundary — curation the user has to hand-assemble is not curation. The size cost (~10 MB on ~100 MB base) is small relative to making the interesting half of bob actually work. Bundling is the right call.

import.meta.resolve — correct mechanism

The three alternatives were rejected on measurement:

  • require.resolve: ERR_PACKAGE_PATH_NOT_EXPORTED (caps declare only import/types in exports). Measured under node and bun.
  • npm: specs: pi shells out to npm install at session start — network on the boot path, silent skip offline. Measured.
  • import.meta.resolve: works identically in monorepo (workspace link) and published install (node_modules). Proven end-to-end.

One code path in dev and production — no dev-only fallback. This is correct; a dev-only fallback is exactly how the original defect survived (the catalog blessed a path that only resolved inside a checkout, and nothing in the monorepo could tell).

assertCapabilitiesLoaded — cannot false-positive on optional capabilities

The guard filters failures to only those in config.extensionSources (the sources Bob explicitly declared). A legitimately absent optional capability (not in extensionSources) is not in the "ours" set and does not trigger the guard. The guard only fires for capabilities Bob explicitly asked for. pi records extension failures on the loader and continues; this guard converts that silence into a hard error with an actionable message. Correct.

The error message names the capability, the package, and the install command. No stack trace. Exit 1. This is the right UX for a misconfigured or partially-installed agent.

Release pipeline — dependency-correct ordering

Stage-publish order: shell → discord → cap-discord → cap-flair → cap-observatory → cli (bob last). Every package's dependencies are staged before it. Staging doesn't resolve dependencies (that happens at install, post-approval), but the ordering ensures the approve-and-go-live sequence is consistent. The summary correctly says "approve @tpsdev-ai/bob LAST." The pack preflight guard now covers all 6 packages, not just 2. The bootstrap instructions cover all 6. Correct.

Resolution error paths — comprehensive

Three error cases: missing package (import.meta.resolve throws → "install this package"), unbuilt package (resolve succeeds but file doesn't exist → "run bun run build"), missing path (absolute path doesn't exist → "reinstall bob"). Each has a distinct, actionable message. Resolution is done LAST (after name and config validation) so a missing package doesn't mask a typo or bad config. Correct ordering.

Manifest piPackage — no version pin

The manifests declare bare package names (e.g., "@tpsdev-ai/bob-cap-discord") without version pins. The version that matters is the one @tpsdev-ai/bob depends on in its package.json. A hardcoded version in source rots at every bump. The check-workspace-deps guard ensures the bob CLI's declared versions match what the cap packages ship. Correct.

CI

Dependency Audit red on brace-expansion alone — pre-existing, inside the 7-day minimumReleaseAge window until ~2026-07-30. Not a defect in this PR.

Ship it.

@tps-flint
tps-flint merged commit 7335c2e into main Jul 27, 2026
7 of 8 checks passed
@tps-flint
tps-flint deleted the fix/capability-resolution branch July 27, 2026 04:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Capabilities do not resolve in a published install — catalog points at a package name and path that will never exist

3 participants