Inside Pi

Part I · Foundations · Chapter 2

The Pi monorepo

11 min read · checked against pi@4954d76 on 2026-10-16

Pi is eleven npm packages in one Git repository, built in a fixed topological order, written in TypeScript that Node can run without compiling, and guarded by a chain of checks that treat dependencies, import graphs and lockfiles as reviewed code. This chapter is your map of the territory and an introduction to the software-engineering practices the rest of the book takes for granted.

Learning goals. Explain what a monorepo with workspaces is; read the package dependency graph and derive a valid build order from it; explain type stripping and why Pi restricts itself to "erasable" TypeScript; describe how tests are isolated; list the supply-chain defences and the attack each one blocks.

2.1 The packages

Every directory under packages/ is a separately published npm package under the @earendil-works scope, all at the same version (1.3.0 at the time of writing).

Directorynpm nameOne-line roleSource size
aipi-aiUnified multi-provider LLM API: messages, streaming, models, auth~27k lines
agentpi-agent-coreThe agent loop and Agent state holder~2.5k lines
coding-agentpi-coding-agentThe pi CLI: tools, sessions, compaction, extensions, terminal app, SDK~76k lines
tuipi-tuiTerminal UI library with differential rendering~19.5k lines
durablepi-durableDurable conversation, task and document runtime~22k lines
envpi-envRemote execution environments: a daemon deployed over SSH~2.3k lines
chordchordHost runtime for hot-reloadable plugins, replicated state, RPC over JSON links~13.5k lines
codemodepi-codemodeSandboxed JavaScript whose only capability is calling injected tools~3k lines
mcppi-mcpModel Context Protocol client~3.3k lines
telemetrypi-telemetryVendor-neutral telemetry contracts~0.6k lines
evalspi-evalsEvaluation harness (private tooling)~1.4k lines

Line counts exclude tests and were measured with wc -l over each src/ directory; they tell you where the complexity lives. The coding agent is by far the largest because it is the application; the libraries below it are deliberately small and reusable.

2.2 The dependency graph and build order

Each package's package.json lists the packages it needs at runtime. Reading only the internal edges gives this graph (an arrow means "depends on"):

package layers · internal runtime dependenciespackage layers · internal runtime dependencies

Each package depends only on packages in rows below it. The two highlighted runtimes are siblings: the CLI runs on pi-agent-core, while pi-durable brings its own harness.

The exact internal edges are:

  • pi-ai → pi-telemetry
  • pi-agent-core → pi-ai
  • pi-durable → chord, pi-ai, pi-codemode
  • pi-env → chord, pi-durable
  • pi-coding-agent → pi-agent-core, pi-ai, pi-codemode, pi-mcp, pi-tui
  • chord, pi-tui, pi-telemetry, pi-codemode, pi-mcp: no internal dependencies

Two observations. First, the graph is a directed acyclic graph (DAG): no package depends on itself through any path. Second, the coding agent and the durable runtime are siblings: both build on pi-ai, but the coding agent uses the simple in-process loop of pi-agent-core while pi-durable brings its own harness. That split is the reason the book has a separate Part VI.

A package must be built after everything it imports. The root package.json hard-codes the build sequence:

"build": "cd packages/chord && npm run build && cd ../tui && npm run build && cd ../telemetry && npm run build && cd ../codemode && npm run build && cd ../mcp && npm run build && cd ../ai && npm run build && cd ../durable && npm run build && cd ../env && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build"

That order — chord, tui, telemetry, codemode, mcp, ai, durable, env, agent, coding-agent — is a topological sort of the graph: every package appears after all of its dependencies. Many orders are valid (any order that respects the edges); this one is written by hand rather than computed.

2.3 Workspaces

The root package.json declares "workspaces": ["packages/*", …] plus a few example extensions. npm workspaces install all packages' dependencies into one shared node_modules at the root and symlink each workspace package there under its published name, so import … from "@earendil-works/pi-ai" inside pi-agent-core resolves to the local source tree, not a downloaded copy. One package-lock.json at the root pins the entire dependency tree.

During development the repository goes one step further and points imports at TypeScript source rather than built output. The root tsconfig.json has a paths map ("@earendil-works/pi-ai": ["./packages/ai/src/index.ts"], …) for the type checker, and vitest.base.ts mirrors it with resolver aliases for tests. Editing a file in ai/src is therefore visible immediately in the coding agent's tests with no rebuild.

2.4 TypeScript that Node can run directly

TypeScript is JavaScript plus static types. Classically a compiler (tsc) transpiles .ts files into .js. Recent Node versions can instead run .ts files by type stripping: deleting the type annotations and leaving everything else byte-for-byte in place. Stripping only works for syntax that has no runtime meaning. Pi therefore enables, in tsconfig.base.json:

