Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
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
57 changes: 57 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,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);
});
91 changes: 63 additions & 28 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 @@ -55,14 +57,18 @@ function getLog(): ReturnType<typeof createLogger> {
* 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<Api> | undefined {
return (getModel as unknown as (p: string, m: string) => Model<Api> | undefined)(
provider,
modelId
);
type GetModelFn = (provider: string, modelId: string) => Model<Api> | undefined;
function lookupPiModel(
getModel: GetModelFn,
provider: string,
modelId: string
): Model<Api> | undefined {
return getModel(provider, modelId);
}

/**
Expand Down Expand Up @@ -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(
Expand All @@ -108,6 +115,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 +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}'. ` +
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:<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
2 changes: 1 addition & 1 deletion packages/providers/src/community/pi/ui-context-stub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Loading