Skip to content

Part II · Pi in Source / Ch. 07

Part II Chapter 7: The Address on the Household Register — pi-coding-agent ​

Xia A and Lao Z consult a session register whose glowing trail leads to the original workshop, where its resources and model are ready.

Chapter guide: Stop ⑥ on the source route (product layer). We enter the thickest package, pi-coding-agent, and read main.ts, core/agent-session-services.ts, and core/model-runtime.ts. What this stop has to do is follow one pi command from the keystroke to a running session, and get clear on how arguments get parsed, which directory resources are assembled against, and where the three modes fork apart.

Yesterday Xiao A started a session in project A. Today he is standing in project B's directory, trying to restore it. He types the command and suddenly stops: "Lao Z, this session was originally created in A. When I restore it, which directory do the project resources actually load against — the directory I'm starting from, or the session's own directory?"

"That is exactly the question most easily overlooked in the pi-coding-agent startup chain." Lao Z said. "The terminal layer can only display and take input. What assembles command-line arguments, the current directory, the session, the model, and the interaction mode into a product is pi-coding-agent. We answer your question by following the entry point; tools, extensions, and trust boundaries are unfolded in the next chapter."

All source evidence in this chapter comes from the upstream pi tag v1.0.0 (commit a13d35a742c6ef8462812a28fbe1d8c8b7431c32, package version 1.0.0, 2026-10-01). This chapter follows the main path of main() from argument parsing to mode dispatch, plus the session directory, service assembly, and the three run modes; it does not cover the package manager, first-run setup, migrations, HTML export, remote catalog refresh, or every CLI flag item by item — tools, extensions, and trust boundaries are left to Part II Chapter 8.

The entry point first handles commands that never start a session ​

Xiao A guessed: "The first line of main() must parse the arguments, right?"

"Quite the opposite. The very first line filters out things that should never start a session at all." Lao Z opened packages/coding-agent/src/main.ts:

ts
export async function main(args: string[], options?: MainOptions) {
  if (await runAuthCommand(args)) return;
  if (await handlePackageCommand(args, { extensionFactories })) return;
  if (await handleConfigCommand(args, { extensionFactories })) return;
  if (args[0] === "mcp") { /* lazily loaded MCP subcommand */ return; }
  const parsed = parseArgs(args);
  if (parsed.diagnostics.some((d) => d.type === "error")) process.exit(1);
  // set up the session and the runtime
}

"Read it section by section, and first separate 'command handled' from 'error occurred': the first two are normal early returns; only an error inside the argument diagnostics triggers a failure exit. Version, export, help, and the model list each have their own early-return branches too — don't read every return as a failed call."

"Does the order of these early-return branches matter?"

"It does," Lao Z said. "runAuthCommand is first, before even the bootstrap settings exist; it handles pi auth print-api-key, pi auth print-bearer-token, and pi auth check, and calls parseArgs once inside itself. handlePackageCommand, handleConfigCommand, and the pi mcp subcommand also come before parseArgs, because they handle subcommands like pi install xxx, pi config set, and pi mcp add, whose argument-parsing rules differ from the main command. Only after that comes the main argument parsing, --version, and --export. help and --list-models are special: they can only print once the runtime has been built — help has to list extension flags, list-models needs a modelRuntime — so those two process.exit(0) calls sit further down the file, not in this section."

"Why does pi update get special treatment on Windows?"

"The comment states it plainly: on Windows with Node, a process.exit(0) that fires during fetch() teardown asserts, so a successful pi update lets things drain naturally instead of forcing an exit." Lao Z said. "That is a platform special case; don't treat it as a general pattern. Note it only drains when exitCode === 0 && args[0] === "update"; a non-zero exit code still goes through process.exit(exitCode), so a failure never gets mistaken for success."

cli.ts calls main(args). Argument diagnostics, --version, export, the RPC file-argument restriction, and fork / session-ID validation all complete before the session runtime is created. Laying the early-return branches out side by side makes it faster to remember which returns mean success and which mean failure:

text
Early-return branches in main() (v1.0.0, in execution order):
| branch | trigger | result |
| --- | --- | --- |
| runAuthCommand | `pi auth print-api-key`/`print-bearer-token`/`check` | parses args once inside itself; check uses 0/1/2 to tell ready, not ready, invalid |
| handlePackageCommand | `pi install`/`update`/`uninstall`… | exits after merging exitCode |
| handleConfigCommand | `pi config set`… | return |
| mcp subcommand | `args[0] === "mcp"` | sets exitCode after lazy loading, then returns |
| argument diagnostics contain an error | `parseArgs` diagnostics | exit(1) |
| --version | `parsed.version` | prints VERSION then exit(0) |
| --export | `parsed.export` | exits with exit(0) after exporting |
| RPC with @file arguments | rpc and fileArgs non-empty | exit(1) |
| fork/session-id conflict | validateForkFlags / validateSessionIdFlags | exit(1) |
| help / --list-models | can only list once the runtime is built | exit(0) further down the file |

"There is an easy-to-miss point in this table: handlePackageCommand still merges process.exitCode before exiting, because a subcommand may already have set an exit code internally." Lao Z said. "Apart from the exit codes the subcommands set themselves, the real failure signals on the main path come down to two kinds: an error in the argument diagnostics, and an error in the runtime diagnostics later on."

The main call chain (excerpt) is:

text
cli.ts → main(args) → parseArgs()
  → createSessionManager()
  → createAgentSessionRuntime(createRuntime, ...)
  → createAgentSessionServices() → createAgentSessionFromServices()
  → InteractiveMode.run() | runPrintMode() | runRpcMode()

The excerpt omits migrations, diagnostics, and theme initialization. The mode is decided by resolveAppMode(parsed, stdinIsTTY, stdoutIsTTY); non-interactive output takes over stdout, and a piped stdin can turn interactive mode into print mode when RPC is not involved. resolveAppMode can also return a mode such as "json", which goes through the same print runner as "print"; only the output format is switched to JSON by toPrintOutputMode().

Find the session first, then settle the runtime directory ​

Xiao A pulled the topic back to the opening: "So at which step exactly does the directory get decided?"

"Look at the session-creation part of main()." Lao Z said:

ts
let sessionManager = await createSessionManager(parsed, cwd, sessionDir, startupSettingsManager);
const missingSessionCwdIssue = getMissingSessionCwdIssue(sessionManager, cwd);
if (missingSessionCwdIssue) {
  if (appMode === "interactive") { /* prompt and open */ }
  else { console.error(...); process.exit(1); }
}

"The detailed state sequence is: startup cwd → bootstrap settings → sessionDir → SessionManager → session cwd → runtime settings/resources/models → session runtime → mode. main() creates a temporary settings manager from the startup directory and uses it to help determine the session directory and to create / open the SessionManager. It then obtains the session directory through sessionManager.getCwd(), and inside createRuntime uses that directory to build the runtime settings, resources, and model scope."

Metaphor: the address on the household register

A session is like a person: its metadata is the household register, and cwd is the registered address. Restoring a session is not pulling him out of your pocket; it is going back to the address on his register to get things done — it does not follow wherever your feet happen to be standing.

"So the answer is: when you restore a session that came from another project, project settings, resources, Provider registration, and model resolution must not bind to the directory where you typed the command; they follow the directory the session registered." Lao Z said. "If the session is missing its cwd, interactive mode calls promptForMissingSessionCwd() to let the user choose; non-interactive mode prints MissingSessionCwdError and exits with failure. Here it is better to take one explicit error path than to silently guess a directory."

"How exactly is 'missing cwd' determined?"

"Look at getMissingSessionCwdIssue(): it first confirms there is a session file — in-memory sessions have none — then takes sessionManager.getCwd() and uses existsSync to check whether that directory still exists." Lao Z said. "If it doesn't, it returns the issue. fallbackCwd is the current process.cwd(). Interactive mode pops a chooser and lets the user pick between 'keep using the current cwd' and 'cancel'; afterwards it reopens with SessionManager.open(..., selectedCwd) and rewrites the cwd. Non-interactive mode refuses outright, because there is nobody there to choose."

