Skip to content

Part II · Pi in Source / Ch. 08

Part II Chapter 8: Queuing at the Checkout — pi-coding-agent ​

Xia A queues two folders on one conveyor for Lao Z to process in turn, while a separate checkpoint examines an extension module.

Chapter guide: Stop ⑦ on the source route (product layer). Still inside pi-coding-agent, but focused on three subsystems under core/, reading tools/file-mutation-queue.ts, extensions/runner.ts, and project-trust.ts. This stop has to draw three lines that are easy to confuse: how writes to the same file queue up, why a successful extension load is not trust, and what project trust actually stands in the way of.

The startup chain has assembled the session and the resources, but two knots still sit in Xiao A's mind. The first: he had the Agent read the same file twice in a row and then modify it, and it suddenly hit him — if two write requests arrive at the same file almost at the same time, will they overwrite each other? The second: he put an extensions directory in his project, and the extension really did load successfully — but does "loaded successfully" mean "can be trusted"?

"The answers to both questions are in the code," Lao Z said. "One is about how file writes queue up, the other about how extensions and project trust get wired in. To be clear up front: these mechanisms do not add up to a complete sandbox."

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 tool-output truncation, same-file write queuing, the discovery-loading binding of extensions, and the decision order of project trust. It does not cover the parameter schema of every built-in tool, extension package management, concrete sandbox implementations, or third-party extension auditing.

Built-in tools enter the loop through the session ​

Xiao A put the tools question on the table first: "Where do the built-in tools come from? Are they all handed to the model?"

"First, look at how tool output keeps its size under control." Lao Z opened packages/coding-agent/src/core/tools/truncate.ts:

ts
export const DEFAULT_MAX_LINES = 2000;
export const DEFAULT_MAX_BYTES = 50 * 1024;
export function truncateHead(content: string, options = {}): TruncationResult {
  // Returns metadata such as content, truncated, totalLines, totalBytes
}

"These two defaults are hardcoded — that is a source fact in this snapshot — but the function accepts options, so you cannot extrapolate them into an absolute limit for every call. The key is that the result carries truncation metadata, so the layer above can tell apart 'the command only produced this much output' and 'the display only kept this much output'."

"How are the two limits computed?"

"Two independent lines; whichever is hit first does the cut," Lao Z said. "splitLinesForCounting counts lines, Buffer.byteLength(content, 'utf-8') counts bytes. Either maxLines or maxBytes exceeding its limit triggers the cut, and the truncatedBy field records whether it was 'lines' or 'bytes'. File reads use truncateHead to keep the head; command output uses truncateTail to keep the tail: the end of a log usually holds the conclusion, the beginning of a file usually holds the declarations — the 'most informative' direction differs on the two sides."

"So the boundary behavior of truncateHead and truncateTail differs too?"

"Exactly — that is why they are not interchangeable," Lao Z said. "truncateHead promises never to return a partial line: when the very first line already exceeds the byte limit, it returns empty content and sets firstLineExceedsLimit: true. truncateTail allows the exception. It accumulates backwards from the end of the output, and when the last line is too big, it slices a maxBytes segment off the end of that line and raises lastLinePartial — because the last line of bash output is often the most important error message. Even 'which end you count the cut from' changes the semantics of the metadata."

Now look at OutputAccumulator.snapshot():

ts
snapshot(options = {}): OutputSnapshot {
  const truncation = truncateTail(this.getSnapshotText(), ...);
  if (options.persistIfTruncated && truncation.truncated) this.ensureTempFile();
  return { content: truncation.content, truncation, fullOutputPath: this.tempFilePath };
}

"It separates the rolling raw bytes, the displayable tail, and possibly the full-output path. v1.0.0 also added readFullOutput(maxBytes), letting a caller that can take more content read the full output: past the limit it keeps half from the head and half from the tail, replaces the middle with an omission marker, and cuts on character boundaries. The normal path returns a snapshot that fits into the context; on overflow it still preserves the fact of truncation. A temp file that fails to create or write is a failure the execution layer must handle — not text the model may ignore."

"Why does OutputAccumulator maintain totalLines and totalDecodedBytes on its own instead of just reading the snapshot's result?"

"Because the snapshot's truncation is computed over the assembled text, while what OutputAccumulator accumulates is stream bytes: append decodes and adds to totalDecodedBytes as it goes, and an incomplete UTF-8 boundary is held back until the next chunk," Lao Z said. "So the truncated decision uses this.totalLines > maxLines || this.totalDecodedBytes > maxBytes, which can disagree with tailTruncation.truncated: the first says 'the raw stream went over', the second says 'the assembled text went over'. This separation keeps a few bad bytes in the middle from blowing up the context."

