| title | Runner architecture and data flow |
|---|---|
| description | Source map for routing, lifecycle, security boundaries, and screen resolution. |
The CLI picks a host and hands it the launch values.
runTui owns the interactive lifecycle: start the
TUI, build and assign the session, run the readiness hooks, settle the intro,
and call runProgram once, with the flow's screens answering its steps through
tuiWorkflow.
store.ts runs each flow step's onInit when the
TUI starts. runReadyHooks awaits the program's ProgramConfig.onReady once,
after the real session is assigned, and marks detection complete. Keep
session-dependent detection in onReady. Noninteractive execution has its own
host, runHeadless, which builds its own
session store and calls runProgram with no workflow; runProgram then runs
the same onReady as its detection, or ciPreRun when the program sets one.
runProgram runs detection when the
store hasn't, resolves the program's run definition, applies its readiness,
login, approval and flag policy, and calls runAgent for each run step the host
confirms and then the program's own run, writing everything into the caller's
SessionStore.
runner/index.ts resolves the route from
RunConfig.routing, calls shared bootstrap, dispatches the sequence, and
flushes the scanner report on cleanup.
| Layer | Source and responsibility |
|---|---|
| Bootstrap | shared/bootstrap.ts: logging targets, the gateway mint and the scan-triage classifier; health, settings, login and flags arrive resolved from runProgram |
| Switchboard | switchboard/index.ts: resolve sequence, harness, model and effort override |
| Linear sequence | sequence/linear.ts: one conversation, skill/prompt assembly, post-run hooks and outro |
| Orchestrator | orchestrator-runner.ts: seed plan, task queue, focused conversations, handoffs and completion |
| Harness | harness/types.ts: SDK boundary, implemented by Pi and Anthropic |
The contribution policy is Pi by default, orchestration preferred. Linear remains useful for very simple tasks and legacy support. Existing bindings and composed sub-runs still use it; the Anthropic SDK remains a supported legacy fallback. These are separate choices: sequence describes the work shape, harness selects the SDK, and the model selects a gateway/provider route.
Read ProgramRun and AgentRunDefinition for exact signatures.
| Surface | Current consumer |
|---|---|
customPrompt, abortCases |
Linear prompt assembly and abort handling |
postRun(session, credentials) |
Linear success path, before outro |
buildOutroData(session, credentials) |
Linear custom outro; otherwise defaults from run metadata |
buildOutroNextSteps(session, ...) |
Orchestrated outro bullets |
agentFlow and task skill variants |
Orchestrator flow selection and task execution |
ProgramConfig.binding |
The program's route; DEFAULT_BINDING when unset |
ProgramConfig.runSteps |
Explicit program composition in the outer lifecycle |
ProgramConfig.requires |
Dependency metadata; it does not execute prerequisite programs |
Do not migrate a linear program merely by changing its binding if it depends on these hooks. Inspect the orchestrator's flow and completion path instead. Metrics is a current Pi/orchestrator example. Native command modules still need registration in runCli; screen sequences derive from each program's TUI flow.
resolveBinding(ctx, role?) is the routing seam. Read its exported types rather
than copying their fields into another document. It receives the program, flag
snapshot/payloads, composition state, and development overrides, returning the
binding and stamping a trace of the selected precedence rungs.
- Harness/model: development CLI override, declared flag route, the program's
binding (
DEFAULT_BINDINGwhen it declares none). - Sequence: composed linear clamp, development CLI override,
runTaskcapability clamp, program flag route, sequence experiment, binding/default.
Harness and
sequence contain the
exact chains. Published builds omit CLI overrides. RUN_SURFACE can disable
harness experiments; static bindings and harness capabilities also affect
resolution. Composed sub-runs remain linear even when a CLI override requests
orchestration.
Effort resolves in two stages: the binding supplies an override, then modelCapabilities applies capabilities and defaults. All selected models and efforts must also be admitted by the minted token and gateway. Local routing cannot bypass that external policy; see the model admission checklist.
Flags belong in
switchboard/flags.
Experiments declare their program scope; malformed payloads yield no experiment
route. Reuse the
switchboard tests
and experiment tests to check full bindings and isolation of unrelated programs;
they run on synthetic programs, and each program pins its own binding in its
__tests__. Do not add a second flag-reading path inside a harness or sequence.
The gateway admits scoped tokens, models, efforts, and required prompt policy. Wizard's local tool boundary separately restricts operations on the user's project. Local commandments provide model guidance; they are not an enforcement mechanism.
- agent-interface.ts configures the Anthropic SDK's tool permissions, sandbox, and gateway transport.
- bash-fence.ts is the shared bash
allowlist behind
wizardCanUseTool, including the onermrule: named files inside the project root. In effect it gates Pi, since the Anthropic SDK pre-allows Bash under its OS sandbox. - yara-hooks.ts adapts warlock scans to SDK tool hooks.
- Pi security adapts shared permissions and scanning to Pi tool-call/result events, including blocking, violation latching, and tool-call limits.
- Pi harness explicitly supplies tools, scrubs shell environments, and disables project-controlled extensions/context loading.
- triage-provider.ts supplies gateway-backed classification for scanner findings.
Scanner rules live in warlock; Wizard owns how its returned categories, severities, and actions affect execution. Scanner failures must not silently permit unsafe operations, but not every blocked call terminates the run. Check each adapter's state machine when changing rejection handling. Preserve useful rejection diagnostics without logging secret content.
Both SDK paths use a scoped gateway token minted through gateway-session.ts, not the user's raw OAuth credential as a model API key. Rejection and refresh behavior belong at that seam; do not restore a legacy-gateway fallback to bypass admission.
secret-vault.ts stores user-provided
values in memory and returns opaque references. Sensitive wizard_ask answers
become secret:<uuid> refs; set_env_values resolves the ref host-side when
writing. Follow wizard-tools and the Pi
adapters when adding a secret-consuming tool. Return references and metadata to
the agent, never the raw value. References are session-scoped, not durable
credentials.
Programs never use the UI. A program's run gets a RunnerContext and its
ciPreRun gets a CiRunnerContext, both from
runner-context.ts, and it reads a
ProgramSession. Each host reports through its own renderer, with no UI
interface shared between them: TUI code takes the
WizardStore it reports through as an argument,
headless prints through its
LoggingUI, and CLI commands
through consoleLog (@shared/console-log). There are no process-global sinks.
An agent run's debug lines are info log progress, which the host's progress
handler shows. An abort takes its presenter as an argument:
abortOnScreens in the TUI, printAbortOutro in
headless and before the TUI mounts. runProgram writes run state into the
session store; displayProgress updates the TUI's display state; LoggingUI
prints log lines for --ci and headless runs. A missing TTY does not
automatically mean an arbitrary caller prints log lines; snapshot CI drives Ink
in a PTY. requestQuestion and task notices are supported interactions, not
console prompts to invent in business logic.
Harness adapters translate SDK messages, status markers, task updates and tool
activity into AgentProgress events. The agent never renders:
agent-progress.ts writes each event to
the store. Anthropic message processing lives in
agent-interface.ts; Pi uses its own
session event handlers. Orchestrated tasks also have queue and handoff state. Do
not assume all harness output passes through handleSDKMessage.
Session changes go through explicit store setters. They emit updates,
re-evaluate gates, detect transitions, and refresh rendering. The
router routes a failed agent run to the
mint-failure handoff, then resolves overlays, then the first visible incomplete
screen from screen-sequences.ts.
Those sequences are projected from each registered program's or tool's flow,
which flowOwner looks up by id: the TUI
tool registry first, then the TUI program registry. Change the state/predicate
that represents progress rather than adding imperative navigation.
Remote PostHog tools, local wizard tools, and optional framework MCP servers are separate surfaces. The Anthropic SDK's MCP integration and Pi's adapter expose them differently; inspect the selected harness rather than assuming identical tool names or discovery. Context-mill supplies skills and flow/task prompts.
Middleware provides opt-in message/phase instrumentation. The linear sequence creates the benchmark pipeline. Inspect the actual consumer before extending instrumentation to another sequence or harness.
Each layer is a TypeScript project, and pnpm typecheck is tsc -b on the root
tsconfig.json, which only lists them. The build
orders itself by references and emits declarations into .tsbuild/. A layer's
tsconfig.json extends tsconfig.no-tui.json
(the TUI extends tsconfig.base.json), holds
its folder with its tests, and references only the layers it may import.
Importing a file of a project it doesn't reach fails with TS6307. A few ESLint
rules close the paths the compiler can't see. CI runs pnpm typecheck in
build.yml.
tsconfig.base.json holds the compiler options and the aliases, entries only:
@env, @shared/*, @utils/*, @host/*, @agent, @agent/types,
@programs, @programs/types, @programs/<id>, @tools, @tui, @headless
and @cli. Three deep aliases stay for the layer that owns them: @agent/*,
@programs/* and @cli/* in tests and source, and @tui/* in the TUI.
extends copies compilerOptions but not references, and replaces paths
whole, so the projects that need other aliases list them again.
| Layer | Project | References |
|---|---|---|
| env | src/tsconfig.json: env.ts |
Nothing |
| shared | src/shared/tsconfig.json |
env |
| host | src/host/tsconfig.json |
env, shared |
| agent | src/agent/tsconfig.json |
env, shared |
| tools | src/tools/tsconfig.json |
env, shared, agent |
| shared program code | src/programs/tsconfig.core.json |
env, shared, agent |
| one program | src/programs/<id>/tsconfig.json |
The shared program code |
| programs entry | src/programs/tsconfig.json: index.ts, types.ts, program-registry.ts, run-program.ts, detect-program.ts, detect-map.ts, __tests__ |
The shared program code and every program |
| headless | src/headless/tsconfig.json |
env, shared, host, agent, programs entry |
| TUI core | src/tui/tsconfig.core.json |
env, shared, host, agent, programs entry, tools |
| one TUI program | src/tui/programs/<id>/tsconfig.json |
The TUI core |
| one TUI tool | src/tui/tools/<id>/tsconfig.json |
The TUI core |
| TUI entry | src/tui/tsconfig.json: index.ts, programs/index.ts, tools/index.ts, playground/ and the core's tests |
The TUI core, every TUI program and tool |
| cli | src/cli/tsconfig.json |
env, shared, host, agent, programs entry, tools, TUI entry, headless |
| bin | tsconfig.bin.json: bin.ts, main.ts, tsdown.config.ts, vitest.config.ts |
env, shared, cli |
| harness | e2e-harness/tsconfig.json: e2e-harness/, scripts/ and docs/examples/ |
env, shared, host, agent, tools, programs entry, TUI entry, headless |
| mocks | __mocks__/tsconfig.json |
Nothing |
The host layer, src/host, is how the hosts end a run: startHostExit,
wizardAbort and registerShutdown in @host/wizard-abort. A host resolves
its run's exit code; wizardAbort shows a decided failure's outro through the
presenter its caller passes and hands the code to it, and only the CLI calls
process.exit. Headless, the TUI, the CLI and the harness reference it. The
agent and the programs don't, so an import of it there fails with TS6307.
The TUI, headless and the CLI each have one entry, their index.ts. Each entry
function loads its module on first call, so importing an entry is as cheap as
importing its launch types:
| Entry | Exports | Imported by |
|---|---|---|
@tui |
runTui with TuiLaunch and TuiLaunchChoices, runTuiTool with TuiToolLaunch, renderFamilyPicker with FamilyPickerOption, runPlayground, tuiProgramFlow with FlowStep, the WizardStore type, ScreenId, Overlay, each program's and tool's screen ids, tuiScreenIds, createTuiStore, readTuiState and mountScreens |
The CLI and the e2e harness |
@headless |
runHeadless, HeadlessLaunch, NonInteractiveMode and modeLabel |
The CLI |
@cli |
runCli |
main.ts |
The programs and the TUI each split in two so a folder can't import its sibling.
The core (tsconfig.core.json) holds the shared code, and each program, TUI
program and TUI tool folder is its own project that references it, so a
sibling's folder fails with TS6307. The entry project (tsconfig.json beside
the core) holds the registry and references the core and every folder, so a
registry imports the folders and nothing imports the registry back. The entry
project also holds the layer's tests, since a test may import any part. The TUI
core calls the registries through two signature stubs, types/tui-programs.d.ts
and types/tui-tools.d.ts, that its own paths map to @tui/programs/index
and @tui/tools/index; it reads a flow through flowOwner, so a tool's id
never falls back to the skill flow.
A new program, TUI program or TUI tool folder needs its own tsconfig.json,
copied from a sibling, and a references line in its entry project. See
adding-skill-program.
Outside the TUI, ink, ink/*, react, react/*, @inkjs/ui and
ink-testing-library map, in tsconfig.no-tui.json and tsconfig.bin.json, to
types/tui-only.fence. That resolves to types/tui-only.d.fence.ts, and
TypeScript rejects every import of a .d.ts file with an arbitrary extension
while allowArbitraryExtensions is off, so never turn it on. A named, default,
namespace or side-effect import, import(), typeof import() and export *
all fail with TS6263. A missing fence file falls back to the real package, so
keep it. Three more options close the rest:
| Option | Effect |
|---|---|
noUncheckedSideEffectImports |
import 'x' resolves like any other import, so it meets the fences and fails when nothing resolves |
moduleDetection: "force" |
Every .ts file is a module, so declare module 'ink' in one augments the fence and fails with TS6263 |
resolveJsonModule: false |
A JSON import fails with TS2732. The MCP prompt copy is a TS data module, mcp-role-prompts.copy.ts |
A project reaches what it references, and what those reference. TypeScript
doesn't stop that second hop, and paths can't stop a relative path, so
tsconfig.bin.json maps only @cli and the shared aliases for bin.ts and
main.ts, and ESLint closes the rest.
| Import | Fails with |
|---|---|
A layer's file from a layer that doesn't reference it: @programs/audit in the agent, @headless in the TUI |
TS6307 |
| A sibling program or TUI program folder, or a registry from a program or TUI folder | TS6307 |
An alias the project doesn't map: @tui/run in the CLI, @tui, @headless or @agent in bin.ts |
TS2307 |
ink, react, @inkjs/ui or ink-testing-library outside the TUI, in any form |
TS6263 |
A relative path that leaves the layer's folder: ../tui/run in the CLI, ../src/programs/audit/x in the harness |
ESLint no-restricted-syntax |
A deep alias outside its layer: @programs/audit/x or @agent/runner/x in the harness |
ESLint no-restricted-syntax |
TS6307 lands on the import line, and again at the imports of every file that import pulls in. tsc sorts errors by path, so an import of another program or the registry can print dozens of errors in other program folders before the one in your file.
TypeScript can't see these paths, so pnpm lint rejects each with a rule in
.eslintrc.cjs:
| Rule | Rejects | Why the compiler can't |
|---|---|---|
no-restricted-imports, patterns .tsbuild and node_modules |
An import or re-export whose path has a .tsbuild/ or node_modules/ segment |
paths never sees a relative path, so ../../node_modules/ink/build/index.js compiles past the fence |
no-restricted-syntax on import() and typeof import() |
The same paths in import('...'), a template-literal import(), typeof import('...') and vi.importActual or vi.importMock |
The same, and no-restricted-imports sees only import and export declarations |
no-restricted-imports, paths module and node:module, and no-restricted-syntax on their import() and typeof import() |
Any import, re-export, import() or typeof import() of module or node:module |
Its loaders, such as createRequire, return any and load what no project resolves. Two uses carry a disable line with their reason: the SDK's CLI path in src/agent/agent-interface.ts and the @xterm/headless CJS entry in e2e-harness/tui-capture.ts |
no-restricted-globals, require |
A bare require |
@types/node declares it, so TypeScript types a call as any and resolves no path. A local require from createRequire is not the global |
@typescript-eslint/triple-slash-reference, path: 'never' and types: 'never' |
/// <reference path="..." /> and /// <reference types="..." /> |
It loads a declaration file past the project's file list, and a declare module in that file beats paths |
no-restricted-syntax, one override per layer folder and depth |
A relative import that leaves its layer's folder, and a deep alias outside its own layer | A project reaches every file of a project it references, by any path. @agent/types and each @programs/<id> stay allowed |
Neither the compiler nor these rules stop an import() of a computed path,
which TypeScript types as any, or a string a test hands vi.importActual
that has no .tsbuild/ or node_modules/ segment, which no project resolves. ESLint ignores scripts/, so the rules don't reach
it; the harness project still compiles it against its references only.
Don't widen a layer's paths or references to make an import compile, and
don't route an import through one of these gaps. Move the code, or add the name
to the entry it should come through. A test of another layer's internals belongs
in that layer's tests. A test mocks another layer through the entry it imports.
The factory's value must match the module's exports; a partial stub of an
object or a function with a narrower return is cast as never. A harness test reads a TUI program's flow through
tuiProgramFlow on @tui.