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).
| Directory | npm name | One-line role | Source size |
|---|---|---|---|
ai | pi-ai | Unified multi-provider LLM API: messages, streaming, models, auth | ~27k lines |
agent | pi-agent-core | The agent loop and Agent state holder | ~2.5k lines |
coding-agent | pi-coding-agent | The pi CLI: tools, sessions, compaction, extensions, terminal app, SDK | ~76k lines |
tui | pi-tui | Terminal UI library with differential rendering | ~19.5k lines |
durable | pi-durable | Durable conversation, task and document runtime | ~22k lines |
env | pi-env | Remote execution environments: a daemon deployed over SSH | ~2.3k lines |
chord | chord | Host runtime for hot-reloadable plugins, replicated state, RPC over JSON links | ~13.5k lines |
codemode | pi-codemode | Sandboxed JavaScript whose only capability is calling injected tools | ~3k lines |
mcp | pi-mcp | Model Context Protocol client | ~3.3k lines |
telemetry | pi-telemetry | Vendor-neutral telemetry contracts | ~0.6k lines |
evals | pi-evals | Evaluation 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 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-telemetrypi-agent-core→pi-aipi-durable→chord,pi-ai,pi-codemodepi-env→chord,pi-durablepi-coding-agent→pi-agent-core,pi-ai,pi-codemode,pi-mcp,pi-tuichord,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" >&2The 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.
| Measure | Mechanism | What it defends against |
|---|---|---|
| Exact pins | save-exact=true in .npmrc; check-pinned-deps.mjs rejects ranges | A malicious patch release being picked up automatically |
| Release cooldown | min-release-age=2 in .npmrc | Same-day hijacked releases, which are usually caught within hours |
| Lockfile as ground truth | Pre-commit blocks lockfile changes unless PI_ALLOW_LOCKFILE_CHANGE=1 | Unreviewed transitive dependency drift |
| No install scripts | npm ci --ignore-scripts in CI and documented installs | Code execution at install time via postinstall |
| Install lock for users | packages/coding-agent/install-lock/, generated from the root lockfile, with an allowlist for lifecycle scripts | Users resolving different transitive versions than were tested |
| Audits | Scheduled npm audit --omit=dev and npm audit signatures | Known vulnerabilities; tampered registry tarballs |
| Pinned CI actions | actions/checkout@3d3c42e… # v7.0.1 (commit SHA, not tag) | A moved or hijacked Git tag in a third-party action |
| Publish what you tested | Release smoke tests and npm publication use the same packed tarballs | Differences 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.jsfiles ES modules (import/export, evaluated once, statically analyzable).exportsmaps 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
- 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 rootbuildscript? List two other valid orders. - Add a hypothetical dependency
pi-tui → pi-ai. Is the graph still acyclic? What aboutpi-ai → pi-agent-core? - Write a TypeScript
enumand an equivalent string-literal union. Show whattscemits for each and explain why only one survives type stripping. - Explain, with a concrete scenario, why
min-release-age=2helps and what it costs when a security fix is released. - Run
./test.sh(or read it) and list every variable the tests can see. Why isTZ=UTCthere?