diff --git a/CHANGELOG.md b/CHANGELOG.md index 887ffe4e94..262a6de436 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Compiled archon binaries no longer crash at startup when the Pi provider is bundled.** `@mariozechner/pi-coding-agent/dist/config.js` runs `readFileSync(getPackageJsonPath(), 'utf-8')` at module top-level, which inside a compiled binary resolves to `dirname(process.execPath) + '/package.json'` — a path that doesn't exist next to `/usr/local/bin/archon`, making every archon command (including `archon version`) crash with ENOENT before it ran. The Pi SDK and all Pi-dependent helper modules are now dynamically imported inside `PiProvider.sendQuery()`; registering Pi and instantiating the provider no longer touches Pi's module-init side effects. A regression test (`provider-lazy-load.test.ts`) walks the same `registerCommunityProviders()` + `getAgentProvider('pi')` path the CLI and server take and asserts neither SDK package was resolved. Claude and Codex providers keep their static import style — their SDKs have no equivalent module-init side effect. Unblocks the v0.3.7 release binaries that could not ship because of this bug. (#1355) +- **Release binary compile no longer silently produces broken bytecode.** `scripts/build-binaries.sh` dropped the `--bytecode` flag: Bun 1.3.11's bytecode step failed with `Failed to generate bytecode for ./cli.js` against the 0.3.7 module graph and fell through to producing a binary that crashed at module instantiation with "Expected CommonJS module to have a function wrapper". Windows was already excluded; this removes the flag everywhere. Release parity preserved via `--minify`. (#1354) + ## [0.3.7] - 2026-04-22 Pi community provider, home-scoped workflows/commands/scripts, worktree policy, Web UI approval-gate auto-resume, three-path env model, and a breaking change to Claude Code binary resolution for compiled binary users. diff --git a/packages/providers/package.json b/packages/providers/package.json index 5937d54658..25227c7a7b 100644 --- a/packages/providers/package.json +++ b/packages/providers/package.json @@ -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": { diff --git a/packages/providers/src/community/pi/provider-lazy-load.test.ts b/packages/providers/src/community/pi/provider-lazy-load.test.ts new file mode 100644 index 0000000000..04ee0d1919 --- /dev/null +++ b/packages/providers/src/community/pi/provider-lazy-load.test.ts @@ -0,0 +1,57 @@ +/** + * 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). + * + * Detection strategy: replace both Pi SDK packages with `mock.module` + * factories that flip a boolean the first time something resolves them. + * Walk the same registration path the CLI and server take and assert + * neither flag tipped. A throwing factory would abort the failing import + * before the `expect` calls run, producing a crash at resolution time with + * no assertion context — counters keep failures actionable. + * + * 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'; + +// Counter-based detection — see the file header for why not `throw`. +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); +}); diff --git a/packages/providers/src/community/pi/provider.ts b/packages/providers/src/community/pi/provider.ts index f0171df202..e4b6804762 100644 --- a/packages/providers/src/community/pi/provider.ts +++ b/packages/providers/src/community/pi/provider.ts @@ -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, @@ -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(). @@ -55,14 +57,18 @@ function getLog(): ReturnType { * Typed wrapper around Pi's `getModel` for a runtime-string provider/model * 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. + * the local `GetModelFn` alias is the narrowest shape that still lets us + * bypass that constraint. Isolating the 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 | undefined { - return (getModel as unknown as (p: string, m: string) => Model | undefined)( - provider, - modelId - ); +type GetModelFn = (provider: string, modelId: string) => Model | undefined; +function lookupPiModel( + getModel: GetModelFn, + provider: string, + modelId: string +): Model | undefined { + return getModel(provider, modelId); } /** @@ -95,11 +101,12 @@ ${JSON.stringify(schema, null, 2)}`; * (no reuse) with in-memory auth/session/settings, so the server never * touches `~/.pi/` and concurrent calls don't collide. * - * v1 capabilities are all false (see `capabilities.ts`): sessionResume, - * thinkingControl, skills, mcp, etc. map to Pi features but require - * intentional wiring before they can be declared. Under-declaring is - * honest; the dag-executor emits warnings for any nodeConfig field not - * supported. + * Capabilities (see `capabilities.ts` for the canonical list): Pi declares + * `sessionResume`, `skills`, `toolRestrictions`, `structuredOutput`, + * `envInjection`, `effortControl`, and `thinkingControl`. Features Pi does + * not currently support through Archon (`mcp`, `hooks`, `agents`, + * `costControl`, `fallbackModel`, `sandbox`) stay off; the dag-executor + * surfaces a warning for any unsupported nodeConfig field. */ export class PiProvider implements IAgentProvider { async *sendQuery( @@ -108,6 +115,33 @@ export class PiProvider implements IAgentProvider { resumeSessionId?: string, requestOptions?: SendQueryOptions ): AsyncGenerator { + // 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); @@ -146,7 +180,8 @@ 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); + // Cast to the runtime-string-friendly shape — see `lookupPiModel`'s docblock. + const model = lookupPiModel(piAi.getModel as GetModelFn, parsed.provider, parsed.modelId); if (!model) { throw new Error( `Pi model not found: provider='${parsed.provider}' model='${parsed.modelId}'. ` + @@ -174,7 +209,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 @@ -265,8 +300,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:`). - 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 diff --git a/packages/providers/src/community/pi/ui-context-stub.ts b/packages/providers/src/community/pi/ui-context-stub.ts index 99f18c63c5..70917a0068 100644 --- a/packages/providers/src/community/pi/ui-context-stub.ts +++ b/packages/providers/src/community/pi/ui-context-stub.ts @@ -3,8 +3,8 @@ import type { ExtensionUIDialogOptions, ExtensionWidgetOptions, TerminalInputHandler, + Theme, } from '@mariozechner/pi-coding-agent'; -import { Theme } from '@mariozechner/pi-coding-agent'; import type { MessageChunk } from '../../types';