@agimon-ai/po-build

Folder-convention scanner and build presets for Po extensions.

Packages

Package details

package

Install @agimon-ai/po-build from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:@agimon-ai/po-build
Package
@agimon-ai/po-build
Version
0.0.0-alpha.107
Published
Nov 4, 2026
Downloads
19.7K/mo · 8,817/wk
Author
agiflow-ai
License
BUSL-1.1
Types
package
Size
533.4 KB
Dependencies
0 dependencies · 0 peers

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

@agimon-ai/po-build

Folder-convention scanner and build presets for Po extensions.

An extension declares what it contributes by where its files sit. This package reads that tree and hands the result to the generators and bundler presets, so a package keeps no parallel registry of its own contents.

See Extension layout for the convention and Extension lifecycles for what happens to a contribution once mounted.

Scanning

import { scanExtensions } from '@agimon-ai/po-build';

const graph = scanExtensions({ packageDir: process.cwd() });
for (const entry of graph.entries) console.log(entry.scope, entry.side, entry.surface, entry.name);
for (const notice of graph.notices) console.warn(notice.path, notice.message);

The scan is filesystem in, data out. It walks directories rather than globbing, which is what keeps parenthesised group folders safe: tsdown's globber reads a bare (name) as an extglob group, and a walk never sees a pattern at all.

Nothing throws. A folder the convention does not recognise becomes a notice and its subtree is skipped, so one bad directory never costs a package its other contributions.

What a path declares

src/extensions/<scope>/(side)/<surface>/<name>[.<target>][.<platform>].<ext>
Axis Read from
scope workspaces/ and sessions/ nesting below the root
side the (backend) or (frontend) group folder
gate a mode/<id>/ or domain/<id>/ container folder
surface the folder directly holding the file, such as tool/
route folders below a routed surface, with [param] and [...catchAll]
name the first filename segment
target filename segments between the name and the platform
platform a trailing filename segment naming a platform for that side

_private folders are never scanned. *.test.*, *.spec.* and *.stories.* are excluded everywhere.

Options

Every name in the convention is configurable, and the defaults are the documented ones.

scanExtensions({
  packageDir,
  root: 'src/extensions',
  sides: { backend: 'backend', frontend: 'frontend' },
  platforms: { backend: ['server', 'mcp'], frontend: ['web', 'native', 'ios', 'android', 'desktop'] },
  generatedEntries: ['pi', 'server', 'web', 'native', 'mcp', 'client'],
});

Platforms and targets

The build emits four hosts: server and mcp from (backend), and web and native from (frontend). ios, android, and desktop are authorable but have no build target, so a file naming one produces a notice and is not emitted.

The server and the cockpit accept an unsuffixed file as a fallback when no sibling names their platform. mcp and native never do: they read only files that name them, because an unsuffixed backend file may reach the whole server and an unsuffixed frontend file may use the DOM. native reads tool/, tab/, slot/, fill/, channel/, activity-group/, setting/, lifecycle/, and store/, and writes the same contribution fields as the cockpit for them, because the Po phone app reuses the cockpit's plugin registry.

isBrowserFile and isNativeFile (from @agimon-ai/po-build/browserFile) classify a package-relative path without reading it. A (frontend) path whose filename names native anywhere between its name and extension, including .native.stories.tsx, is native; every other (frontend) path is browser code. Lint rules use these to apply browser and native rules to the right files.

Generating

generateExtension scans the authored tree, renders an entry for each host it contributes to, and writes build inputs beneath ignored generated/. A target with no contributions produces no entry, and removing a target deletes its obsolete generated entry.

import { generateExtension } from '@agimon-ai/po-build';

const { targets, changed, notices } = generateExtension({ packageDir });

Without a target option it generates server, web, and native. The native entry, generated/native.ts, exports nativePlugin = defineNativePlugin({...}) from @agimon-ai/po-core/native.

Normal builds generate missing or stale entries, including in CI. Pass check: true only for an explicit write-free freshness check. Generated files are disposable build output and are not committed.

The tsdown preset

// tsdown.config.ts, the whole file
import { poExtension } from '@agimon-ai/po-build/tsdown';
import { defineConfig } from 'tsdown';

export default defineConfig(poExtension());

The preset generates entries while the config loads because tsdown needs its entry map before building. It builds Node host entries and public src/exports modules, then bundles routed frontend contributions separately to dist/extensions/web.mjs. The browser bundle keeps bare package imports external so the cockpit supplies shared React and store singletons. It also emits local ?url assets and ?worker&url worker chunks.

When a package has .native contributions, the preset adds a third config that bundles generated/native.ts to dist/extensions/native.mjs: ESM, platform: 'neutral', every bare import external, typechecked under tsconfig.native.json. Its po-unistyles-babel transform runs the react-native-unistyles Babel plugin ({ root: 'src' }) over .ts and .tsx files under the package's own src that mention react-native, before rolldown strips the TypeScript. po-build does not depend on Babel or Unistyles: it loads @babel/core and react-native-unistyles/plugin from the extension package with createRequire, and fails with a message naming both when either is missing. This file bundle is not yet a composition; the po CLI joins every package's native half into one signed composition and checks its imports against the native host module list (see Composition and package loading).

Native component libraries reuse the same transform: import { unistylesBabel } from '@agimon-ai/po-build/tsdown' and add unistylesBabel(import.meta.dirname) to their own tsdown plugins.

The build synchronizes pi, poServer, poWeb, poNative, and generated host exports in package.json. poNative holds pluginId, the frame types its .native channels claim, client: './dist/extensions/native.mjs', and the scopes it contributes to; the build also writes the ./extensions/native export. poWeb and poNative describe disjoint files, because the cockpit never reads .native files. Keep package-specific public entries with the preset entry option, use exportsDir for a nonstandard public-export root, and set pluginId when the historical cockpit id differs from the package-derived default.

Packages with a frontend target typecheck it through a root tsconfig.web.json. Include generated in the Node project, exclude routed (frontend) files from it, and include those files plus generated/web.ts in the browser project. Packages with a native target also own a tsconfig.native.json with no DOM lib that includes src/extensions/**/(frontend)/**/*.native.* and generated/native.ts; exclude **/*.native.* from tsconfig.web.json and add @babel/core, react-native, and react-native-unistyles to the package's dev dependencies. layers/task/po-task is the working example.