"Then how does it control memory?"

"maxRollingBytes = maxBytes * 2; after append, if tailBytes exceeds maxRollingBytes * 2 it calls trimTail(), keeping only a block at the tail, cutting at a UTF-8 boundary and aligning to line boundaries as well as it can," Lao Z said. "shouldUseTempFile() starts writing the temp file as soon as either line is exceeded, and ensureTempFile() backfills the rawChunks accumulated so far into the file stream in one go. So the memory footprint has a ceiling and the full output is not lost — provided the temp file can be written, which, when it cannot, is an execution-layer failure."

core/tools/index.ts gathers the built-in tools; read.ts, write.ts, edit.ts, bash.ts, powershell.ts, grep.ts, find.ts, and ls.ts implement the individual capabilities, wired into the model loop by AgentSession's tool configuration and the session runtime. allToolNames defines these eight tool names — powershell is new in v1.0.0 — and createToolDefinition/createCodingToolDefinitions assemble the schema and the executor by tool name.

Key point: benefit and cost

The benefit is that the context is not easily filled up by a single command's output. The cost is that the model sees a trimmed result and may miss key information sitting in the middle. The truncation metadata and the location of the full output must travel as part of the result; they cannot be dressed up as a complete log.

Writes to the same file are explicitly queued ​

Xiao A: "Back to my first knot."

"Look at withFileMutationQueue()." Lao Z opened packages/coding-agent/src/core/tools/file-mutation-queue.ts:

ts
const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve();
const chainedQueue = currentQueue.then(() => nextQueue);
fileMutationQueues.set(key, chainedQueue);
await currentQueue;
try { return await fn(); }
finally { releaseNext(); /* clean up the same queue */ }

"Paragraph by paragraph: first record the predecessor Promise registered under the key, then install the successor Promise, only then wait for the predecessor, and finally — whether fn succeeds or throws — release the successor. This finally is the key that keeps the queue from clogging forever on a failure. On the normal path, mutations under the same key run in submission order. Boundary one: key normalization fails. Boundary two: fn throws, and the later mutations must still get released."

"What does this pseudocode leave out?"

"It leaves out registrationQueue," Lao Z said. "The real code does not read and write fileMutationQueues directly; it wraps the 'compute the key + read the old value + write the new value' sequence inside registrationQueue.then(...) so those steps run serially. The reason is that getMutationQueueKey is async (it needs realpath): when two calls enter concurrently they can race on the Map — A reads it empty, B also reads it empty, both install their own successor, neither sees the other, and the later install overwrites the earlier one. registrationQueue chains this registration into an atomic step, so the chain for one key never breaks."

Draw the queue's timeline and the role of registrationQueue becomes more direct:

text
Two nearly simultaneous withFileMutationQueue("/x.txt", fn) calls (idealized timeline):
  mutation A enters               mutation B enters
  registrationQueue.then(...)     registrationQueue.then(...)   ← serial registration
   |   keyA = realpath → /real    | (wait for A's registration)
   |   read Map → empty           |   keyB = realpath → /real
   |   build current=A0           |   read Map → already A's chain
   |   Map[key] = A0 → nextA      |   Map[key] = nextA → nextB
   |   registration done          |   registration done
   |   await current(A0)          |   await current(nextA)
   |   fn()                       |   fn()                      ← strict serialization
   |   finally: releaseNext()     |   finally: releaseNext()

"The release side has its subtleties too." Lao Z continued. "After the finally releases the successor, the key is deleted only if fileMutationQueues.get(key) === chainedQueue — the chain is cleaned up only while it is still the current chain; otherwise you would delete the chain someone else just installed."

Xiao A: "How is the key normalized? I'd guess you just use the path passed in as the key?"

"First resolve(filePath), then realpath," Lao Z said. "realpath resolves symlinks, so two writes through symlink paths to the same file land on one key and get serialized. But when the file does not exist yet, realpath throws ENOENT/ENOTDIR; isMissingPathError catches that and falls back to resolvedPath. The cost is right here: for a file that has not been created yet, symlink equivalence is lost — /tmp/new.txt and the symlink /link/new.txt are two different keys before creation and may execute concurrently."

"So different files don't share a queue?"

"Right. withFileMutationQueue(filePath, fn) first resolves the path into a queue key, then waits on that key's current Promise. Different keys walk their own queues and share nothing."

Metaphor: queuing at the checkout

One file is like one checkout lane: the same key pays in turn. Lanes make no promises to each other.

Xiao A: "Then if I wrap every file-writing operation in this queue, are writes safe?"

"Hardly 'safe'," Lao Z said. "It provides serialization for mutations aimed at the same canonicalized file target — that is a checkable boundary. Different paths pointing at the same resource, modifications by external processes, conflicts in business semantics, cross-file atomicity: all of these stay outside it. Don't hear 'queued per file' as 'every write is safe'."

Extension loading, binding, and events ​

Xiao A remembered the extensions directory he had put in his project: "The load did succeed. But what does that actually prove?"

"Only that it was discovered and read in. It does not prove it can execute, and it certainly does not prove it is trustworthy." Lao Z pulled up packages/coding-agent/src/core/extensions/loader.ts:

ts
addPaths(discoverExtensionsInDir(localExtDir));
addPaths(discoverExtensionsInDir(globalExtDir));
for (const p of configuredPaths) { /* a directory or an explicit file */ }
return loadExtensions(allPaths, resolvedCwd, eventBus);

"The discovery order is project-local, then global, then explicitly configured; the seen set keeps one resolved path from loading twice. Discovery is discovery — the result of loadExtensions() also carries errors, and the startup chain promotes some of them into diagnostics."

"Which path does the seen dedup use?"

"It compares after path.resolve(p)," Lao Z said. "What it dedups are paths from the discovery phase; what gets stored into allPaths is still the original p, not the resolved one, because loadExtensions resolves again internally, with cwd context. The same file coming in through two paths, identical after resolve, loads only once. A configured path pointing at a directory first goes through resolveExtensionEntries to look for a manifest; if there is none, discoverExtensionsInDir lists the files."

"What exactly are the directory-discovery rules?"

"Three tiers, with no recursion beyond one level," Lao Z said. "A *.ts/*.js file placed directly in the directory counts as one tier; a subdirectory with index.ts/index.js counts as one tier; a subdirectory whose package.json carries a pi.extensions field counts as one tier, loaded along the paths the manifest declares. A more complex package must use a manifest — the loader comment 'no recursion beyond one level' says exactly this. What addPaths receives is the output of these three tiers, and each entry goes to loadExtensionModule for a dynamic import via jiti. Only when the factory function runs does the extension get a chance to call registerTool/registerCommand."

For the binding step, look at ExtensionRunner.bindCore():

ts
this.runtime.sendMessage = actions.sendMessage;
this.runtime.refreshTools = actions.refreshTools;
this.getModel = contextActions.getModel;
this.isProjectTrustedFn = contextActions.isProjectTrusted;

Xiao A stared at this string of assignments: "After binding, does the extension run in its own territory?"

"Binding only points the extension API at session actions and context readers. It does no process isolation. A Provider registration may be queued at load time or take effect directly after binding; a registration exception becomes an extension error rather than vanishing quietly. The event types of the extension API live in extensions/types.ts: session, message, provider-request, tool-execution, project-trust and other events all have typed entry points and result types. What this structure provides is interception and subscription points; every handler is still executed without isolation, and an extension's origin cannot be automatically treated as safe."

"Are the load-time API and the bound API the same?"

"Same interface, different behavior," Lao Z said. "In createExtensionRuntime(), the load-time action methods are throwing stubs — call sendMessage or appendEntry and they throw outright. The only legitimate calls are the registration ones: registerTool writes into the extension's own object; registerProvider, registerNativeProvider, and the v1.0.0 addition registerVirtualModel queue into their respective pending arrays. The comment says it plainly: notInitialized throws 'Extension runtime not initialized'. bindCore() swaps the runtime's action fields, one by one, for real implementations, and the context readers (getModel, isProjectTrusted, and so on) are assigned here too. So an extension that calls sendMessage during load throws immediately. Don't treat it as a bug — the load-time boundary is implemented exactly this way."

"What happens after that string of assignments in bindCore?"

"It flushes the Provider registrations queued during load: it walks pendingProviderRegistrations, calls providerActions.registerProvider for each, and collects exceptions as diagnostics," Lao Z said. "There is a buried default here: isProjectTrustedFn defaults to () => true and getModel defaults to () => undefined, so that an ExtensionRunner can run even without binding — in tests, for example. But that means an unbound runner doesn't block anything either. What actually decides trust is resolveProjectTrusted(), not this field default."

Project trust decides whether resources may enter the runtime directly ​

Xiao A: "What about the project-trust layer? Isn't it enough to add a line to the model's prompt saying 'be careful'?"

"It isn't. Project trust is a condition inside the resource-loading process; it has nothing to do with prompting." Lao Z switched to packages/coding-agent/src/core/project-trust.ts:

ts
// packages/coding-agent/src/core/project-trust.ts, resolveProjectTrusted (v1.0.0, excerpt)
if (options.trustOverride !== undefined) return options.trustOverride;
if (!hasTrustRequiringProjectResources(options.cwd)) return true;
// Extensions' project_trust event speaks first here — see below
const decision = options.trustStore.get(options.cwd);
if (decision !== null) return decision;
if (!options.projectTrustContext.hasUI) return false;

"This sequence separates three concepts: project trust decides whether certain project resources may load at all; an approval like beforeToolCall decides whether one particular tool call goes through; an OS sandbox decides what a process can actually access. The three are complementary, but not interchangeable."

Xiao A: "If you ask me, the step people most easily miss is the trustStore one?"

"The thing people more easily miss is the extensions' project_trust event," Lao Z said. "It runs before any stored decision in trustStore — that is, an extension can step in and answer 'trust or not' itself. emitProjectTrustEvent sends the event to all extensions; if any of them returns { result }, its trusted field is used, and when remember === true the answer is also written into trustStore. So project trust is not simply 'ask the user' — an extension can decide on your behalf. The cost sits right here: a malicious loaded extension can lie in this event and return trusted."

"How is it computed when several handlers respond to the extension event?"

"They run handler by handler in extension order; the first one returning yes/no wins, undecided passes to the next, and only when all of them are undecided does it fall through to trustStore," Lao Z said. "A single handler throwing is recorded as an ExtensionError and the run continues — the error neither blocks the other handlers nor counts as a refusal. 'Extensions before the store' refers to exactly this first-mover position: loaded extensions have the right to speak first on the trust decision, regardless of whether they themselves are trusted."

Lao Z laid out the whole chain's evaluation order and its exits in one table:

text
Order of resolveProjectTrusted() (v1.0.0):
| Which step | Check                                   | Result                                              |
| ---        | ---                                     | ---                                                 |
| 1          | trustOverride !== undefined             | return the override directly                        |
| 2          | !hasTrustRequiringProjectResources(cwd) | return true directly (no trust-requiring resources) |
| 3          | extensions' project_trust event         | yes/no → return (may write trustStore)              |
| 4          | trustStore.get(cwd) !== null            | return the stored decision                          |
| 5          | defaultProjectTrust                     | always→true / never→false / ask→continue            |
| 6          | projectTrustContext.hasUI is false      | return false (no UI, default untrusted)             |
| 7          | ask with a UI                           | selector; with no choice made, still returns false  |

"Steps 5 and 6 have to be read together: always and never need no UI; only ask continues. And when hasUI is false, ask goes straight to return false. If no dialog can pop up, the result is still 'not allowed through'."

"Then where do defaultProjectTrust's always/never/ask sit?"

"After the extension event produced no result and trustStore has no stored decision," Lao Z said. "always trusts directly, never refuses directly, only ask goes to the UI. The order as a whole is: override → no trust needed → extension event → stored decision → default policy → UI. This chain short-circuits: once an earlier step decides, the later ones never run. So the relative positions 'extensions before the store' and 'store before the default' are design choices — swapping them changes the semantics."

Key point: trusted ≠ trustworthy content

A trusted project does not make the content of its files trustworthy; an untrusted project does not make all risk disappear. Extension code, the shell, the network, and credentials each still need their own permission, auditing, and environment-isolation policies.

Boundary responsibility table ​

BoundaryEntry pointWhat it allowsWhat it does not guarantee
Tool argumentsTool schema/executorparse arguments and return error resultsthat arguments match user intent
File mutationqueue keyserialization under the same keycross-file atomicity, external writes
Extensionsloader/runnerdiscovery, loading, event bindingextension code safety or compatibility
Project trustresolveProjectTrusted()whether trust-requiring resources loadthe model's immunity to injection
OS isolationnot in these symbolscannot be confirmed by this chapterleast privilege for files, network, processes

core/trust-manager.ts identifies the project configuration resources that require trust and stores decisions in ProjectTrustStore; resolveProjectTrusted() processes, in order, an explicit override, a trust-free project, the extensions' project_trust event, a stored decision, and the default policy.

Xiao A: "So is this already a sandbox?"

"It isn't," Lao Z said. "What these files can confirm is discovery, the trust decision, and the call boundaries. Whether process, filesystem, and network access are isolated by the operating system — these files cannot prove that. Security has to be verified with the actual executor and the deployment environment in hand."

Failure paths and design costs ​

"So what happens when an extension fails to load?" Xiao A asked.

"Extension load errors and Provider registration errors are folded into the diagnostics; the startup entry point can treat error diagnostics as a failed exit and prompt you to restart with extensions disabled. Tool-output truncation, extension handler exceptions, and trust refusals must all stay as observable results — the model must not be able to overwrite them with one 'done'."

"How does an error diagnostic get promoted from 'warning' to 'error'?"

"Look at the source," Lao Z said. "collectSettingsDiagnostics marks settings errors uniformly as warnings; resourceLoader.getExtensions().errors (a module that failed to load) is marked error; the catch around a Provider registration marks error. At the end of main() only diagnostics.some(d => d.type === "error") is checked before exiting with 1, so a warning does not block startup but is still printed. That is why 'an extension failed to load' stops the program while 'a misspelled settings field' only warns: the first breaks functionality, the second is usually backward compatibility."

"There is one more detail in here," Lao Z added. "The errors mix two different sources: the extension module itself failing to import, and an exception thrown while registering a Provider. In main(), when runtime.diagnostics.some(...message.includes("Failed to load extension")) matches, an extra hint is appended: Hint: Start without extensions using "pi -ne". — in v1.0.0 the command name is taken from APP_NAME. So even though the exit code is 1 either way, the hint text switches by failure category, and the user's next action differs."

"The accounting of this design has to be done on both sides," Lao Z wrapped up. "The benefit: the core gains new Providers, tools, commands, interface behavior, and lifecycle behavior without changing the main program. The cost: load order, compatibility, observability, and the trust surface all grow. And to name what is still untouched — the parameter schemas of all built-in tools, extension package management, the concrete sandbox implementation, auditing of third-party extensions, user-level credential policies. This chapter covered none of them."

Summary ​

This chapter brought the two risk-heavy questions — "what can it do" and "who brought the capability in" — down to the source level. The tool layer controls what flows back to the model with truncation and output accumulation: the defaults of two thousand lines and fifty KB are a configurable boundary, meant to keep a single command's output from filling up the context. Writes to the same file are explicitly queued — withFileMutationQueue serializes mutations by a path-normalized key and releases the next entry in a finally, so one failure cannot clog the queue forever. But this boundary protects one file only: cross-file cases, modifications by external processes, and conflicts in business semantics all lie outside its jurisdiction. The extension layer is responsible for discovery, loading, binding, and event dispatch. A module being read in, being able to execute, and being trustworthy are three different things.

The queue's details expose two sides of the concurrency boundary: normalization uses realpath to resolve symlinks, so two writes aimed at the same file land on one key; but when the file does not exist it falls back to resolvedPath, and symlink equivalence before creation is lost. registrationQueue gathers "compute the key, read the old value, write the new value" into an atomic registration, eliminating the race of concurrent reads on the Map; on the release side the key is deleted only while the chain is still the current chain. The corresponding arrangement on the extension side is "queue at load time, land after binding": during load the action methods are throwing stubs, only Provider registrations may queue, and bindCore installs the real actions and flushes the backlog. Load failures and registration exceptions survive as diagnostics — error makes startup stop and prints the -ne hint for the matching category, warning only prints and never blocks.

Project trust decides whether certain local resources may participate in the runtime directly; without a UI the default outcome of ask is distrust — a default-allow must never stand in for human confirmation. In resolveProjectTrusted()'s short-circuit chain, the extensions' project_trust event sits before the stored decision: loaded extensions hold first-mover say over trust, and remember: true lets them rewrite trustStore. That is a design choice, and also where the trust surface grows. Tool concurrency, extension loading, and project trust each remove only some engineering risk; together they are not proof of complete isolation against untrusted code and side effects. Treating "the project is trusted" or "the extension is loaded" as "arbitrary code can run without worry" is exactly the misreading this chapter set out to correct.

Source Walkthrough ​

Read truncate.ts and OutputAccumulator.snapshot() first, and confirm how the truncation metadata and the full-output path come into being; then follow the wait and the finally release from withFileMutationQueue(). After that, compare discoverAndLoadExtensions(), ExtensionRunner.bindCore(), and resolveProjectTrusted(), listing the decision points an extension passes through between being discovered and gaining session capabilities — and deliberately check the no-UI ask branch.