Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/providers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"./registry": "./src/registry.ts"
},
"scripts": {
"test": "bun test src/claude/provider.test.ts && bun test src/codex/provider.test.ts && bun test src/registry.test.ts && bun test src/codex/binary-guard.test.ts && bun test src/codex/binary-resolver.test.ts && bun test src/codex/binary-resolver-dev.test.ts && bun test src/claude/binary-resolver.test.ts && bun test src/claude/binary-resolver-dev.test.ts && bun test src/community/pi/model-ref.test.ts && bun test src/community/pi/config.test.ts && bun test src/community/pi/event-bridge.test.ts && bun test src/community/pi/options-translator.test.ts && bun test src/community/pi/session-resolver.test.ts && bun test src/community/pi/provider.test.ts",
"test": "bun test src/claude/provider.test.ts && bun test src/codex/provider.test.ts && bun test src/registry.test.ts && bun test src/codex/binary-guard.test.ts && bun test src/codex/binary-resolver.test.ts && bun test src/codex/binary-resolver-dev.test.ts && bun test src/claude/binary-resolver.test.ts && bun test src/claude/binary-resolver-dev.test.ts && bun test src/community/pi/model-ref.test.ts && bun test src/community/pi/config.test.ts && bun test src/community/pi/event-bridge.test.ts && bun test src/community/pi/options-translator.test.ts && bun test src/community/pi/session-resolver.test.ts && bun test src/community/pi/provider.test.ts && bun test src/community/pi/provider-lazy-load.test.ts",
"type-check": "bun x tsc --noEmit"
},
"dependencies": {
Expand Down
60 changes: 60 additions & 0 deletions packages/providers/src/community/pi/provider-lazy-load.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Regression test: Pi SDK must not load at module-import time.
*
* Pi's `@mariozechner/pi-coding-agent/dist/config.js` runs
* `readFileSync(getPackageJsonPath(), 'utf-8')` at module top-level. Inside
* a compiled Archon binary `getPackageJsonPath()` resolves to
* `dirname(process.execPath) + '/package.json'`, which doesn't exist — so
* any static import chain from `@archon/providers` into the Pi SDK crashes
* archon at startup with ENOENT before any command runs (v0.3.7 symptom).
*
* This test mocks `@mariozechner/pi-coding-agent` and `@mariozechner/pi-ai`
* so that resolving either from a module's static import chain throws.
* Registration + provider instantiation must not trigger those mocks.
*
* Runs in its own `bun test` invocation because Bun's `mock.module` is
* process-wide and would poison `provider.test.ts`, which installs benign
* stubs for the same modules (see CLAUDE.md on test isolation).
*/
import { expect, mock, test } from 'bun:test';

// Track whether the Pi SDK packages were resolved during module init. Mock
// factories run when something imports the module — if our lazy-load is
// working, registration + instantiation must not trigger either factory.
// We don't throw here because Bun's mock.module runs the factory even for
// type-only imports during dependency resolution; throwing would poison
// unrelated import paths. Counter-based detection is sufficient and lets
// the real test assertions produce actionable failures.
let piCodingAgentLoaded = false;
let piAiLoaded = false;

mock.module('@mariozechner/pi-coding-agent', () => {
piCodingAgentLoaded = true;
return {};
});
mock.module('@mariozechner/pi-ai', () => {
piAiLoaded = true;
return {};
});

test('registering and instantiating the Pi provider does not eagerly load the Pi SDK', async () => {
// Go through the same public entrypoint the CLI and server call.
// `registerCommunityProviders()` pulls in the full registration path
// (registry.ts → registration.ts → provider.ts → provider's helpers).
const { clearRegistry, getAgentProvider, registerCommunityProviders } =
await import('../../registry');

clearRegistry();
registerCommunityProviders();

const provider = getAgentProvider('pi');
expect(provider.getType()).toBe('pi');
expect(provider.getCapabilities()).toBeDefined();

// If either of these fails, someone reintroduced a static (non-type)
// `import { ... }` from a Pi SDK package somewhere in the module chain
// reachable from `registerCommunityProviders()`. Fix by moving that value
// import inside `PiProvider.sendQuery()`'s dynamic-import block.
expect(piCodingAgentLoaded).toBe(false);
expect(piAiLoaded).toBe(false);
});
76 changes: 54 additions & 22 deletions packages/providers/src/community/pi/provider.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
import { createLogger } from '@archon/paths';
import {
AuthStorage,
ModelRegistry,
SettingsManager,
createAgentSession,
} from '@mariozechner/pi-coding-agent';
import { getModel, type Api, type Model } from '@mariozechner/pi-ai';
import type { Api, Model } from '@mariozechner/pi-ai';

import type {
IAgentProvider,
Expand All @@ -16,12 +10,20 @@ import type {

import { PI_CAPABILITIES } from './capabilities';
import { parsePiConfig } from './config';
import { bridgeSession } from './event-bridge';
import { parsePiModelRef } from './model-ref';
import { resolvePiSkills, resolvePiThinkingLevel, resolvePiTools } from './options-translator';
import { createNoopResourceLoader } from './resource-loader';
import { resolvePiSession } from './session-resolver';
import { createArchonUIBridge, createArchonUIContext } from './ui-context-stub';

// IMPORTANT: Do NOT add static `import { ... } from '@mariozechner/*'` here,
// and do NOT statically import sibling modules that themselves import runtime
// values from Pi (options-translator, resource-loader, session-resolver,
// ui-context-stub, event-bridge). Pi's `@mariozechner/pi-coding-agent/dist/config.js`
// runs `readFileSync(getPackageJsonPath(), "utf-8")` at module load; inside a
// compiled Archon binary `getPackageJsonPath()` resolves to
// `dirname(process.execPath) + "/package.json"` — a path that doesn't exist —
// and archon crashes at startup before any command runs (v0.3.7 symptom).
//
// All Pi SDK value bindings and Pi-dependent helper modules are dynamically
// imported inside `sendQuery()` below, which runs only when a Pi workflow is
// actually invoked. Type-only imports above are fine — TS erases them.

/**
* Map Pi provider id → env var name used by pi-ai's getEnvApiKey().
Expand Down Expand Up @@ -56,13 +58,16 @@ function getLog(): ReturnType<typeof createLogger> {
* pair. Pi's getModel signature constrains `TModelId` to
* `keyof MODELS[TProvider]`, which isn't knowable from a runtime string —
* the cast through `unknown` is the only way to bypass it. Isolating that
* escape hatch behind one searchable name keeps it auditable.
* escape hatch behind one searchable name keeps it auditable. Takes
* `getModel` as a parameter because the Pi SDK is loaded dynamically (see
* the header comment on this file for why).
*/
function lookupPiModel(provider: string, modelId: string): Model<Api> | undefined {
return (getModel as unknown as (p: string, m: string) => Model<Api> | undefined)(
provider,
modelId
);
function lookupPiModel(
getModel: unknown,
provider: string,
modelId: string
): Model<Api> | undefined {
return (getModel as (p: string, m: string) => Model<Api> | undefined)(provider, modelId);
}

/**
Expand Down Expand Up @@ -108,6 +113,33 @@ export class PiProvider implements IAgentProvider {
resumeSessionId?: string,
requestOptions?: SendQueryOptions
): AsyncGenerator<MessageChunk> {
// Lazy-load Pi SDK and all Pi-dependent helper modules here. Must not move
// these imports to module scope — see the header comment for the failure
// mode (archon compiled binary crashes at startup when Pi's config.js
// reads a package.json that doesn't exist next to the executable).
//
// Class constructors (AuthStorage, ModelRegistry, SettingsManager) are
// accessed via `piCodingAgent.X` rather than destructured, because
// destructured PascalCase bindings trip eslint's naming-convention rule.
const [
piCodingAgent,
piAi,
{ bridgeSession },
{ resolvePiSkills, resolvePiThinkingLevel, resolvePiTools },
{ createNoopResourceLoader },
{ resolvePiSession },
{ createArchonUIBridge, createArchonUIContext },
] = await Promise.all([
import('@mariozechner/pi-coding-agent'),
import('@mariozechner/pi-ai'),
import('./event-bridge'),
import('./options-translator'),
import('./resource-loader'),
import('./session-resolver'),
import('./ui-context-stub'),
]);
const { createAgentSession } = piCodingAgent;

const assistantConfig = requestOptions?.assistantConfig ?? {};
const piConfig = parsePiConfig(assistantConfig);

Expand Down Expand Up @@ -146,7 +178,7 @@ export class PiProvider implements IAgentProvider {

// 2. Look up the Model via Pi's static catalog. `lookupPiModel` returns
// undefined when not found; we guard explicitly below.
const model = lookupPiModel(parsed.provider, parsed.modelId);
const model = lookupPiModel(piAi.getModel, parsed.provider, parsed.modelId);
if (!model) {
throw new Error(
`Pi model not found: provider='${parsed.provider}' model='${parsed.modelId}'. ` +
Expand Down Expand Up @@ -174,7 +206,7 @@ export class PiProvider implements IAgentProvider {
// OAuth refresh note: Pi refreshes expired access tokens against the
// provider's OAuth server and rewrites ~/.pi/agent/auth.json under a
// file lock (same mechanism pi CLI uses — safe for concurrent access).
const authStorage = AuthStorage.create();
const authStorage = piCodingAgent.AuthStorage.create();

const envVarName = PI_PROVIDER_ENV_VARS[parsed.provider];
const envOverride = envVarName
Expand Down Expand Up @@ -265,8 +297,8 @@ export class PiProvider implements IAgentProvider {
// when piConfig.enableExtensions is true — Pi's community extension
// ecosystem (tools + lifecycle hooks from ~/.pi/agent/extensions/ and
// packages installed via `pi install npm:<pkg>`).
const modelRegistry = ModelRegistry.inMemory(authStorage);
const settingsManager = SettingsManager.inMemory();
const modelRegistry = piCodingAgent.ModelRegistry.inMemory(authStorage);
const settingsManager = piCodingAgent.SettingsManager.inMemory();
// Default ON: extensions (community packages like @plannotator/pi-extension
// or your own local ones) are a core reason users run Pi. Opt out with
// `assistants.pi.enableExtensions: false` (or `interactive: false`) in
Expand Down
Loading