"Then how do all those --session/--resume/--fork flags split inside createSessionManager?"

"It is a priority chain that short-circuits in order." Lao Z said:

text
createSessionManager() priority chain (short-circuit evaluation):
  noSession / help / listModels → inMemory (nothing written to disk)
  fork → first check whether the target id already exists → resolve the path → forkFrom
  session → resolve the path: path/local opens directly;
            global prints "the session is in another project" and asks whether to fork into the current directory;
            not_found errors out and exits
  resume → chooser (local list + global list) → open
  continue → continueRecent
  sessionId → exact or prefix match inside this project → open; otherwise prompt and create a new one
  fallback → SessionManager.create

"Be clear about what each branch's exit means: in fork, checking whether the target id already exists guards against an id collision, and finding one exits; in session, the global branch asks 'fork into the current directory or not', and choosing 'no' quietly leaves with process.exit(0); in resume, an empty return from the chooser means the user cancelled on purpose." Lao Z said. "All of these exits are 'user cancelled' or 'not found' — they are not bugs."

"What makes 'global matching' work?"

"resolveSessionPath: if the argument contains /, \, or ends in .jsonl, it is resolved as a file path first; otherwise SessionManager.findById looks for an exact id inside this project, and the comment says this only needs to read the session headers. If that fails, SessionManager.list(cwd) does a prefix match, and only if that also fails does listAll scan across all projects. So 'found in another project' is what enters the global branch; project-first is simply part of the search order."

The order of service assembly ​

"Once the session directory is settled, how do the services get assembled?"

"createAgentSessionServices() creates the long-lived service objects first, then loads resources, then refreshes models." Lao Z opened packages/coding-agent/src/core/agent-session-services.ts:

ts
const modelRuntime = await ModelRuntime.create({ authPath, modelsPath, signal: options.modelRuntimeSignal });
const resourceLoader = new DefaultResourceLoader({ cwd, agentDir, settingsManager });
await resourceLoader.reload(options.resourceLoaderReloadOptions);
await modelRuntime.refresh({ allowNetwork: false });

"Note that allowNetwork: false is only an argument at this call site; don't read it as a product-wide promise that pi never touches the network. Afterwards createAgentSessionFromServices() is the one that calls createAgentSession(), so tools and model scope can be built against the already-resolved target cwd."

"Why deliberately no network here?"

"Startup has to be fast, and it has to work offline." Lao Z said. "Inside create() there is a separate allowModelNetwork switch that controls the first catalog fetch; refresh({ allowNetwork: false }) only reads the local cache to build the model table. The real network refresh happens later: interactive mode does it after the TUI is up, RPC mode runs modelRuntime.refresh({ signal }) in the background before dispatch, aborts after 15 seconds, and silently swallows a failure. main() also gives ModelRuntime.create, resolveModelScope, and --list-models each a 15-second timeout signal."

"What does DefaultResourceLoader manage?"

"It manages the load results and diagnostics for extensions, skills, prompt templates, themes, project context, and the system prompt (resource-loader.ts). ModelRuntime maintains the model registry, configuration, and auth state (model-runtime.ts); functions such as resolveModelScope() and findInitialModel() (model-resolver.ts) turn configuration or command-line patterns into usable models. This services-first, session-second separation lets the caller resolve model and resource questions against the target cwd before constructing the Agent — though the source never declares it the only workable architecture."

"How do I trace when Providers get registered?"

"There are three loops inside createAgentSessionServices: first drain pendingProviderRegistrations by calling modelRuntime.registerProvider, then drain pendingNativeProviderRegistrations by calling registerNativeProvider, and v1.0.0 adds draining pendingVirtualModelRegistrations by calling registerVirtualModel; each one is wrapped in try/catch that turns the exception into an error diagnostic." Lao Z said. "ExtensionRunner.bindCore() contains one more flush, which adds another round when session actions get bound. So Provider registration can appear at three points: queued during loading, landed at service creation, appended after binding. A failed registration does not crash the process; it becomes a diagnostic, and that diagnostics.some(type==='error') block at the tail of main() decides whether to exit. In v1.0.0, interactive mode with no error does not print diagnostics during startup; it hands the de-duplicated startupDiagnostics to InteractiveMode to display in the interface. Non-interactive mode, or any case with an error, prints to stderr as usual."