"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,

erasableSyntaxOnly makes the compiler reject constructs that need code generation. AGENTS.md spells out the consequence: "no parameter properties, enum, namespace/module, import =, export =, or other constructs needing JS emit. Use explicit fields with constructor assignments." An enum, for example, compiles to a runtime object, so deleting it would break the program; Pi uses string-literal unions (type StopReason = "stop" | …) instead, which vanish cleanly.

verbatimModuleSyntax forces type-only imports to be written as import type, so a stripper knows they can be deleted. allowImportingTsExtensions lets source files import ./agent-loop.ts with the real extension, and rewriteRelativeImportExtensions makes tsc rewrite those to .js when it does emit a published build. A custom check, scripts/check-ts-relative-imports.mjs, enforces the convention from the other side: "Relative .js imports are not allowed in non-declaration .ts files". The engine requirement "node": ">=22.19.0" matches Node's built-in type-stripping support.

The compiler itself is the TypeScript 7 line ("typescript": "7.0.2" in the root devDependencies), and the import-check script uses its typescript/unstable/ast API to walk syntax trees. Type checking runs as tsc --noEmit over all packages at once, using the source paths map.

2.5 Entry points as cost contracts

A library's public surface is its package.json exports map. Importing one function through a large "barrel" file (index.ts that re-exports everything) can pull in and evaluate the whole package. scripts/check-entry-graphs.mjs opens with the rationale:

Entry points are cost contracts. … one stray export * can silently make a narrow entry drag an entire barrel: importing a 1-file pure function through a barrel costs ~37 MB of evaluated module graph, and nothing fails until someone measures a process. This walks the value-import graph of every declared entry point and enforces a budget per entry, so that regression fails at commit time instead.

