fix(pi-ext): stop importing @earendil-works/pi-ai at runtime
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m19s
CI/CD / test (pull_request) Successful in 1m23s
CI/CD / lint (pull_request) Successful in 3m10s
CI/CD / smoke (pull_request) Failing after 10m42s
CI/CD / build (pull_request) Failing after 13m32s
CI/CD / publish (pull_request) Has been skipped

The extension failed to load outright on older pi installs:

  Failed to load extension ".../mcpctl-pi.ts":
  Cannot find module '@earendil-works/pi-ai'

pi doesn't resolve an extension's bare specifiers the ordinary way — it
hands jiti a hard-coded alias table built from its own dependencies, and
that table differs between pi distributions. `@earendil-works/pi-coding-
agent` (0.84.1) aliases both the `@earendil-works/*` and legacy
`@mariozechner/*` names; `@mariozechner/pi-coding-agent` (0.73.1) aliases
only the old ones. Neither resolves the other's namespace, so a single
import outside the intersection takes the whole extension down: every
tool, the /mcpctl command, and the status line, all gone.

The only thing we used from pi-ai was `StringEnum`, a six-line wrapper
over `Type.Unsafe`. Inlined as a local `stringEnum` with byte-identical
output, so `typebox` — aliased by every published pi — is now the sole
bare runtime import. The call site also passes `description` through,
which the pi-ai version was silently dropping.

Guarded in tests/config/pi-extension-embed.test.ts: any runtime import in
the embedded sources that isn't `node:`, relative, or typebox now fails.

Verified against both installs with the same active project: 0.73.1
reproduced the error verbatim before the change and loads cleanly after,
and 0.84.1 keeps registering the gate tool exactly as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tsRTqhEC7YYYYaP3cBqo8
This commit is contained in:
Michal
2026-08-10 00:15:04 +01:00
parent e4e2e063f1
commit be7fabd467
4 changed files with 83 additions and 6 deletions

View File

@@ -29,6 +29,34 @@ describe('embedded pi extension', () => {
expect(PI_EXTENSION_FILES['mcpctl-pi.ts']).toContain('./mcp-http.js');
});
/**
* pi resolves an extension's bare specifiers through a hard-coded alias table
* in its own loader, and that table is not the same across pi distributions:
* `@earendil-works/*` exists only in the newer packages, `@mariozechner/*`
* installs alias only the old names, and neither resolves the other. An
* import of a package outside the intersection makes the whole extension fail
* to load with `Cannot find module` — every tool gone, on someone else's pi.
*
* `typebox` is aliased by every published pi, so it is the only safe bare
* runtime import. Type-only imports are erased before jiti resolves anything,
* so they may name whatever they like.
*/
it('imports nothing at runtime that some pi build cannot resolve', () => {
// `import x from "s"` / `import {..} from "s"` (but not `import type`),
// plus the side-effect form `import "s"`.
const runtimeImport =
/^\s*import\s+(?!type\s)[^;]*?from\s*["']([^"']+)["']|^\s*import\s*["']([^"']+)["']/gm;
const allowed = /^(node:|\.\/|\.\.\/|typebox$|typebox\/)/;
for (const name of PI_EXTENSION_FILENAMES) {
const src = PI_EXTENSION_FILES[name] ?? '';
for (const match of src.matchAll(runtimeImport)) {
const specifier = match[1] ?? match[2] ?? '';
expect(specifier, `${name} runtime-imports ${specifier}`).toMatch(allowed);
}
}
});
it('carries the fixes the pi API requires', () => {
const main = PI_EXTENSION_FILES['mcpctl-pi.ts'] ?? '';
// ctx.ui.select takes string[] and returns the chosen string.