"Wait — how should 'queued during loading' be understood exactly?"

"Look at the default implementation of createExtensionRuntime(): during loading, registerProvider does not register directly, it pushes onto pendingProviderRegistrations." Lao Z said. "The reason is that extension loading happens before the model registry is available: registerProvider needs to write into ModelRuntime, and ModelRuntime is not created until inside createAgentSessionServices. The flush in bindCore works the same way: it lands everything queued before binding into modelRegistry in one pass, with exceptions going to emitError. 'Queueing' and 'landing' are two pieces of code at two moments, both verifiable in this snapshot."

Startup states and failure semantics ​

StageKey stateSuccess exitError / boundary exit
Argumentsparsed.diagnosticscontinue to select the modean error diagnostic exits with 1
SessionSessionManager and cwdconstruct the runtimemissing cwd in non-interactive mode exits with 1
Resourcesloader results and diagnosticsregister Providersan extension error blocks startup
ModelsModelRuntime / scoped modelscreate the sessionno model in non-interactive mode exits with 1
ModeappModedispatch to interactive/print/rpcthe RPC argument restriction returns early

The modes are different exits from the same runtime ​

Xiao A: "Interactive, print, RPC — are those three different Agents?"

"They are not. They share the session, model, and resource services assembled earlier." Lao Z opened the dispatch at the tail of main.ts:

ts
if (appMode === "rpc") await runRpcMode(runtime);
else if (appMode === "interactive") await interactiveMode.run();
else {
  const exitCode = await runPrintMode(runtime, options);
  if (exitCode !== 0) process.exitCode = exitCode;
}

"Print mode hands a non-zero result to Node's process.exitCode; the interactive path runs InteractiveMode; RPC avoids mixing ordinary stdin file arguments into protocol input. Shared services do not mean every mode accepts the same startup conditions: print or RPC mode exits with an error when no usable model is available; an extension-load diagnostic of type error at startup also prevents running on, and gives a hint on how to disable the extension."

"Is appMode fixed from the very beginning?"

"Not necessarily." Lao Z said. "resolveAppMode derives an initial value from parsed.mode, --print, and whether stdin/stdout are TTYs. But there are rewrites later: if a non-RPC mode reads piped stdin, appMode === "interactive" is changed to "print", because content fed in through a pipe cannot drive an interactive interface. Then there is trustPromptMode, which when help or listModels is set forces "print" for the trust prompt, so one metadata command does not bring up a TUI."

"Why does print mode use process.exitCode instead of process.exit()?"

"So Node can finish the event loop naturally and flush buffered output." Lao Z said. "runPrintMode returns an exit code that gets assigned to exitCode; after the function returns, restoreStdout() puts back the stdout it had taken over, and the process exits normally. Calling process.exit() directly may truncate stdout that has not finished writing — a known Node pitfall."

"'stdout taken over' — what mechanism does that hook into?"

"Look at takeOverStdout/restoreStdout in output-guard.ts." Lao Z said. "When the mode is not interactive, it calls takeOverStdout(), which swaps process.stdout.write for a restorable implementation, so print-mode output does not go through the TUI's ProcessTerminal rendering path and the two output systems do not fight each other. restoreStdout puts the original implementation back when the mode ends, which pairs exactly with process.exitCode — print mode's cleanup depends on this pair."

"I saw the three dispatch branches at the tail of main(), but why is RPC singled out with 'avoid mixing ordinary stdin file arguments into protocol input'?"

"Because RPC mode uses stdin to carry JSON-RPC. The readPipedStdin block explicitly skips appMode === 'rpc', and parsed.mode === 'rpc' && parsed.fileArgs.length > 0 errors out and exits directly, so no @file argument can get in. Of the three modes, RPC has the strongest claim on stdin and the strictest startup conditions."

Configuration and the failure boundary ​

Xiao A: "What about configuration precedence? People online say there are several layers."