You can see the same discipline in packages/ai/src/index.ts, whose header comment reads: "Core only, side-effect free: no generated catalogs, no provider factories, no api-registry, no OAuth implementations, no compat." Provider factories and API implementations live under separate subpath exports (@earendil-works/pi-ai/providers/*, @earendil-works/pi-ai/api/*). This enables tree shaking — a bundler can drop modules that nothing reachable imports — and keeps startup fast for programs that use one provider.

2.6 Checks, tests and continuous integration

npm run check chains: Biome (formatter and linter) with auto-fix, pinned-dependency check, runtime-dependency check, the import-extension check, entry-graph budgets, verification that the generated install lock is current, tsc --noEmit, and a browser smoke test (does pi-ai still bundle for browsers?). A Git pre-commit hook (Husky, .husky/pre-commit) runs the lockfile guard and npm run check before every commit.

Tests use Vitest in most packages and Node's built-in node:test runner in pi-tui. The root test.sh is worth reading as a lesson in hermetic testing — making a test run independent of the machine it runs on. It creates a fresh temporary root, then runs the suite under env -i (an empty environment) with only an allowlist of variables: a private HOME, TMPDIR and npm cache, LANG=C, LC_ALL=C, TZ=UTC, Git configured to never prompt, and PI_NO_LOCAL_LLM=1. No API keys leak in, so tests that would call paid providers skip themselves. Its cleanup trap refuses to rm -rf anything that is not the directory it created, checking a marker file first:

if [[ -d "$test_root" && ! -L "$test_root" && -f "$test_root/.pi-test-owned" ]]; then
	rm -rf -- "$test_root"
else
	printf "Refusing to remove unverified test directory: %s\n" "$test_root" >&2

The coding-agent's end-to-end suite uses a faux provider — a fake LLM that replays scripted responses — so the full agent can be exercised without network access or tokens (Chapter 5).

Continuous integration (.github/workflows/ci.yml) runs on every push and pull request to main: npm ci --ignore-scripts, build, check, build the pi-env daemon, test. A second job runs Pi's MCP client against the official MCP conformance suite and fails on regressions from a recorded baseline. The workflow's concurrency block cancels superseded runs for the same branch.

2.7 Supply-chain hardening

An npm install executes and ships other people's code, so a compromised dependency is a compromised agent — one that has your shell and your API keys. The README lists the defences; each blocks a specific attack.

MeasureMechanismWhat it defends against
Exact pinssave-exact=true in .npmrc; check-pinned-deps.mjs rejects rangesA malicious patch release being picked up automatically
Release cooldownmin-release-age=2 in .npmrcSame-day hijacked releases, which are usually caught within hours
Lockfile as ground truthPre-commit blocks lockfile changes unless PI_ALLOW_LOCKFILE_CHANGE=1Unreviewed transitive dependency drift
No install scriptsnpm ci --ignore-scripts in CI and documented installsCode execution at install time via postinstall
Install lock for userspackages/coding-agent/install-lock/, generated from the root lockfile, with an allowlist for lifecycle scriptsUsers resolving different transitive versions than were tested
AuditsScheduled npm audit --omit=dev and npm audit signaturesKnown vulnerabilities; tampered registry tarballs
Pinned CI actionsactions/checkout@3d3c42e… # v7.0.1 (commit SHA, not tag)A moved or hijacked Git tag in a third-party action
Publish what you testedRelease smoke tests and npm publication use the same packed tarballsDifferences between tested and published artifacts

2.8 Shipping: npm, standalone binaries and Nix

Pi reaches users three ways. The npm package @earendil-works/pi-coding-agent requires Node. Standalone executables are built by scripts/build-binaries.sh with bun build --compile, which bundles the JavaScript, its worker scripts (image resizing, the codemode worker) and a JavaScript runtime into one file per platform; the script disables Bun's automatic loading of bunfig.toml and .env from the current directory so a project cannot inject code into the binary at startup. The Nix flake builds from source offline; because a pure build cannot fetch the live model catalog, it uses a catalog revision pinned in nix/model-catalog.json, and Pi overlays newer catalog data at runtime.

That runtime overlay creates a compatibility problem: pi.dev serves each newly published model catalog to every released Pi at or above a minimum version, and those clients use it without updating their code. A catalog that needs code an old client lacks (for example, a model whose API its provider does not implement) would break them. The minimum lives in scripts/model-catalog-minimum-version.mjs (MINIMUM_PI_VERSION), to be bumped only in the same commit as a change that requires it. Before publishing, the catalog workflow runs npm run check:model-catalog-compat: scripts/check-model-catalog-compat.mjs takes the latest npm release, rebuilds the model list that release would end up with (its shipped built-in models overlaid by the new catalog), and type-checks that release's pi-ai source, extracted from its Git tag, against it. Because pi-ai's types tie each provider to the APIs it implements, an incompatible catalog surfaces as a compile error rather than as failed requests on users' machines.

2.9 Working rules for humans and agents

AGENTS.md is the repository's instruction file for both people and coding agents. Several of its rules are general engineering lessons: read files in full before wide changes; no any unless unavoidable; never edit generated code (models.generated.ts) — change the generator and regenerate; never hard-code key checks, add a configurable default binding instead; and, because several agent sessions may share one working tree, stage explicit paths and never run destructive Git commands such as git reset --hard, git stash or git add -A. The last rule is a concurrency-control policy for a shared resource (the working tree) enforced by convention instead of locks.

2.10 Concepts introduced in this chapter

  • Monorepo and workspaces. Many packages in one repository with one lockfile; the package manager links local packages so they can import each other by name. Gains atomic cross-package changes; costs tooling complexity.
  • DAG and topological sort. A directed graph without cycles can be linearized so every node follows its prerequisites (Kahn's algorithm repeatedly removes nodes with no remaining incoming edges). Build systems, task schedulers and Pi's durable task graph (Chapter 15) all rely on it.
  • Transpilation vs type stripping. Transpiling rewrites code; stripping only deletes type annotations. Stripping is faster and keeps line numbers, but forbids syntax with runtime semantics.
  • ES modules and package exports. "type": "module" makes .js files ES modules (import/export, evaluated once, statically analyzable). exports maps define the public entry points.
  • Tree shaking and side-effect-free modules. If importing a module has no side effects, unused modules can be dropped. Barrel files defeat this.
  • Hermetic tests. Tests that depend only on declared inputs (clean environment, fixed locale and time zone) are reproducible.
  • Supply-chain security. Your attack surface includes every transitive dependency, its install scripts, and your CI's third-party actions. Pinning, cooldowns, script blocking and signature audits shrink it.
  • Reproducible builds. Nix builds in a sandbox with pinned inputs so the same source produces the same output; network data must be pinned too.

Exercises

  1. Implement Kahn's algorithm in a short script that reads every packages/*/package.json, extracts internal dependencies and prints a build order. Does it match the root build script? List two other valid orders.
  2. Add a hypothetical dependency pi-tui → pi-ai. Is the graph still acyclic? What about pi-ai → pi-agent-core?
  3. Write a TypeScript enum and an equivalent string-literal union. Show what tsc emits for each and explain why only one survives type stripping.
  4. Explain, with a concrete scenario, why min-release-age=2 helps and what it costs when a security fix is released.
  5. Run ./test.sh (or read it) and list every variable the tests can see. Why is TZ=UTC there?

Source files for this chapter

Every quote is checked against these files at 4954d76. A change under package.json, tsconfig.json, tsconfig.base.json, vitest.base.ts, scripts, .github, nix, AGENTS.md, flake.nix flags this chapter for review.