session

Source: src/session.ts

The whole pi API lives here, and nowhere else.

The rest of the library only talks to {@link SessionPort}, a tiny subset of AgentSession. Two consequences: when pi moves, only this file moves; and tests inject a fake session with no network, no disk and no ~/.pi.

That second promise is why this file also absorbs pi’s version churn - see {@link buildModelOptions}. Which pi matters is the one the code runs inside, not the one in node_modules: an extension is loaded into pi’s own process, so it resolves pi’s own copy of the package. Homebrew ships 0.80.6 and npm ships 0.80.10, and those two do not agree on how models are built.

AgentMessage

type

export type AgentMessage = AgentSession["messages"][number];

Alias to pi’s message type, without depending on a transitive package.

checkModel

function

export async function checkModel(pattern: string): Promise<void> { /* … */ }

Checks that a model pattern resolves in this pi, without opening a session.

For whoever takes a --model argument: /interview asks the user for minutes before the first spawn and /build runs unwatched, and a typo must cost a second, not a conversation or a run found stopped. It touches the real pi module, like {@link buildRegistry} - a fake cannot stand in for it, only a real pi run proves it end to end.

createDefaultSession

const

export const createDefaultSession: CreateSession = async (agent, options) => {
	/* … */
};

Creates a real, isolated pi session for an agent.

The system prompt goes through a {@link StaticResourceLoader}: the subagent inherits neither the user’s extensions, nor their context files, nor any skill it did not name. It only sees what its own definition gives it - which is what makes it reproducible.

CreateSession

type

export type CreateSession = (agent: Agent, options: CreateSessionOptions) => Promise<SessionPort>;

Session factory. The injection point for tests.

CreateSessionOptions

type

export type CreateSessionOptions = {
	/** Working directory of the session. Defaults to the process's own. */
	cwd?: string;
	/**
	 * Session directory dedicated to this run. Absent means an in-memory
	 * session: not exportable, and leaving no trace in `~/.pi`. That is the
	 * default, and it is intentional.
	 */
	sessionDir?: string;
	/**
	 * Model pattern for this session, already resolved against the precedence
	 * ladder by `spawn()` - see `SpawnOptions.model`. Absent means pi's own
	 * settings decide, which is the last resort, never a choice made here.
	 */
	model?: string;
	/**
	 * Tools combo itself defines, offered to this session.
	 *
	 * Offering is not granting: `tools` is an allowlist and it covers these too,
	 * so a tool the agent's `tools:` does not name is not enabled. That leaves
	 * the guarantee of {@link StaticResourceLoader} intact - a subagent still
	 * inherits nothing from the user's environment, and what it can do is
	 * readable in its own definition.
	 */
	customTools?: ToolDefinition[];
};

Session creation settings, passed through by spawn().

MainSession

type

export type MainSession = Pick<SessionManager, "getSessionFile" | "getHeader" | "getEntries">;

What the library reads of the parent pi session, the one a run is launched from: pi’s ctx.sessionManager fits it as it is.

The file’s path alone is not enough. pi creates a session’s file with its first assistant message, so a command typed first in a fresh pi runs in a session whose file does not exist yet, and --no-session never writes one. The header and the entries are in memory from the start, and the file is those and nothing else, one JSON object per line.

READ_ONLY_TOOLS

const

export const READ_ONLY_TOOLS = ["read", "grep", "find", "ls"] as const;

Tools of an exploration agent: read, never write. This is the default.

SessionPort

type

export type SessionPort = {
	/** One turn. Returns when the model stops asking for tools; see `timeoutMs`. */
	prompt(text: string): Promise<void>;
	/** Every event of the turn. Returns the unsubscribe function. */
	subscribe(listener: (event: SessionEvent) => void): () => void;
	/** **Cumulative** over the session: a turn's usage is the difference of two snapshots. */
	getSessionStats(): SessionStats;
	/** How full the context is - what a persistent subagent has to be watched on. */
	getContextUsage(): ContextUsage | undefined;
	/** Cuts the in-flight turn short. `prompt()` takes no signal, so this is the bridge. */
	abort(): Promise<void>;
	/**
	 * Queues a word for the turn in flight, delivered after the tool call the
	 * model is in. **Only while `isStreaming`**: measured, a steer queued on an
	 * idle session is delivered with the next `prompt()` and answered in place
	 * of it, which silently changes what a workflow reads back from its own
	 * task. The mirror is the one caller, and it checks first.
	 */
	steer(text: string): Promise<void>;
	/** Whether a turn is in flight - the one moment a steer is safe. */
	readonly isStreaming: boolean;
	/** Releases the session. An undisposed session leaks; measurements come first. */
	dispose(): void;
	/**
	 * Writes the session as a readable HTML page. **Before `dispose()`.**
	 *
	 * Optional because it is not always available: pi refuses to export an
	 * in-memory session ("Cannot export in-memory session to HTML"), which is
	 * exactly what a subagent gets unless it was spawned with a `sessionDir`.
	 */
	exportToHtml?(outputPath?: string): Promise<string>;
	/** Writes the current branch as replayable JSONL. **Before `dispose()`.** */
	exportToJsonl?(outputPath?: string): string;
	/** The transcript so far. It **grows** with every turn. */
	readonly messages: AgentMessage[];
	/**
	 * The model actually in use, once pi has resolved it.
	 *
	 * Read, never set: an agent declares a *pattern* (`"anthropic/claude-sonnet-5"`,
	 * or nothing at all), and only the session knows what that became.
	 */
	readonly model?: { provider?: string; id?: string };
};

What the library consumes from a pi session - nothing more.

AgentSession satisfies this type structurally: no adapter to write, and a fake session fits in fifty lines.

situate

function

export function situate(systemPrompt: string, cwd: string): string { /* … */ }

The agent’s prompt, plus the one fact it cannot do its job without: where it is.

A subagent inherits nothing from the user’s environment, deliberately - but its own working directory is not inherited context, it is the ground every tool call stands on. Without it a model guesses, and a real run showed exactly what that costs: a scout called ls /Users/loic/gouarin/… - the user’s name with a dot turned into a slash - got “no such path”, and gave up without trying a relative one. One branch of three, wasted on a fabricated path.

StaticResourceLoader

class

export class StaticResourceLoader implements ResourceLoader {
	readonly #systemPrompt: string;
	readonly #skills: Skill[];
	getExtensions() { /* … */ }
	getSkills() { /* … */ }
	getPrompts() { /* … */ }
	getThemes() { /* … */ }
	getAgentsFiles() { /* … */ }
	getSystemPrompt() { /* … */ }
	getSystemPromptSource() { /* … */ }
	getAppendSystemPrompt() { /* … */ }
	getAppendSystemPromptSources() { /* … */ }
	extendResources() { /* … */ }
	async reload() { /* … */ }
}

A ResourceLoader that discovers nothing: it returns the agent’s system prompt, the skills it was handed, and empty lists for everything else.

DefaultResourceLoader would re-read the disk on every spawn, load the user’s extensions and trigger the project trust logic. For a subagent that is non-deterministic context nobody asked for. Skills are the one thing that comes from outside the definition, and even then only by name: they are resolved by resolveSkills before we get here, never found by this loader.