"Don't treat a fixed 'several layers of precedence' from older documentation as a fact about this snapshot." Lao Z said. "packages/coding-agent/src/config.ts provides resolution functions for the package directory, application name, config directory, models, auth, session, and resource paths; the actual settings merging and diagnostics are handled by callers such as SettingsManager, so go by the current settings-loading code and the specific field."

"How many SettingsManager instances does this startup chain create?"

"Three, each owning one stage." Lao Z said. "The first is bootstrapSettingsManager, built with the startup cwd and projectTrusted: false, used only to look up sessionDir and to run first-time setup. The second is startupSettingsManager, still on the startup cwd but with trust, used for settings reads during session selection. The third is the one actually bound to the target cwd, runtimeSettingsManager, created inside the createRuntime closure using sessionCwd; that is the one used while the session runs. Three managers for three stages — don't treat them as one configuration rebuilt several times."

"Why is bootstrapSettingsManager specifically projectTrusted: false?"

"Because all it has to do is look up the session directory and apply the HTTP proxy, and neither of those needs to trust the project's contents, so it is built with minimum trust. startupSettingsManager appears a bit later and takes part in the --resume chooser and first-run setup, with default trust. runtimeSettingsManager's projectTrusted is computed inside createRuntime and may be rewritten by trustOverride, the trustStore cache, or the result of resolveProjectTrusted()." Lao Z said. "The same SettingsManager.create is called three times, with different arguments and different purposes; when tracing the startup chain, keep the three apart."

The payoff at startup is that command parsing, restoration, service creation, and mode dispatch are concentrated in one place; the cost is that the entry point must handle a large number of early returns, diagnostics, and cwd changes. This chapter did not cover the package manager, first-run setup, every migration, HTML export, remote catalog refresh, or every CLI flag item by item.

Summary ​

What main() in pi-coding-agent does looks trivial, but every step answers a concrete question. The entry point first diverts package management, configuration, version, and export — commands that should not start a session — and each has its own early-return branch; do not misread those returns as failed calls. The real main path begins by creating the session manager, and there is an easily overlooked decision right there: when restoring a session, project resources, model resolution, and Provider registration are assembled against the session's own cwd, not the directory you happened to be in when you typed the command. The session's metadata records which project it originally belonged to, so restoring it means going back to that project to get the work done. If the session is missing its cwd, interactive mode asks the user, and non-interactive mode errors out directly: refusing to guess the directory here is a visible trade-off in favour of restore safety.

Service assembly has a strict order: create the long-lived service objects first, then load resources, then refresh model availability, and only then construct the session. The three run modes — interactive, print, RPC — share one assembled runtime, not three independent Agents, but sharing does not mean they accept the same startup conditions: print or RPC mode exits with an error when no model is available, and an extension-load diagnostic of type error prevents running on too.

Another point in this chapter worth remembering is the semantics of the early-return branches: version, export, help, and the model list are all normal exits, and handlePackageCommand even has to merge process.exitCode; the only real failure signals are errors in the argument diagnostics and errors in the runtime diagnostics. createSessionManager is a chain that short-circuits by priority: noSession/help take the in-memory session, fork guards against an id collision, session asks whether to fork into the current directory when it meets a cross-project session, resume goes through the chooser, continue takes the most recent one, and only the fallback creates a new session. Each branch's exit carries its own meaning and must not be lumped together as an anomaly. SettingsManager appears three times in the startup chain — bootstrap, startup, runtime — each owning one stage with a different trust argument; treat them as one configuration rebuilt several times, and you will misread how the directory and trust are bound together. Reading the entry section from top to bottom once, the easiest thing to grasp is not any particular flag but how the "parse, assemble, dispatch" main line stitches early returns, cwd changes, and error diagnostics into one whole.

Source Walkthrough ​

Enter at main() in packages/coding-agent/src/main.ts, and mark the directory changes from createSessionManager() to createAgentSessionRuntime(); then read the two creation functions in agent-session-services.ts and confirm the order of resource reload, Provider registration, model refresh, and session construction. Finally compare modes/index.ts with the three dispatch points in main() and verify where each mode obtains its runtime.