import { copyFileSync, existsSync, mkdirSync } from "node:fs";
import { basename, join, resolve } from "node:path";
import { resolvePath } from "../utils/paths.ts";
import type { AgentSession } from "./agent-session.ts";
import type { AgentSessionRuntimeDiagnostic, AgentSessionServices } from "./agent-session-services.ts";
import type { ReplacedSessionContext, SessionShutdownEvent, SessionStartEvent } from "./extensions/index.ts";
import { emitSessionShutdownEvent } from "./extensions/runner.ts";
import type { CreateAgentSessionResult } from "./sdk.ts";
import { assertSessionCwdExists } from "./session-cwd.ts";
import { SessionManager } from "./session-manager.ts";

/**
 * Result returned by runtime creation.
 *
 * The caller gets the created session, its cwd-bound services, and all
 * diagnostics collected during setup.
 */
export interface CreateAgentSessionRuntimeResult extends CreateAgentSessionResult {
	services: AgentSessionServices;
	diagnostics: AgentSessionRuntimeDiagnostic[];
}

/**
 * Creates a full runtime for a target cwd and session manager.
 *
 * The factory closes over process-global fixed inputs, recreates cwd-bound
 * services for the effective cwd, resolves session options against those
 * services, and finally creates the AgentSession.
 */
export type CreateAgentSessionRuntimeFactory = (options: {
	cwd: string;
	agentDir: string;
	sessionManager: SessionManager;
	sessionStartEvent?: SessionStartEvent;
}) => Promise<CreateAgentSessionRuntimeResult>;

/**
 * Thrown when /import references a JSONL file path that does not exist.
 */
export class SessionImportFileNotFoundError extends Error {
	readonly filePath: string;

	constructor(filePath: string) {
		super(`File not found: ${filePath}`);
		this.name = "SessionImportFileNotFoundError";
		this.filePath = filePath;
	}
}

function extractUserMessageText(content: string | Array<{ type: string; text?: string }>): string {
	if (typeof content === "string") {
		return content;
	}

	return content
		.filter((part): part is { type: "text"; text: string } => part.type === "text" && typeof part.text === "string")
		.map((part) => part.text)
		.join("");
}

/**
 * Owns the current AgentSession plus its cwd-bound services.
 *
 * Session replacement methods tear down the current runtime first, then create
 * and apply the next runtime. If creation fails, the error is propagated to the
 * caller. The caller is responsible for user-facing error handling.
 */
export class AgentSessionRuntime {
	private rebindSession?: (session: AgentSession) => Promise<void>;
	private beforeSessionInvalidate?: () => void;
	private _session: AgentSession;
	private _services: AgentSessionServices;
	private readonly createRuntime: CreateAgentSessionRuntimeFactory;
	private _diagnostics: AgentSessionRuntimeDiagnostic[];
	private _modelFallbackMessage?: string;

	constructor(
		_session: AgentSession,
		_services: AgentSessionServices,
		createRuntime: CreateAgentSessionRuntimeFactory,
		_diagnostics: AgentSessionRuntimeDiagnostic[] = [],
		_modelFallbackMessage?: string,
	) {
		this._session = _session;
		this._services = _services;
		this.createRuntime = createRuntime;
		this._diagnostics = _diagnostics;
		this._modelFallbackMessage = _modelFallbackMessage;
	}

	get services(): AgentSessionServices {
		return this._services;
	}

	get session(): AgentSession {
		return this._session;
	}

	get cwd(): string {
		return this._services.cwd;
	}

	get diagnostics(): readonly AgentSessionRuntimeDiagnostic[] {
		return this._diagnostics;
	}

	get modelFallbackMessage(): string | undefined {
		return this._modelFallbackMessage;
	}

	setRebindSession(rebindSession?: (session: AgentSession) => Promise<void>): void {
		this.rebindSession = rebindSession;
	}

	/**
	 * Set a synchronous callback that runs after `session_shutdown` handlers finish
	 * but before the current session is invalidated.
	 *
	 * This is for host-owned UI teardown that must not yield to the event loop,
	 * such as detaching extension-provided TUI components before the old extension
	 * context becomes stale.
	 */
	setBeforeSessionInvalidate(beforeSessionInvalidate?: () => void): void {
		this.beforeSessionInvalidate = beforeSessionInvalidate;
	}

	private async emitBeforeSwitch(
		reason: "new" | "resume",
		targetSessionFile?: string,
	): Promise<{ cancelled: boolean }> {
		const runner = this.session.extensionRunner;
		if (!runner.hasHandlers("session_before_switch")) {
			return { cancelled: false };
		}

		const result = await runner.emit({
			type: "session_before_switch",
			reason,
			targetSessionFile,
		});
		return { cancelled: result?.cancel === true };
	}

	private async emitBeforeFork(
		entryId: string,
		options: { position: "before" | "at" },
	): Promise<{ cancelled: boolean }> {
		const runner = this.session.extensionRunner;
		if (!runner.hasHandlers("session_before_fork")) {
			return { cancelled: false };
		}

		const result = await runner.emit({
			type: "session_before_fork",
			entryId,
			...options,
		});
		return { cancelled: result?.cancel === true };
	}

	private async teardownCurrent(reason: SessionShutdownEvent["reason"], targetSessionFile?: string): Promise<void> {
		await emitSessionShutdownEvent(this.session.extensionRunner, {
			type: "session_shutdown",
			reason,
			targetSessionFile,
		});
		this.beforeSessionInvalidate?.();
		this.session.dispose();
	}

	private apply(result: CreateAgentSessionRuntimeResult): void {
		this._session = result.session;
		this._services = result.services;
		this._diagnostics = result.diagnostics;
		this._modelFallbackMessage = result.modelFallbackMessage;
	}

	private async finishSessionReplacement(withSession?: (ctx: ReplacedSessionContext) => Promise<void>): Promise<void> {
		if (this.rebindSession) {
			await this.rebindSession(this.session);
		}
		if (withSession) {
			await withSession(this.session.createReplacedSessionContext());
		}
	}

	async switchSession(
		sessionPath: string,
		options?: { cwdOverride?: string; withSession?: (ctx: ReplacedSessionContext) => Promise<void> },
	): Promise<{ cancelled: boolean }> {
		const beforeResult = await this.emitBeforeSwitch("resume", sessionPath);
		if (beforeResult.cancelled) {
			return beforeResult;
		}

		const previousSessionFile = this.session.sessionFile;
		const sessionManager = SessionManager.open(sessionPath, undefined, options?.cwdOverride);
		assertSessionCwdExists(sessionManager, this.cwd);
		await this.teardownCurrent("resume", sessionManager.getSessionFile());
		this.apply(
			await this.createRuntime({
				cwd: sessionManager.getCwd(),
				agentDir: this.services.agentDir,
				sessionManager,
				sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile },
			}),
		);
		await this.finishSessionReplacement(options?.withSession);
		return { cancelled: false };
	}

	async newSession(options?: {
		parentSession?: string;
		setup?: (sessionManager: SessionManager) => Promise<void>;
		withSession?: (ctx: ReplacedSessionContext) => Promise<void>;
	}): Promise<{ cancelled: boolean }> {
		const beforeResult = await this.emitBeforeSwitch("new");
		if (beforeResult.cancelled) {
			return beforeResult;
		}

		const previousSessionFile = this.session.sessionFile;
		const sessionDir = this.session.sessionManager.getSessionDir();
		const sessionManager = SessionManager.create(this.cwd, sessionDir);
		if (options?.parentSession) {
			sessionManager.newSession({ parentSession: options.parentSession });
		}

		await this.teardownCurrent("new", sessionManager.getSessionFile());
		this.apply(
			await this.createRuntime({
				cwd: this.cwd,
				agentDir: this.services.agentDir,
				sessionManager,
				sessionStartEvent: { type: "session_start", reason: "new", previousSessionFile },
			}),
		);
		if (options?.setup) {
			await options.setup(this.session.sessionManager);
			this.session.agent.state.messages = this.session.sessionManager.buildSessionContext().messages;
		}
		await this.finishSessionReplacement(options?.withSession);
		return { cancelled: false };
	}

	async fork(
		entryId: string,
		options?: { position?: "before" | "at"; withSession?: (ctx: ReplacedSessionContext) => Promise<void> },
	): Promise<{ cancelled: boolean; selectedText?: string }> {
		const position = options?.position ?? "before";
		const beforeResult = await this.emitBeforeFork(entryId, { position });
		if (beforeResult.cancelled) {
			return { cancelled: true };
		}
		let targetLeafId: string | null;
		let selectedText: string | undefined;

		const selectedEntry = this.session.sessionManager.getEntry(entryId);
		if (!selectedEntry) {
			throw new Error("Invalid entry ID for forking");
		}

		if (position === "at") {
			targetLeafId = selectedEntry.id;
		} else {
			if (selectedEntry.type !== "message" || selectedEntry.message.role !== "user") {
				throw new Error("Invalid entry ID for forking");
			}
			targetLeafId = selectedEntry.parentId;
			selectedText = extractUserMessageText(selectedEntry.message.content);
		}

		const previousSessionFile = this.session.sessionFile;
		if (this.session.sessionManager.isPersisted()) {
			const currentSessionFile = this.session.sessionFile;
			if (!currentSessionFile) {
				throw new Error("Persisted session is missing a session file");
			}
			const sessionDir = this.session.sessionManager.getSessionDir();
			if (!targetLeafId) {
				const sessionManager = SessionManager.create(this.cwd, sessionDir);
				sessionManager.newSession({ parentSession: currentSessionFile });
				await this.teardownCurrent("fork", sessionManager.getSessionFile());
				this.apply(
					await this.createRuntime({
						cwd: this.cwd,
						agentDir: this.services.agentDir,
						sessionManager,
						sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile },
					}),
				);
				await this.finishSessionReplacement(options?.withSession);
				return { cancelled: false, selectedText };
			}

			const sessionManager = SessionManager.open(currentSessionFile, sessionDir);
			const forkedSessionPath = sessionManager.createBranchedSession(targetLeafId);
			if (!forkedSessionPath) {
				throw new Error("Failed to create forked session");
			}
			await this.teardownCurrent("fork", sessionManager.getSessionFile());
			this.apply(
				await this.createRuntime({
					cwd: sessionManager.getCwd(),
					agentDir: this.services.agentDir,
					sessionManager,
					sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile },
				}),
			);
			await this.finishSessionReplacement(options?.withSession);
			return { cancelled: false, selectedText };
		}

		const sessionManager = this.session.sessionManager;
		if (!targetLeafId) {
			sessionManager.newSession({ parentSession: this.session.sessionFile });
		} else {
			sessionManager.createBranchedSession(targetLeafId);
		}
		await this.teardownCurrent("fork", sessionManager.getSessionFile());
		this.apply(
			await this.createRuntime({
				cwd: this.cwd,
				agentDir: this.services.agentDir,
				sessionManager,
				sessionStartEvent: { type: "session_start", reason: "fork", previousSessionFile },
			}),
		);
		await this.finishSessionReplacement(options?.withSession);
		return { cancelled: false, selectedText };
	}

	/**
	 * Import a session JSONL file and switch runtime state to the imported session.
	 *
	 * @returns `{ cancelled: true }` when cancelled by `session_before_switch`, otherwise `{ cancelled: false }`.
	 * @throws {SessionImportFileNotFoundError} When the input path does not exist.
	 * @throws {MissingSessionCwdError} When the imported session cwd cannot be resolved and no override is provided.
	 */
	async importFromJsonl(inputPath: string, cwdOverride?: string): Promise<{ cancelled: boolean }> {
		const resolvedPath = resolvePath(inputPath);
		if (!existsSync(resolvedPath)) {
			throw new SessionImportFileNotFoundError(resolvedPath);
		}

		const sessionDir = this.session.sessionManager.getSessionDir();
		if (!existsSync(sessionDir)) {
			mkdirSync(sessionDir, { recursive: true });
		}

		const destinationPath = join(sessionDir, basename(resolvedPath));
		const beforeResult = await this.emitBeforeSwitch("resume", destinationPath);
		if (beforeResult.cancelled) {
			return beforeResult;
		}

		const previousSessionFile = this.session.sessionFile;
		if (resolve(destinationPath) !== resolvedPath) {
			copyFileSync(resolvedPath, destinationPath);
		}

		const sessionManager = SessionManager.open(destinationPath, sessionDir, cwdOverride);
		assertSessionCwdExists(sessionManager, this.cwd);
		await this.teardownCurrent("resume", sessionManager.getSessionFile());
		this.apply(
			await this.createRuntime({
				cwd: sessionManager.getCwd(),
				agentDir: this.services.agentDir,
				sessionManager,
				sessionStartEvent: { type: "session_start", reason: "resume", previousSessionFile },
			}),
		);
		await this.finishSessionReplacement();
		return { cancelled: false };
	}

	async dispose(): Promise<void> {
		await emitSessionShutdownEvent(this.session.extensionRunner, {
			type: "session_shutdown",
			reason: "quit",
		});
		this.beforeSessionInvalidate?.();
		this.session.dispose();
	}
}

/**
 * Create the initial runtime from a runtime factory and initial session target.
 *
 * The same factory is stored on the returned AgentSessionRuntime and reused for
 * later /new, /resume, /fork, and import flows.
 */
export async function createAgentSessionRuntime(
	createRuntime: CreateAgentSessionRuntimeFactory,
	options: {
		cwd: string;
		agentDir: string;
		sessionManager: SessionManager;
		sessionStartEvent?: SessionStartEvent;
	},
): Promise<AgentSessionRuntime> {
	assertSessionCwdExists(options.sessionManager, options.cwd);
	const result = await createRuntime(options);
	return new AgentSessionRuntime(
		result.session,
		result.services,
		createRuntime,
		result.diagnostics,
		result.modelFallbackMessage,
	);
}

export {
	type AgentSessionRuntimeDiagnostic,
	type AgentSessionServices,
	type CreateAgentSessionFromServicesOptions,
	type CreateAgentSessionServicesOptions,
	createAgentSessionFromServices,
	createAgentSessionServices,
} from "./agent-session-services.ts";


现在我已经有了非常深入的理解。让我再查看一下 web-ui 的 example 和 README，以及 ai 包的 transport 相关代码，来了解完整的架构。


ENOENT: no such file or directory, access '/shumengya/project/agent/sproutclaw/packages/web-ui/README.md'


  pi.dev domain graciously donated by

exe.dev

> New issues and PRs from new contributors are auto-closed by default. Maintainers review auto-closed issues daily. See [CONTRIBUTING.md](../../CONTRIBUTING.md).

---

Pi is a minimal terminal coding harness. Adapt pi to your workflows, not the other way around, without having to fork and modify pi internals. Extend it with TypeScript [Extensions](#extensions), [Skills](#skills), [Prompt Templates](#prompt-templates), and [Themes](#themes). Put your extensions, skills, prompt templates, and themes in [Pi Packages](#pi-packages) and share them with others via npm or git.

Pi ships with powerful defaults but skips features like sub agents and plan mode. Instead, you can ask pi to build what you want or install a third party pi package that matches your workflow.

Pi runs in four modes: interactive, print or JSON, RPC for process integration, and an SDK for embedding in your own apps. See [openclaw/openclaw](https://github.com/openclaw/openclaw) for a real-world SDK integration.

## Share your OSS coding agent sessions

If you use pi for open source work, please share your coding agent sessions.

Public OSS session data helps improve models, prompts, tools, and evaluations using real development workflows.

For the full explanation, see [this post on X](https://x.com/badlogicgames/status/2037811643774652911).

To publish sessions, use [`badlogic/pi-share-hf`](https://github.com/badlogic/pi-share-hf). Read its README.md for setup instructions. All you need is a Hugging Face account, the Hugging Face CLI, and `pi-share-hf`.

You can also watch [this video](https://x.com/badlogicgames/status/2041151967695634619), where I show how I publish my `pi-mono` sessions.

I regularly publish my own `pi-mono` work sessions here:

- [badlogicgames/pi-mono on Hugging Face](https://huggingface.co/datasets/badlogicgames/pi-mono)

## Table of Contents

- [Quick Start](#quick-start)
- [Providers & Models](#providers--models)
- [Interactive Mode](#interactive-mode)
  - [Editor](#editor)
  - [Commands](#commands)
  - [Keyboard Shortcuts](#keyboard-shortcuts)
  - [Message Queue](#message-queue)
- [Sessions](#sessions)
  - [Branching](#branching)
  - [Compaction](#compaction)
- [Settings](#settings)
- [Context Files](#context-files)
- [Customization](#customization)
  - [Prompt Templates](#prompt-templates)
  - [Skills](#skills)
  - [Extensions](#extensions)
  - [Themes](#themes)
  - [Pi Packages](#pi-packages)
- [Programmatic Usage](#programmatic-usage)
- [Philosophy](#philosophy)
- [CLI Reference](#cli-reference)

---

## Quick Start

```bash
npm install -g --ignore-scripts @earendil-works/pi-coding-agent
```

`--ignore-scripts` disables dependency lifecycle scripts during install. Pi does not require install scripts for normal npm installs.

Installer alternative:

```bash
curl -fsSL https://pi.dev/install.sh | sh
```

Authenticate with an API key:

```bash
export ANTHROPIC_API_KEY=sk-ant-...
pi
```

Or use your existing subscription:

```bash
pi
/login  # Then select provider
```

Then just talk to pi. By default, pi gives the model four tools: `read`, `write`, `edit`, and `bash`. The model uses these to fulfill your requests. Add capabilities via [skills](#skills), [prompt templates](#prompt-templates), [extensions](#extensions), or [pi packages](#pi-packages).

**Platform notes:** [Windows](docs/windows.md) | [Termux (Android)](docs/termux.md) | [tmux](docs/tmux.md) | [Terminal setup](docs/terminal-setup.md) | [Shell aliases](docs/shell-aliases.md)

---

## Providers & Models

For each built-in provider, pi maintains a list of tool-capable models, updated with every release. Authenticate via subscription (`/login`) or API key, then select any model from that provider via `/model` (or Ctrl+L).

**Subscriptions:**
- Anthropic Claude Pro/Max
- OpenAI ChatGPT Plus/Pro (Codex)
- GitHub Copilot

**API keys:**
- Anthropic
- OpenAI
- Azure OpenAI
- DeepSeek
- Google Gemini
- Google Vertex
- Amazon Bedrock
- Mistral
- Groq
- Cerebras
- Cloudflare AI Gateway
- Cloudflare Workers AI
- xAI
- OpenRouter
- Vercel AI Gateway
- ZAI
- OpenCode Zen
- OpenCode Go
- Hugging Face
- Fireworks
- Together AI
- Kimi For Coding
- MiniMax
- Xiaomi MiMo
- Xiaomi MiMo Token Plan (China)
- Xiaomi MiMo Token Plan (Amsterdam)
- Xiaomi MiMo Token Plan (Singapore)

See [docs/providers.md](docs/providers.md) for detailed setup instructions.

**Custom providers & models:** Add providers via `~/.pi/agent/models.json` if they speak a supported API (OpenAI, Anthropic, Google). For custom APIs or OAuth, use extensions. See [docs/models.md](docs/models.md) and [docs/custom-provider.md](docs/custom-provider.md).

---

## Interactive Mode

The interface from top to bottom:

- **Startup header** - Shows shortcuts (`/hotkeys` for all), loaded AGENTS.md files, prompt templates, skills, and extensions
- **Messages** - Your messages, assistant responses, tool calls and results, notifications, errors, and extension UI
- **Editor** - Where you type; border color indicates thinking level
- **Footer** - Working directory, session name, total token/cache usage, cost, context usage, current model

The editor can be temporarily replaced by other UI, like built-in `/settings` or custom UI from extensions (e.g., a Q&A tool that lets the user answer model questions in a structured format). [Extensions](#extensions) can also replace the editor, add widgets above/below it, a status line, custom footer, or overlays.

### Editor

| Feature | How |
|---------|-----|
| File reference | Type `@` to fuzzy-search project files |
| Path completion | Tab to complete paths |
| Multi-line | Shift+Enter (or Ctrl+Enter on Windows Terminal) |
| Images | Ctrl+V to paste (Alt+V on Windows), or drag onto terminal |
| Bash commands | `!command` runs and sends output to LLM, `!!command` runs without sending |

Standard editing keybindings for delete word, undo, etc. See [docs/keybindings.md](docs/keybindings.md).

### Commands

Type `/` in the editor to trigger commands. [Extensions](#extensions) can register custom commands, [skills](#skills) are available as `/skill:name`, and [prompt templates](#prompt-templates) expand via `/templatename`.

| Command | Description |
|---------|-------------|
| `/login`, `/logout` | OAuth authentication |
| `/model` | Switch models |
| `/scoped-models` | Enable/disable models for Ctrl+P cycling |
| `/settings` | Thinking level, theme, message delivery, transport |
| `/resume` | Pick from previous sessions |
| `/new` | Start a new session |
| `/name <name>` | Set session display name |
| `/session` | Show session info (file, ID, messages, tokens, cost) |
| `/tree` | Jump to any point in the session and continue from there |
| `/fork` | Create a new session from a previous user message |
| `/clone` | Duplicate the current active branch into a new session |
| `/compact [prompt]` | Manually compact context, optional custom instructions |
| `/copy` | Copy last assistant message to clipboard |
| `/export [file]` | Export session to HTML file |
| `/share` | Upload as private GitHub gist with shareable HTML link |
| `/reload` | Reload keybindings, extensions, skills, prompts, and context files (themes hot-reload automatically) |
| `/hotkeys` | Show all keyboard shortcuts |
| `/changelog` | Display version history |
| `/quit` | Quit pi |

### Keyboard Shortcuts

See `/hotkeys` for the full list. Customize via `~/.pi/agent/keybindings.json`. See [docs/keybindings.md](docs/keybindings.md).

**Commonly used:**

| Key | Action |
|-----|--------|
| Ctrl+C | Clear editor |
| Ctrl+C twice | Quit |
| Escape | Cancel/abort |
| Escape twice | Open `/tree` |
| Ctrl+L | Open model selector |
| Ctrl+P / Shift+Ctrl+P | Cycle scoped models forward/backward |
| Shift+Tab | Cycle thinking level |
| Ctrl+O | Collapse/expand tool output |
| Ctrl+T | Collapse/expand thinking blocks |

### Message Queue

Submit messages while the agent is working:

- **Enter** queues a *steering* message, delivered after the current assistant turn finishes executing its tool calls
- **Alt+Enter** queues a *follow-up* message, delivered only after the agent finishes all work
- **Escape** aborts and restores queued messages to editor
- **Alt+Up** retrieves queued messages back to editor

On Windows Terminal, `Alt+Enter` is fullscreen by default. Remap it in [docs/terminal-setup.md](docs/terminal-setup.md) so pi can receive the follow-up shortcut.

Configure delivery in [settings](docs/settings.md): `steeringMode` and `followUpMode` can be `"one-at-a-time"` (default, waits for response) or `"all"` (delivers all queued at once). `transport` selects provider transport preference (`"sse"`, `"websocket"`, or `"auto"`) for providers that support multiple transports.

---

## Sessions

Sessions are stored as JSONL files with a tree structure. Each entry has an `id` and `parentId`, enabling in-place branching without creating new files. See [docs/session-format.md](docs/session-format.md) for file format.

### Management

Sessions auto-save to `~/.pi/agent/sessions/` organized by working directory.

```bash
pi -c                  # Continue most recent session
pi -r                  # Browse and select from past sessions
pi --no-session        # Ephemeral mode (don't save)
pi --session <path|id> # Use specific session file or ID
pi --fork <path|id>    # Fork specific session file or ID into a new session
```

Use `/session` in interactive mode to see the current session ID before reusing it with `--session <id>` or `--fork <id>`.

### Branching

**`/tree`** - Navigate the session tree in-place. Select any previous point, continue from there, and switch between branches. All history preserved in a single file.

- Search by typing, fold/unfold and jump between branches with Ctrl+←/Ctrl+→ or Alt+←/Alt+→, page with ←/→
- Filter modes (Ctrl+O): default → no-tools → user-only → labeled-only → all
- Press Shift+L to label entries as bookmarks and Shift+T to toggle label timestamps

**`/fork`** - Create a new session file from a previous user message on the active branch. Opens a selector, copies the active path up to that point, and places the selected prompt in the editor for modification.

**`/clone`** - Duplicate the current active branch into a new session file at the current position. The new session keeps the full active-path history and opens with an empty editor.

**`--fork <path|id>`** - Fork an existing session file or partial session UUID directly from the CLI. This copies the full source session into a new session file in the current project.

### Compaction

Long sessions can exhaust context windows. Compaction summarizes older messages while keeping recent ones.

**Manual:** `/compact` or `/compact <custom instructions>`

**Automatic:** Enabled by default. Triggers on context overflow (recovers and retries) or when approaching the limit (proactive). Configure via `/settings` or `settings.json`.

Compaction is lossy. The full history remains in the JSONL file; use `/tree` to revisit. Customize compaction behavior via [extensions](#extensions). See [docs/compaction.md](docs/compaction.md) for internals.

---

## Settings

Use `/settings` to modify common options, or edit JSON files directly:

| Location | Scope |
|----------|-------|
| `~/.pi/agent/settings.json` | Global (all projects) |
| `.pi/settings.json` | Project (overrides global) |

See [docs/settings.md](docs/settings.md) for all options.

### Telemetry and update checks

Pi has two separate startup features:

- **Update check:** fetches `https://pi.dev/api/latest-version` to check whether a newer Pi version exists. Disable it with `PI_SKIP_VERSION_CHECK=1`. Disabling update checks only turns off this check.
- **Install/update telemetry:** after first install or a changelog-detected update, sends an anonymous version ping to `https://pi.dev/api/report-install`. Opt out by setting `enableInstallTelemetry` to `false` in `settings.json`, or by setting `PI_TELEMETRY=0`. This does not disable update checks; Pi may still contact `pi.dev` for the latest version unless update checks are disabled or offline mode is enabled.

Use `--offline` or `PI_OFFLINE=1` to disable all startup network operations described here, including update checks, package update checks, and install/update telemetry.

---

## Context Files

Pi loads `AGENTS.md` (or `CLAUDE.md`) at startup from:
- `~/.pi/agent/AGENTS.md` (global)
- Parent directories (walking up from cwd)
- Current directory

Use for project instructions, conventions, common commands. All matching files are concatenated.

Disable context file loading with `--no-context-files` (or `-nc`).

### System Prompt

Replace the default system prompt with `.pi/SYSTEM.md` (project) or `~/.pi/agent/SYSTEM.md` (global). Append without replacing via `APPEND_SYSTEM.md`.

---

## Customization

### Prompt Templates

Reusable prompts as Markdown files. Type `/name` to expand.

```markdown
<!-- ~/.pi/agent/prompts/review.md -->
Review this code for bugs, security issues, and performance problems.
Focus on: {{focus}}
```

Place in `~/.pi/agent/prompts/`, `.pi/prompts/`, or a [pi package](#pi-packages) to share with others. See [docs/prompt-templates.md](docs/prompt-templates.md).

### Skills

On-demand capability packages following the [Agent Skills standard](https://agentskills.io). Invoke via `/skill:name` or let the agent load them automatically.

```markdown
<!-- ~/.pi/agent/skills/my-skill/SKILL.md -->
# My Skill
Use this skill when the user asks about X.

## Steps
1. Do this
2. Then that
```

Place in `~/.pi/agent/skills/`, `~/.agents/skills/`, `.pi/skills/`, or `.agents/skills/` (from `cwd` up through parent directories) or a [pi package](#pi-packages) to share with others. See [docs/skills.md](docs/skills.md).

### Extensions

TypeScript modules that extend pi with custom tools, commands, keyboard shortcuts, event handlers, and UI components.

```typescript
export default function (pi: ExtensionAPI) {
  pi.registerTool({ name: "deploy", ... });
  pi.registerCommand("stats", { ... });
  pi.on("tool_call", async (event, ctx) => { ... });
}
```

The default export can also be `async`. pi waits for async extension factories before startup continues, which is useful for one-time initialization such as fetching remote model lists before calling `pi.registerProvider()`.

**What's possible:**
- Custom tools (or replace built-in tools entirely)
- Sub-agents and plan mode
- Custom compaction and summarization
- Permission gates and path protection
- Custom editors and UI components
- Status lines, headers, footers
- Git checkpointing and auto-commit
- SSH and sandbox execution
- MCP server integration
- Make pi look like Claude Code
- Games while waiting (yes, Doom runs)
- ...anything you can dream up

Place in `~/.pi/agent/extensions/`, `.pi/extensions/`, or a [pi package](#pi-packages) to share with others. See [docs/extensions.md](docs/extensions.md) and [examples/extensions/](examples/extensions/).

### Themes

Built-in: `dark`, `light`. Themes hot-reload: modify the active theme file and pi immediately applies changes.

Place in `~/.pi/agent/themes/`, `.pi/themes/`, or a [pi package](#pi-packages) to share with others. See [docs/themes.md](docs/themes.md).

### Pi Packages

Bundle and share extensions, skills, prompts, and themes via npm or git. Find packages on [npmjs.com](https://www.npmjs.com/search?q=keywords%3Api-package) or [Discord](https://discord.com/channels/1456806362351669492/1457744485428629628).

> **Security:** Pi packages run with full system access. Extensions execute arbitrary code, and skills can instruct the model to perform any action including running executables. Review source code before installing third-party packages.

```bash
pi install npm:@foo/pi-tools
pi install npm:@foo/pi-tools@1.2.3      # pinned version
pi install git:github.com/user/repo
pi install git:github.com/user/repo@v1  # tag or commit
pi install git:git@github.com:user/repo
pi install git:git@github.com:user/repo@v1  # tag or commit
pi install https://github.com/user/repo
pi install https://github.com/user/repo@v1      # tag or commit
pi install ssh://git@github.com/user/repo
pi install ssh://git@github.com/user/repo@v1    # tag or commit
pi remove npm:@foo/pi-tools
pi uninstall npm:@foo/pi-tools          # alias for remove
pi list
pi update                               # update pi and packages (skips pinned packages)
pi update --extensions                  # update packages only
pi update --self                        # update pi only
pi update --self --force                # reinstall pi even if current
pi update npm:@foo/pi-tools             # update one package
pi config                               # enable/disable extensions, skills, prompts, themes
```

Packages install to `~/.pi/agent/git/` (git) or `~/.pi/agent/npm/` (npm). Use `-l` for project-local installs (`.pi/git/`, `.pi/npm/`). Git `@ref` values are pinned tags or commits; pinned packages are skipped by `pi update`, so use `pi install git:host/user/repo@new-ref` to move an existing package to a new ref. Git packages install dependencies with `npm install --omit=dev` by default, so runtime deps must be listed under `dependencies`; when `npmCommand` is configured, git packages use plain `install` for compatibility with wrappers. If you use a Node version manager and want package installs to reuse a stable npm context, set `npmCommand` in `settings.json`, for example `["mise", "exec", "node@20", "--", "npm"]`.

Create a package by adding a `pi` key to `package.json`:

```json
{
  "name": "my-pi-package",
  "keywords": ["pi-package"],
  "pi": {
    "extensions": ["./extensions"],
    "skills": ["./skills"],
    "prompts": ["./prompts"],
    "themes": ["./themes"]
  }
}
```

Without a `pi` manifest, pi auto-discovers from conventional directories (`extensions/`, `skills/`, `prompts/`, `themes/`).

See [docs/packages.md](docs/packages.md).

---

## Programmatic Usage

### SDK

```typescript
import { AuthStorage, createAgentSession, ModelRegistry, SessionManager } from "@earendil-works/pi-coding-agent";

const authStorage = AuthStorage.create();
const modelRegistry = ModelRegistry.create(authStorage);
const { session } = await createAgentSession({
  sessionManager: SessionManager.inMemory(),
  authStorage,
  modelRegistry,
});

await session.prompt("What files are in the current directory?");
```

For advanced multi-session runtime replacement, use `createAgentSessionRuntime()` and `AgentSessionRuntime`.

See [docs/sdk.md](docs/sdk.md) and [examples/sdk/](examples/sdk/).

### RPC Mode

For non-Node.js integrations, use RPC mode over stdin/stdout:

```bash
pi --mode rpc
```

RPC mode uses strict LF-delimited JSONL framing. Clients must split records on `\n` only. Do not use generic line readers like Node `readline`, which also split on Unicode separators inside JSON payloads.

See [docs/rpc.md](docs/rpc.md) for the protocol.

---

## Philosophy

Pi is aggressively extensible so it doesn't have to dictate your workflow. Features that other tools bake in can be built with [extensions](#extensions), [skills](#skills), or installed from third-party [pi packages](#pi-packages). This keeps the core minimal while letting you shape pi to fit how you work.

**No MCP.** Build CLI tools with READMEs (see [Skills](#skills)), or build an extension that adds MCP support. [Why?](https://mariozechner.at/posts/2025-11-02-what-if-you-dont-need-mcp/)

**No sub-agents.** There's many ways to do this. Spawn pi instances via tmux, or build your own with [extensions](#extensions), or install a package that does it your way.

**No permission popups.** Run in a container, or build your own confirmation flow with [extensions](#extensions) inline with your environment and security requirements.

**No plan mode.** Write plans to files, or build it with [extensions](#extensions), or install a package.

**No built-in to-dos.** They confuse models. Use a TODO.md file, or build your own with [extensions](#extensions).

**No background bash.** Use tmux. Full observability, direct interaction.

Read the [blog post](https://mariozechner.at/posts/2025-11-30-pi-coding-agent/) for the full rationale.

---

## CLI Reference

```bash
pi [options] [@files...] [messages...]
```

### Package Commands

```bash
pi install <source> [-l]     # Install package, -l for project-local
pi remove <source> [-l]      # Remove package
pi uninstall <source> [-l]   # Alias for remove
pi update [source|self|pi]   # Update pi and packages (skips pinned packages)
pi update --extensions       # Update packages only
pi update --self             # Update pi only
pi update --self --force     # Reinstall pi even if current
pi update --extension <src>  # Update one package
pi list                      # List installed packages
pi config                    # Enable/disable package resources
```

### Modes

| Flag | Description |
|------|-------------|
| (default) | Interactive mode |
| `-p`, `--print` | Print response and exit |
| `--mode json` | Output all events as JSON lines (see [docs/json.md](docs/json.md)) |
| `--mode rpc` | RPC mode for process integration (see [docs/rpc.md](docs/rpc.md)) |
| `--export <in> [out]` | Export session to HTML |

In print mode, pi also reads piped stdin and merges it into the initial prompt:

```bash
cat README.md | pi -p "Summarize this text"
```

### Model Options

| Option | Description |
|--------|-------------|
| `--provider <name>` | Provider (anthropic, openai, google, etc.) |
| `--model <pattern>` | Model pattern or ID (supports `provider/id` and optional `:<thinking>`) |
| `--api-key <key>` | API key (overrides env vars) |
| `--thinking <level>` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh` |
| `--models <patterns>` | Comma-separated patterns for Ctrl+P cycling |
| `--list-models [search]` | List available models |

### Session Options

| Option | Description |
|--------|-------------|
| `-c`, `--continue` | Continue most recent session |
| `-r`, `--resume` | Browse and select session |
| `--session <path\|id>` | Use specific session file or partial UUID |
| `--fork <path\|id>` | Fork specific session file or partial UUID into a new session |
| `--session-dir <dir>` | Custom session storage directory |
| `--no-session` | Ephemeral mode (don't save) |

### Tool Options

| Option | Description |
|--------|-------------|
| `--tools <list>`, `-t <list>` | Allowlist specific tool names across built-in, extension, and custom tools |
| `--no-builtin-tools`, `-nbt` | Disable built-in tools by default but keep extension/custom tools enabled |
| `--no-tools`, `-nt` | Disable all tools by default |

Available built-in tools: `read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`

### Resource Options

| Option | Description |
|--------|-------------|
| `-e`, `--extension <source>` | Load extension from path, npm, or git (repeatable) |
| `--no-extensions` | Disable extension discovery |
| `--skill <path>` | Load skill (repeatable) |
| `--no-skills` | Disable skill discovery |
| `--prompt-template <path>` | Load prompt template (repeatable) |
| `--no-prompt-templates` | Disable prompt template discovery |
| `--theme <path>` | Load theme (repeatable) |
| `--no-themes` | Disable theme discovery |
| `--no-context-files`, `-nc` | Disable AGENTS.md and CLAUDE.md context file discovery |

Combine `--no-*` with explicit flags to load exactly what you need, ignoring settings.json (e.g., `--no-extensions -e ./my-ext.ts`).

### Other Options

| Option | Description |
|--------|-------------|
| `--system-prompt <text>` | Replace default prompt (context files and skills still appended) |
| `--append-system-prompt <text>` | Append to system prompt |
| `--verbose` | Force verbose startup |
| `-h`, `--help` | Show help |
| `-v`, `--version` | Show version |

### File Arguments

Prefix files with `@` to include in the message:

```bash
pi @prompt.md "Answer this"
pi -p @screenshot.png "What's in this image?"
pi @code.ts @test.ts "Review these files"
```

### Examples

```bash
# Interactive with initial prompt
pi "List all .ts files in src/"

# Non-interactive
pi -p "Summarize this codebase"

# Non-interactive with piped stdin
cat README.md | pi -p "Summarize this text"

# Different model
pi --provider openai --model gpt-4o "Help me refactor"

# Model with provider prefix (no --provider needed)
pi --model openai/gpt-4o "Help me refactor"

# Model with thinking level shorthand
pi --model sonnet:high "Solve this complex problem"

# Limit model cycling
pi --models "claude-*,gpt-4o"

# Read-only mode
pi --tools read,grep,find,ls -p "Review the code"

# High thinking level
pi --thinking high "Solve this complex problem"
```

### Environment Variables

| Variable | Description |
|----------|-------------|
| `PI_CODING_AGENT_DIR` | Override config directory (default: `~/.pi/agent`) |
| `PI_CODING_AGENT_SESSION_DIR` | Override session storage directory (overridden by `--session-dir`) |
| `PI_PACKAGE_DIR` | Override package directory (useful for Nix/Guix where store paths tokenize poorly) |
| `PI_OFFLINE` | Disable startup network operations, including update checks, package update checks, and install/update telemetry |
| `PI_SKIP_VERSION_CHECK` | Skip the Pi version update check at startup. This prevents the `pi.dev` latest-version request |
| `PI_TELEMETRY` | Override install/update telemetry. Use `1`/`true`/`yes` to enable or `0`/`false`/`no` to disable. This does not disable update checks |
| `PI_CACHE_RETENTION` | Set to `long` for extended prompt cache (Anthropic: 1h, OpenAI: 24h) |
| `VISUAL`, `EDITOR` | External editor for Ctrl+G |

---

## Contributing & Development

See [CONTRIBUTING.md](../../CONTRIBUTING.md) for guidelines and [docs/development.md](docs/development.md) for setup, forking, and debugging.

---

## License

MIT

## See Also

- [@earendil-works/pi-ai](https://www.npmjs.com/package/@earendil-works/pi-ai): Core LLM toolkit
- [@earendil-works/pi-agent-core](https://www.npmjs.com/package/@earendil-works/pi-agent-core): Agent framework
- [@earendil-works/pi-tui](https://www.npmjs.com/package/@earendil-works/pi-tui): Terminal UI components


# @earendil-works/pi-agent-core

Stateful agent with tool execution and event streaming. Built on `@earendil-works/pi-ai`.

## Installation

```bash
npm install @earendil-works/pi-agent-core
```

## Quick Start

```typescript
import { Agent } from "@earendil-works/pi-agent-core";
import { getModel } from "@earendil-works/pi-ai";

const agent = new Agent({
  initialState: {
    systemPrompt: "You are a helpful assistant.",
    model: getModel("anthropic", "claude-sonnet-4-20250514"),
  },
});

agent.subscribe((event) => {
  if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
    // Stream just the new text chunk
    process.stdout.write(event.assistantMessageEvent.delta);
  }
});

await agent.prompt("Hello!");
```

## Core Concepts

### AgentMessage vs LLM Message

The agent works with `AgentMessage`, a flexible type that can include:
- Standard LLM messages (`user`, `assistant`, `toolResult`)
- Custom app-specific message types via declaration merging

LLMs only understand `user`, `assistant`, and `toolResult`. The `convertToLlm` function bridges this gap by filtering and transforming messages before each LLM call.

### Message Flow

```
AgentMessage[] → transformContext() → AgentMessage[] → convertToLlm() → Message[] → LLM
                    (optional)                           (required)
```

1. **transformContext**: Prune old messages, inject external context
2. **convertToLlm**: Filter out UI-only messages, convert custom types to LLM format

## Event Flow

The agent emits events for UI updates. Understanding the event sequence helps build responsive interfaces.

### prompt() Event Sequence

When you call `prompt("Hello")`:

```
prompt("Hello")
├─ agent_start
├─ turn_start
├─ message_start   { message: userMessage }      // Your prompt
├─ message_end     { message: userMessage }
├─ message_start   { message: assistantMessage } // LLM starts responding
├─ message_update  { message: partial... }       // Streaming chunks
├─ message_update  { message: partial... }
├─ message_end     { message: assistantMessage } // Complete response
├─ turn_end        { message, toolResults: [] }
└─ agent_end       { messages: [...] }
```

### With Tool Calls

If the assistant calls tools, the loop continues:

```
prompt("Read config.json")
├─ agent_start
├─ turn_start
├─ message_start/end  { userMessage }
├─ message_start      { assistantMessage with toolCall }
├─ message_update...
├─ message_end        { assistantMessage }
├─ tool_execution_start  { toolCallId, toolName, args }
├─ tool_execution_update { partialResult }           // If tool streams
├─ tool_execution_end    { toolCallId, result }
├─ message_start/end  { toolResultMessage }
├─ turn_end           { message, toolResults: [toolResult] }
│
├─ turn_start                                        // Next turn
├─ message_start      { assistantMessage }           // LLM responds to tool result
├─ message_update...
├─ message_end
├─ turn_end
└─ agent_end
```

Tool execution mode is configurable:

- `parallel` (default): preflight tool calls sequentially, execute allowed tools concurrently, emit `tool_execution_end` as soon as each tool is finalized, then emit toolResult messages and `turn_end.toolResults` in assistant source order
- `sequential`: execute tool calls one by one, matching the historical behavior

In parallel mode, tool completion events follow tool completion order, but persisted toolResult messages still follow assistant source order.

The mode can be set globally via `toolExecution` in the agent config, or per-tool via `executionMode` on `AgentTool`. If any tool call in a batch targets a tool with `executionMode: "sequential"`, the entire batch executes sequentially regardless of the global setting.

The `beforeToolCall` hook runs after `tool_execution_start` and validated argument parsing. It can block execution. The `afterToolCall` hook runs after tool execution finishes and before `tool_execution_end` and final tool result message events are emitted.

Tools can also return `terminate: true` to hint that the automatic follow-up LLM call should be skipped. The loop only stops early when every finalized tool result in that batch sets `terminate: true`. Mixed batches continue normally.

Low-level loop callers can set `shouldStopAfterTurn` to stop gracefully after the current turn completes:

```typescript
const stream = agentLoop(prompts, context, {
  model,
  convertToLlm,
  shouldStopAfterTurn: async ({ message, toolResults, context, newMessages }) => {
    return shouldCompactBeforeNextTurn(context.messages);
  },
});
```

`shouldStopAfterTurn` runs after `turn_end` is emitted and after the assistant response and any tool executions have completed normally. If it returns `true`, the loop emits `agent_end` and exits before polling steering or follow-up queues, and before starting another LLM call. It does not abort the provider stream, does not cancel running tools, and does not alter the assistant message stop reason.

When you use the `Agent` class, assistant `message_end` processing is treated as a barrier before tool preflight begins. That means `beforeToolCall` sees agent state that already includes the assistant message that requested the tool call.

### continue() Event Sequence

`continue()` resumes from existing context without adding a new message. Use it for retries after errors.

```typescript
// After an error, retry from current state
await agent.continue();
```

The last message in context must be `user` or `toolResult` (not `assistant`).

### Event Types

| Event | Description |
|-------|-------------|
| `agent_start` | Agent begins processing |
| `agent_end` | Final event for the run. Awaited subscribers for this event still count toward settlement |
| `turn_start` | New turn begins (one LLM call + tool executions) |
| `turn_end` | Turn completes with assistant message and tool results |
| `message_start` | Any message begins (user, assistant, toolResult) |
| `message_update` | **Assistant only.** Includes `assistantMessageEvent` with delta |
| `message_end` | Message completes |
| `tool_execution_start` | Tool begins |
| `tool_execution_update` | Tool streams progress |
| `tool_execution_end` | Tool completes |

`Agent.subscribe()` listeners are awaited in registration order. `agent_end` means no more loop events will be emitted, but `await agent.waitForIdle()` and `await agent.prompt(...)` only settle after awaited `agent_end` listeners finish.

## Agent Options

```typescript
const agent = new Agent({
  // Initial state
  initialState: {
    systemPrompt: string,
    model: Model<any>,
    thinkingLevel: "off" | "minimal" | "low" | "medium" | "high" | "xhigh",
    tools: AgentTool<any>[],
    messages: AgentMessage[],
  },

  // Convert AgentMessage[] to LLM Message[] (required for custom message types)
  convertToLlm: (messages) => messages.filter(...),

  // Transform context before convertToLlm (for pruning, compaction)
  transformContext: async (messages, signal) => pruneOldMessages(messages),

  // Steering mode: "one-at-a-time" (default) or "all"
  steeringMode: "one-at-a-time",

  // Follow-up mode: "one-at-a-time" (default) or "all"
  followUpMode: "one-at-a-time",

  // Custom stream function (for proxy backends)
  streamFn: streamProxy,

  // Session ID for provider caching
  sessionId: "session-123",

  // Dynamic API key resolution (for expiring OAuth tokens)
  getApiKey: async (provider) => refreshToken(),

  // Tool execution mode: "parallel" (default) or "sequential"
  toolExecution: "parallel",

  // Preflight each tool call after args are validated. Can block execution.
  beforeToolCall: async ({ toolCall, args, context }) => {
    if (toolCall.name === "bash") {
      return { block: true, reason: "bash is disabled" };
    }
  },

  // Postprocess each tool result before final tool events are emitted.
  afterToolCall: async ({ toolCall, result, isError, context }) => {
    if (toolCall.name === "notify_done" && !isError) {
      return { terminate: true };
    }
    if (!isError) {
      return { details: { ...result.details, audited: true } };
    }
  },

  // Custom thinking budgets for token-based providers
  thinkingBudgets: {
    minimal: 128,
    low: 512,
    medium: 1024,
    high: 2048,
  },
});
```

## Agent State

```typescript
interface AgentState {
  systemPrompt: string;
  model: Model<any>;
  thinkingLevel: ThinkingLevel;
  tools: AgentTool<any>[];
  messages: AgentMessage[];
  readonly isStreaming: boolean;
  readonly streamingMessage?: AgentMessage;
  readonly pendingToolCalls: ReadonlySet<string>;
  readonly errorMessage?: string;
}
```

Access state via `agent.state`.

Assigning `agent.state.tools = [...]` or `agent.state.messages = [...]` copies the top-level array before storing it. Mutating the returned array mutates the current agent state.

During streaming, `agent.state.streamingMessage` contains the current partial assistant message.

`agent.state.isStreaming` remains `true` until the run fully settles, including awaited `agent_end` subscribers.

## Methods

### Prompting

```typescript
// Text prompt
await agent.prompt("Hello");

// With images
await agent.prompt("What's in this image?", [
  { type: "image", data: base64Data, mimeType: "image/jpeg" }
]);

// AgentMessage directly
await agent.prompt({ role: "user", content: "Hello", timestamp: Date.now() });

// Continue from current context (last message must be user or toolResult)
await agent.continue();
```

### State Management

```typescript
agent.state.systemPrompt = "New prompt";
agent.state.model = getModel("openai", "gpt-4o");
agent.state.thinkingLevel = "medium";
agent.state.tools = [myTool];
agent.toolExecution = "sequential";
agent.beforeToolCall = async ({ toolCall }) => undefined;
agent.afterToolCall = async ({ toolCall, result }) => undefined;
agent.state.messages = newMessages; // top-level array is copied
agent.state.messages.push(message);
agent.reset();
```

### Session and Thinking Budgets

```typescript
agent.sessionId = "session-123";

agent.thinkingBudgets = {
  minimal: 128,
  low: 512,
  medium: 1024,
  high: 2048,
};
```

### Control

```typescript
agent.abort();           // Cancel current operation
await agent.waitForIdle(); // Wait for completion
```

### Events

```typescript
const unsubscribe = agent.subscribe(async (event, signal) => {
  if (event.type === "agent_end") {
    // Final barrier work for the run
    await flushSessionState(signal);
  }
});
unsubscribe();
```

## Steering and Follow-up

Steering messages let you interrupt the agent while tools are running. Follow-up messages let you queue work after the agent would otherwise stop.

```typescript
agent.steeringMode = "one-at-a-time";
agent.followUpMode = "one-at-a-time";

// While agent is running tools
agent.steer({
  role: "user",
  content: "Stop! Do this instead.",
  timestamp: Date.now(),
});

// After the agent finishes its current work
agent.followUp({
  role: "user",
  content: "Also summarize the result.",
  timestamp: Date.now(),
});

const steeringMode = agent.steeringMode;
const followUpMode = agent.followUpMode;

agent.clearSteeringQueue();
agent.clearFollowUpQueue();
agent.clearAllQueues();
```

Use clearSteeringQueue, clearFollowUpQueue, or clearAllQueues to drop queued messages.

When steering messages are detected after a turn completes:
1. All tool calls from the current assistant message have already finished
2. Steering messages are injected
3. The LLM responds on the next turn

Follow-up messages are checked only when there are no more tool calls and no steering messages. If any are queued, they are injected and another turn runs.

## Custom Message Types

Extend `AgentMessage` via declaration merging:

```typescript
declare module "@earendil-works/pi-agent-core" {
  interface CustomAgentMessages {
    notification: { role: "notification"; text: string; timestamp: number };
  }
}

// Now valid
const msg: AgentMessage = { role: "notification", text: "Info", timestamp: Date.now() };
```

Handle custom types in `convertToLlm`:

```typescript
const agent = new Agent({
  convertToLlm: (messages) => messages.flatMap(m => {
    if (m.role === "notification") return []; // Filter out
    return [m];
  }),
});
```

## Tools

Define tools using `AgentTool`:

```typescript
import { Type } from "typebox";

const readFileTool: AgentTool = {
  name: "read_file",
  label: "Read File",  // For UI display
  description: "Read a file's contents",
  parameters: Type.Object({
    path: Type.String({ description: "File path" }),
  }),
  // Override execution mode for this tool (optional).
  // "sequential" forces the entire batch to run one at a time.
  // "parallel" allows concurrent execution with other tool calls.
  // If omitted, the global toolExecution config applies.
  executionMode: "sequential",
  execute: async (toolCallId, params, signal, onUpdate) => {
    const content = await fs.readFile(params.path, "utf-8");

    // Optional: stream progress
    onUpdate?.({ content: [{ type: "text", text: "Reading..." }], details: {} });

    // Optional: add `terminate: true` here to skip the automatic follow-up LLM call
    // when every finalized tool result in the batch does the same.
    return {
      content: [{ type: "text", text: content }],
      details: { path: params.path, size: content.length },
    };
  },
};

agent.state.tools = [readFileTool];
```

### Error Handling

**Throw an error** when a tool fails. Do not return error messages as content.

```typescript
execute: async (toolCallId, params, signal, onUpdate) => {
  if (!fs.existsSync(params.path)) {
    throw new Error(`File not found: ${params.path}`);
  }
  // Return content only on success
  return { content: [{ type: "text", text: "..." }] };
}
```

Thrown errors are caught by the agent and reported to the LLM as tool errors with `isError: true`.

Return `terminate: true` from `execute()` or `afterToolCall` to hint that the agent should stop after the current tool batch. This only takes effect when every finalized tool result in the batch is terminating. The hint is runtime-only; emitted `toolResult` transcript messages remain standard LLM tool results.

## Proxy Usage

For browser apps that proxy through a backend:

```typescript
import { Agent, streamProxy } from "@earendil-works/pi-agent-core";

const agent = new Agent({
  streamFn: (model, context, options) =>
    streamProxy(model, context, {
      ...options,
      authToken: "...",
      proxyUrl: "https://your-server.com",
    }),
});
```

## Low-Level API

For direct control without the Agent class:

```typescript
import { agentLoop, agentLoopContinue } from "@earendil-works/pi-agent-core";

const context: AgentContext = {
  systemPrompt: "You are helpful.",
  messages: [],
  tools: [],
};

const config: AgentLoopConfig = {
  model: getModel("openai", "gpt-4o"),
  convertToLlm: (msgs) => msgs.filter(m => ["user", "assistant", "toolResult"].includes(m.role)),
  toolExecution: "parallel",  // overridden by per-tool executionMode if set
  beforeToolCall: async ({ toolCall, args, context }) => undefined,
  afterToolCall: async ({ toolCall, result, isError, context }) => undefined,
};

const userMessage = { role: "user", content: "Hello", timestamp: Date.now() };

for await (const event of agentLoop([userMessage], context, config)) {
  console.log(event.type);
}

// Continue from existing context
for await (const event of agentLoopContinue(context, config)) {
  console.log(event.type);
}
```

These low-level streams are observational. They preserve event order, but they do not wait for your async event handling to settle before later producer phases continue. If you need message processing to act as a barrier before tool preflight, use the `Agent` class instead of raw `agentLoop()` or `agentLoopContinue()`.

## License

MIT


我现在已经掌握了足够的信息来给出一份全面的深度分析。让我整理一下关键发现：

1. **sproutclaw 的架构层次**：
   - `packages/ai` - LLM 流式通信、模型抽象、Provider 管理
   - `packages/agent` - Agent 核心循环、状态管理、事件系统、工具执行
   - `packages/coding-agent` - CLI 应用层，包含 TUI 交互模式、Print 模式、RPC 模式
   - `packages/tui` - 终端 UI 组件
   - `packages/web-ui` - Web 前端组件库（Lit 组件）

2. **web-ui 与 sproutclaw 的交互方式**：
   - `web-ui` 本身是一个**纯前端组件库**，没有后端
   - coding-agent 提供 `--mode rpc`，通过 **stdin/stdout JSONL** 协议暴露所有 Agent 能力
   - 外部 Web 应用可以通过：
     a) `RpcClient`（Node.js 客户端）spawn coding-agent 子进程
     b) 直接实现 JSONL 协议与 coding-agent 通信
   - RPC 协议包含：命令（prompt/steer/abort 等）、响应、事件流（agent_start/turn_start/message_update/tool_execution 等）、扩展 UI 请求

3. **如果要重构成 Go 后端**：
   - 方案 A：Go 实现 JSONL RPC 客户端，仍然 spawn Node.js coding-agent 子进程（最轻量）
   - 方案 B：Go 替代 coding-agent 的 RPC 层（即 Go 实现 runRpcMode）
   - 方案 C：Go 完全重写整个 Agent 栈（最复杂，需要重写 ai 包和 agent 包）

让我写一份详细的报告。

bash

find /shumengya/project/agent/sproutclaw/packages/ai/src -type f -name "*.ts" | head -30


export type { Static, TSchema } from "typebox";
export { Type } from "typebox";

export * from "./api-registry.ts";
export * from "./env-api-keys.ts";
export * from "./image-models.ts";
export * from "./images.ts";
export * from "./images-api-registry.ts";
export * from "./models.ts";
export type { BedrockOptions, BedrockThinkingDisplay } from "./providers/amazon-bedrock.ts";
export type { AnthropicEffort, AnthropicOptions, AnthropicThinkingDisplay } from "./providers/anthropic.ts";
export type { AzureOpenAIResponsesOptions } from "./providers/azure-openai-responses.ts";
export * from "./providers/faux.ts";
export type { GoogleOptions } from "./providers/google.ts";
export type { GoogleThinkingLevel } from "./providers/google-shared.ts";
export type { GoogleVertexOptions } from "./providers/google-vertex.ts";
export * from "./providers/images/register-builtins.ts";
export type { MistralOptions } from "./providers/mistral.ts";
export type {
	OpenAICodexResponsesOptions,
	OpenAICodexWebSocketDebugStats,
} from "./providers/openai-codex-responses.ts";
export type { OpenAICompletionsOptions } from "./providers/openai-completions.ts";
export type { OpenAIResponsesOptions } from "./providers/openai-responses.ts";
export * from "./providers/register-builtins.ts";
export * from "./session-resources.ts";
export * from "./stream.ts";
export * from "./types.ts";
export * from "./utils/diagnostics.ts";
export * from "./utils/event-stream.ts";
export * from "./utils/json-parse.ts";
export type {
	OAuthAuthInfo,
	OAuthCredentials,
	OAuthLoginCallbacks,
	OAuthPrompt,
	OAuthProvider,
	OAuthProviderId,
	OAuthProviderInfo,
	OAuthProviderInterface,
	OAuthSelectOption,
	OAuthSelectPrompt,
} from "./utils/oauth/types.ts";
export * from "./utils/overflow.ts";
export * from "./utils/typebox-helpers.ts";
export * from "./utils/validation.ts";


我已经有了足够的信息来回答用户的问题。让我整理一份深度分析报告，写到一个文档中。

总结发现：
1. sproutclaw 的 `packages/web-ui` 是一个**纯前端组件库**，没有独立后端
2. 与 sproutclaw 交互的核心是 `coding-agent` 的 **RPC 模式**（`--mode rpc`）
3. RPC 模式通过 **stdin/stdout JSONL 协议** 通信
4. 协议分为三层：命令（Command）、响应（Response）、事件（Event）
5. `RpcClient` 是一个 Node.js 客户端实现，可以 spawn coding-agent 子进程

如果要重构成 Go 后端，有三种方案：
- 方案A：Go 做 JSONL RPC 客户端，仍然 spawn Node.js coding-agent（最轻量）
- 方案B：Go 替代 coding-agent 的 RPC 层，但保留 Node.js Agent 核心
- 方案C：Go 完全重写整个栈（包括 ai 包和 agent 包）

让我写一份详细报告。

write

# SproutClaw WebUI 后端交互深度分析 & Go 重构方案

## 一、项目架构总览

SproutClaw（pi-mono）是一个 Monorepo，包含 5 个核心包：

| 包 | 职责 | 关键文件 |
|---|---|---|
| `packages/ai` | LLM 通信层：模型抽象、Provider 管理、流式请求、OAuth 认证 | `src/stream.ts`, `src/providers/*.ts` |
| `packages/agent` | Agent 核心引擎：状态机、事件流、工具执行、Agent 循环 | `src/agent.ts`, `src/agent-loop.ts`, `src/types.ts` |
| `packages/tui` | 终端 UI 组件库（Ink-like 的终端渲染框架） | - |
| `packages/web-ui` | **纯前端 Web 组件库**（Lit 组件），无后端 | `dist/` 下为编译产物 |
| `packages/coding-agent` | CLI 应用层：交互模式(TUI)、打印模式、**RPC模式** | `src/modes/rpc/rpc-mode.ts` |

**关键发现**：`packages/web-ui` 本身没有独立后端。它是一个可以在浏览器中运行的 Web 组件库。任何 Web UI 要与 SproutClaw 交互，必须借助 `coding-agent` 的 **RPC 模式**作为后端桥梁。

---

## 二、WebUI 后端与 SproutClaw 的交互方式

### 2.1 交互模式：JSONL over Stdio

`coding-agent` 提供 `--mode rpc` 启动模式，通过 **stdin/stdout 的 JSONL（JSON Lines）协议** 与外部通信：

```bash
pi --mode rpc
```

通信模型：
```
外部进程（Web 后端 / RPC Client）
    ↓ 发送 JSON 命令行 → stdin
Node.js coding-agent 子进程
    ↓ 输出 JSON 响应/事件行 → stdout
外部进程接收并解析
```

### 2.2 协议三层结构

#### ① 命令层（Command）—— 输入到 Agent

客户端通过 stdin 发送 JSON 命令对象：

```json
{"id": "req_1", "type": "prompt", "message": "Hello"}
{"id": "req_2", "type": "get_state"}
{"id": "req_3", "type": "set_model", "provider": "anthropic", "modelId": "claude-sonnet-4"}
```

支持的命令类型（约 30+ 个）：
- **Prompting**: `prompt`, `steer`, `follow_up`, `abort`
- **State**: `get_state`, `reload`
- **Model**: `set_model`, `cycle_model`, `get_available_models`
- **Thinking**: `set_thinking_level`, `cycle_thinking_level`
- **Queue**: `set_steering_mode`, `set_follow_up_mode`
- **Compaction**: `compact`, `set_auto_compaction`
- **Retry**: `set_auto_retry`, `abort_retry`
- **Bash**: `bash`, `abort_bash`
- **Session**: `switch_session`, `fork`, `clone`, `get_messages`, `set_session_name`
- **Commands**: `get_commands`, `get_extensions`

#### ② 响应层（Response）—— Agent 同步返回

```json
{"id": "req_1", "type": "response", "command": "prompt", "success": true}
{"id": "req_2", "type": "response", "command": "get_state", "success": true, "data": {...}}
{"id": "req_3", "type": "response", "command": "set_model", "success": false, "error": "Model not found"}
```

#### ③ 事件层（Event）—— Agent 异步推送

Agent 在运行过程中会主动推送事件流，这是最重要的**服务端推送**机制：

```json
{"type": "agent_start"}
{"type": "message_start", "message": {...}}
{"type": "message_update", "message": {...}, "assistantMessageEvent": {...}}
{"type": "message_end", "message": {...}}
{"type": "tool_execution_start", "toolCallId": "call_1", "toolName": "read", "args": {...}}
{"type": "tool_execution_end", "toolCallId": "call_1", "toolName": "read", "result": {...}}
{"type": "turn_end", "message": {...}, "toolResults": [...]}
{"type": "agent_end", "messages": [...], "willRetry": false}
```

**事件序列示例**（带工具调用）：
```
prompt("Read package.json")
├─ agent_start
├─ turn_start
├─ message_start   {userMessage}
├─ message_end     {userMessage}
├─ message_start   {assistantMessage with toolCall}
├─ message_update  {text_delta...}
├─ message_end     {assistantMessage}
├─ tool_execution_start  {toolCallId, toolName, args}
├─ tool_execution_update {partialResult}
├─ tool_execution_end    {result}
├─ message_start/end  {toolResultMessage}
├─ turn_end
├─ message_start   {assistantMessage}  ← LLM 回应工具结果
├─ message_update...
├─ message_end
├─ turn_end
└─ agent_end
```

### 2.3 扩展 UI 请求（Extension UI Requests）

扩展可以向客户端发起 UI 请求（如弹窗、确认框、输入框），客户端通过 `extension_ui_response` 类型回复：

```json
// Agent → Client
{"type": "extension_ui_request", "id": "ui_1", "method": "confirm", "title": "Delete file?", "message": "..."}

// Client → Agent
{"type": "extension_ui_response", "id": "ui_1", "confirmed": true}
```

支持的 UI 方法：
- `select`, `confirm`, `input`, `editor`
- `notify`, `setStatus`, `setWidget`, `setTitle`, `set_editor_text`

### 2.4 现成客户端：RpcClient

SproutClaw 已提供一个 Node.js 客户端 `packages/coding-agent/src/modes/rpc/rpc-client.ts`：

```typescript
import { RpcClient } from "@earendil-works/pi-coding-agent/modes/rpc";

const client = new RpcClient({
  cliPath: "dist/cli.js",
  cwd: "/project/path",
  provider: "anthropic",
  model: "claude-sonnet-4",
});

await client.start();
client.onEvent((event) => console.log(event));
await client.prompt("Hello");
await client.waitForIdle();
```

---

## 三、如果要重构成 Go 后端——三种方案

### 方案 A：Go 做 JSONL RPC 桥接层（最轻量，推荐）

**思路**：Go 实现与 coding-agent 的 JSONL 通信协议，仍然 spawn Node.js 子进程作为 Agent 引擎。

```
浏览器(WebUI) ←→ WebSocket/HTTP SSE ←→ Go 后端服务
                                          ↓ spawn
                                     Node.js coding-agent (pi --mode rpc)
                                          ↓ JSONL stdin/stdout
```

**Go 需要实现**：
1. **JSONL 编解码器**：按 `\n` 严格分割 JSON 行（不能用通用行读取器，因为 JSON 内部可能包含 Unicode 行分隔符）
2. **命令发送**：`prompt`, `abort`, `get_state`, `set_model` 等 30+ 命令
3. **事件订阅与分发**：将 stdout 事件流转换为 WebSocket/SSE 推送给前端
4. **扩展 UI 请求处理**：将 `extension_ui_request` 转发到前端并等待回复
5. **子进程生命周期管理**：spawn、kill、重启、stderr 收集

**工作量**：中等（约 2-3 周）
**优点**：
- 完全复用 SproutClaw 的 Agent 引擎、Provider 支持、工具系统、扩展系统
- 不需要维护 LLM 通信层、OAuth 认证、模型发现等复杂逻辑
- 可以随时升级 SproutClaw 获得新功能

**缺点**：
- 依赖 Node.js 运行时（需要安装 pi）
- 每个会话一个 Node.js 子进程，内存开销较大

---

### 方案 B：Go 替代 coding-agent 的 RPC 层

**思路**：保留 `packages/ai` 和 `packages/agent` 的 Node.js 核心，但把 `coding-agent` 的 RPC 模式换成 Go 实现，通过 cgo/Node-API 或直接 HTTP 调用 Node.js 服务。

这个方案在实践中不太合理，因为：
- `coding-agent` 的 RPC 层只是薄薄一层 JSONL 转接（约 500 行代码）
- 核心复杂度都在 `AgentSession` 和 `AgentLoop` 中
- 如果 Go 要调用 Node.js 核心，反而增加了架构复杂度

**结论**：不推荐此方案，收益远低于成本。

---

### 方案 C：Go 完全重写整个 Agent 栈（最彻底，工作量最大）

**思路**：用 Go 重写 `packages/ai` + `packages/agent` + `packages/coding-agent` 的全部功能。

```
浏览器(WebUI) ←→ WebSocket/HTTP SSE ←→ Go 后端服务（完整 Agent 引擎）
                                          ↓
                                    LLM Provider APIs
```

**Go 需要实现**：

| 模块 | 复杂度 | 说明 |
|---|---|---|
| **LLM Provider 层** | 高 | 支持 Anthropic、OpenAI、Google、Azure、Bedrock、GitHub Copilot、OAuth 等 20+ Provider 的 API 协议、流式响应、错误处理、重试机制 |
| **模型注册表** | 中 | 模型发现、models.json 解析、动态 Provider 注册、 thinking level 支持 |
| **Agent 状态机** | 高 | `Agent` 类的完整行为：prompt/steer/followUp/abort、消息队列、状态管理、事件订阅 |
| **Agent Loop** | 高 | `agentLoop` 和 `agentLoopContinue`：LLM 调用 → 解析 toolCall → 执行工具 → 生成 toolResult → 继续对话的完整循环 |
| **工具系统** | 中 | 8 个内置工具（read/bash/edit/write/grep/find/ls）+ 扩展工具注册、参数校验（TypeBox schema）、并行/串行执行 |
| **Session 管理** | 中 | JSONL 会话持久化、树形分支结构、fork/clone/switch、历史导航 |
| **Compaction** | 中 | 上下文压缩：摘要生成、token 估算、裁剪策略 |
| **扩展系统** | 高 | TypeScript 扩展加载、命令注册、事件钩子（beforeToolCall/afterToolCall/tool_call/tool_result/input）、扩展 UI 上下文 |
| **Settings 管理** | 低 | JSON 配置读写、全局/项目级配置覆盖 |
| **Auth 系统** | 中 | API Key 管理、OAuth 2.0 流程（特别是 Anthropic 的 OAuth 和 GitHub Copilot 的 token 刷新） |
| **System Prompt** | 低 | 系统提示词构建、上下文文件拼接、技能注入 |
| **Web 传输层** | 中 | WebSocket/SSE 事件推送、HTTP API 设计、多会话并发管理 |

**工作量**：巨大（约 3-6 个月，需要 1-2 名全职工程师）

**优点**：
- 纯 Go 部署，单二进制文件，无 Node.js 依赖
- 内存占用低、启动快
- 完全掌控所有代码

**缺点**：
- 需要重写约 2 万行核心逻辑
- 必须持续跟踪 20+ LLM Provider 的 API 变化
- 失去 SproutClaw 的扩展生态（TypeScript 扩展无法直接在 Go 中运行）
- 失去 TypeScript 的类型安全优势
- 维护成本极高

---

## 四、推荐方案：Go 后端 + Node.js Agent 引擎

### 4.1 架构设计

```
┌─────────────────┐         ┌─────────────────┐         ┌─────────────────┐
│   Web 前端      │◄───────►│   Go 后端服务   │◄───────►│  Node.js Agent  │
│  (packages/     │ WebSocket│  (HTTP + WS)   │  JSONL  │ (pi --mode rpc) │
│   web-ui 或    │  / SSE   │                │ stdio   │                 │
│   自定义前端)   │         │                │         │                 │
└─────────────────┘         └─────────────────┘         └─────────────────┘
                                  │
                                  ▼
                           ┌─────────────────┐
                           │  会话管理 +     │
                           │  用户认证 +     │
                           │  持久化存储     │
                           └─────────────────┘
```

### 4.2 Go 后端核心模块

```go
// 1. 子进程管理器
type AgentProcessManager struct {
    processes map[string]*AgentProcess // sessionId -> process
}

type AgentProcess struct {
    cmd      *exec.Cmd
    stdin    io.WriteCloser
    stdout   io.ReadCloser
    stderr   io.ReadCloser
    events   chan AgentEvent
    requests map[string]chan RpcResponse
}

// 2. JSONL 协议解析（关键：不能用 bufio.Scanner，必须用自定义分割器）
func readJsonlLine(reader io.Reader) ([]byte, error) {
    // 严格按 \n 分割，不要按 Unicode 行分隔符分割
}

// 3. WebSocket 事件桥接
func (s *Server) handleWebSocket(ws *websocket.Conn, sessionId string) {
    process := manager.Get(sessionId)

    // Agent stdout → WebSocket
    go func() {
        for event := range process.events {
            ws.WriteJSON(event)
        }
    }()

    // WebSocket → Agent stdin
    for {
        var cmd RpcCommand
        ws.ReadJSON(&cmd)
        process.SendCommand(cmd)
    }
}

// 4. 扩展 UI 请求转发
func (p *AgentProcess) handleExtensionUIRequest(req ExtensionUIRequest) {
    // 通过 WebSocket 推送给前端，等待前端回复
    response := <-p.waitForUIResponse(req.ID)
    p.SendCommand(RpcExtensionUIResponse{...})
}
```

### 4.3 前端适配

SproutClaw 的 `packages/web-ui` 是 Lit 组件，理论上可以直接在浏览器中使用。但 RPC 模式的事件是面向 Node.js 进程的，需要适配：

```typescript
// 前端用 WebSocket 替代 stdin/stdout
class WebSocketRpcClient {
    ws: WebSocket;

    async prompt(message: string) {
        this.ws.send(JSON.stringify({ type: 'prompt', message }));
    }

    onEvent(listener: (event: AgentEvent) => void) {
        this.ws.onmessage = (msg) => listener(JSON.parse(msg.data));
    }
}
```

### 4.4 效果评估

| 维度 | 效果 |
|---|---|
| **功能完整性** | 100% 复用 SproutClaw 的全部能力（所有工具、所有 Provider、所有扩展） |
| **开发成本** | 中等（Go 后端约 2-3 周 + 前端适配 1 周） |
| **部署复杂度** | 需要同时部署 Go 服务和 Node.js 运行时（可打包到 Docker） |
| **性能** | 会话启动稍慢（spawn Node.js 进程），运行时性能取决于 Node.js 子进程 |
| **可维护性** | 高（核心逻辑跟随 SproutClaw 上游更新） |
| **扩展性** | 可以保留 SproutClaw 的 TypeScript 扩展生态 |

---

## 五、关键风险点

1. **JSONL 协议严格性**：必须按 `\n` 分割，不能用 `bufio.Scanner`（Unicode 行分隔符会导致 JSON 被错误分割）
2. **事件顺序保证**：`agent_end` 之后必须等待所有事件监听器完成，才能认为一轮对话结束
3. **扩展 UI 超时**：`extension_ui_request` 可能有 `timeout` 字段，前端必须限时回复
4. **子进程泄漏**：必须实现僵尸进程清理、信号转发（SIGTERM/SIGHUP）
5. **多会话并发**：每个用户/会话应隔离一个 Node.js 子进程，避免状态冲突

---

## 六、总结

| 问题 | 答案 |
|---|---|
| web-ui 后端是什么？ | `web-ui` 是前端组件库，**没有后端**。真正的"后端"是 `coding-agent` 的 **RPC 模式** |
| 如何交互？ | 通过 **stdin/stdout JSONL 协议**，命令→响应+事件流 |
| 能否重构成 Go？ | 可以，但推荐**方案 A**（Go 做协议桥接，保留 Node.js Agent 引擎） |
| 完全重写可行吗？ | 技术上可行，但工作量极大（3-6个月），且失去扩展生态 |
| 推荐怎么做？ | Go 后端服务（WebSocket + 子进程管理）+ `pi --mode rpc` 子进程，是最务实的方案 |


Successfully wrote 10377 bytes to /shumengya/docs/sproutclaw-webui-backend-analysis.md


用户要求深度检索 sproutclaw 项目的 webui 后端与 sproutclaw 的交互通信方式，以及重构为 Go 后端的效果。我已经完成了详细的分析并写入了文档。现在让我给用户一个简洁的中文回复，概述核心发现和结论。

深度检索完成！我为你生成了一份完整的分析报告，保存在 `/shumengya/docs/sproutclaw-webui-backend-analysis.md`。

## 核心发现

`packages/web-ui` **本身没有后端**——它只是一个纯前端的 Lit 组件库。webui 与 sproutclaw 交互的"后端"实际上是 `coding-agent` 的 **RPC 模式**：

```bash
pi --mode rpc
```

### 交互协议：JSONL over Stdio

通过 **stdin/stdout** 的严格 JSON Lines 协议通信，分为三层：

1. **命令层**（客户端 → Agent）—— 30+ 个命令：`prompt`、`abort`、`set_model`、`bash`、`get_messages` 等
2. **响应层**（Agent → 客户端）—— 同步返回的 `response` 对象
3. **事件层**（Agent → 客户端）—— 异步推送的完整事件流：
   ```
   agent_start → turn_start → message_start → message_update → message_end →
   tool_execution_start → tool_execution_end → turn_end → agent_end
   ```

### 扩展 UI 请求

扩展还可以向客户端发起弹窗、确认框、输入框等 `extension_ui_request`，客户端回复 `extension_ui_response`。

---

## Go 重构方案

| 方案 | 思路 | 工作量 | 推荐度 |
|---|---|---|---|
| **A** | Go 做 JSONL RPC 桥接，保留 Node.js `pi --mode rpc` 子进程 | 2-3 周 | ⭐⭐⭐ **强烈推荐** |
| **B** | Go 替代 coding-agent 的 RPC 层 | 收益低 | ❌ 不推荐 |
| **C** | Go 完全重写整个 Agent 栈（LLM Provider + Agent Loop + 工具系统） | 3-6 个月 | ⚠ 风险极高 |

**方案 A 是最务实的选择**：
- Go 后端负责 WebSocket/HTTP 服务、子进程管理、JSONL 协议转发
- 仍然 spawn Node.js 的 `coding-agent` 作为 Agent 引擎
- 100% 复用 SproutClaw 的全部能力（20+ Provider、8 个内置工具、扩展生态、Session 管理、Compaction 等）

**方案 C 的问题**：
- 需要重写约 2 万行核心逻辑（Agent 状态机、Agent Loop、20+ Provider 的流式 API、OAuth 认证、工具系统）
- 失去 TypeScript 扩展生态
- 必须持续跟踪所有 LLM Provider 的 API 变化

---

## 关键风险点

1. **JSONL 严格分割**：必须按 `\n` 分割，不能用通用行读取器（Unicode 行分隔符会破坏 JSON）
2. **事件顺序**：`agent_end` 之后必须等待所有监听器完成
3. **子进程管理**：必须实现僵尸清理、信号转发、stderr 收集
4. **扩展 UI 超时**：`extension_ui_request` 可能带 `timeout`，前端必须限时回复

如果你决定用方案 A，我可以帮你写一个 Go 的 `AgentProcessManager` 骨架代码，包括 JSONL 协议解析和 WebSocket 事件桥接。


你找错了！webui是sproutclaw的webui扩展插件！


用户说"webui是sproutclaw的webui扩展插件"。让我重新理解一下。用户说的是 sproutclaw 的 webui 扩展插件，不是 packages/web-ui 那个包。sproutclaw 的扩展（extensions）在 .pi/agent/extensions 目录下，可能有 webui 相关的扩展。

让我搜索一下 sproutclaw 的扩展目录，看看有没有 webui 扩展。

bash

find /shumengya/project/agent/sproutclaw -maxdepth 5 -type f | grep -i "webui\|web-ui\|web_ui" | head -40

find /shumengya/project/agent/sproutclaw -maxdepth 4 -type d | grep -i "webui\|web-ui\|web_ui" | head -30


找到了！真正的 webui 扩展在 `/shumengya/project/agent/sproutclaw/.pi/agent/extensions/webui/` 下。让我深入读取这个扩展的所有文件。

find /shumengya/project/agent/sproutclaw/.pi/agent/extensions/webui -type f | sort


找到了！真正的 webui 扩展在 `/shumengya/project/agent/sproutclaw/.pi/agent/extensions/webui/` 下。让我深入读取这个扩展的核心文件。关键文件：
- `index.ts` - 扩展入口
- `server.ts` - 后端服务器
- `frontend/src/api/` - 前端 API 层
- `frontend/src/App.tsx` - 前端应用

让我先读取扩展入口和后端服务器。


/**
 * WebUI 扩展 —— 在浏览器中通过网页与 pi 对话
 *
 * 安装：
 *   将本文件放入 .pi/extensions/webui/index.ts
 *
 * 用法：
 *   /webui on    → 启动网页服务（默认端口 19133）
 *   /webui off   → 停止网页服务
 *   /webui down  → 同 off
 *   /webui reload → 重载网页服务（等同 off + on，保留当前端口）
 *   /webui on 8080 → 指定端口启动
 */

import { spawn, spawnSync, execSync, type ChildProcess } from "node:child_process";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { existsSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import {
	DEFAULT_WEBUI_PORT,
	WEBUI_SERVICE_NAME,
	ensureSystemdService,
	getSystemdDisabledReason,
	isSystemdManaged,
	syncSystemdPort,
	systemdControl,
	type SystemdServiceConfig,
} from "./systemd/service.ts";

const __dirname = dirname(fileURLToPath(import.meta.url));
const PID_FILE = join(__dirname, ".webui.pid");
const DIST_DIR = join(__dirname, "frontend", "dist");
const FRONTEND_DIR = join(__dirname, "frontend");
const WEBUI_STARTUP_TIMEOUT_MS = process.platform === "win32" ? 20_000 : 10_000;

let serverProcess: ChildProcess | null = null;
let serverPort = DEFAULT_WEBUI_PORT;
let systemdConfig: SystemdServiceConfig | null = null;

function findTsx(): string {
	// On Linux/Mac, use the tsx shebang script directly
	let dir = __dirname;
	for (let i = 0; i < 10; i++) {
		const candidate = join(dir, "node_modules", ".bin", "tsx");
		if (existsSync(candidate)) return candidate;
		const parent = dirname(dir);
		if (parent === dir) break;
		dir = parent;
	}
	return join(__dirname, "..", "..", "..", "node_modules", ".bin", "tsx");
}

function findTsxMjs(): string {
	// On Windows, spawn node + tsx/dist/cli.mjs directly to avoid the cmd.exe wrapper
	// that shell:true introduces and which causes process lifecycle / probe timing issues
	let dir = __dirname;
	for (let i = 0; i < 10; i++) {
		const candidate = join(dir, "node_modules", "tsx", "dist", "cli.mjs");
		if (existsSync(candidate)) return candidate;
		const parent = dirname(dir);
		if (parent === dir) break;
		dir = parent;
	}
	return join(__dirname, "..", "..", "..", "node_modules", "tsx", "dist", "cli.mjs");
}

function findRepoRoot(): string {
	let dir = __dirname;
	for (let i = 0; i < 12; i++) {
		if (
			existsSync(join(dir, "package.json")) &&
			existsSync(join(dir, "packages", "coding-agent", "src", "cli.ts"))
		) {
			return dir;
		}
		const parent = dirname(dir);
		if (parent === dir) break;
		dir = parent;
	}
	return process.cwd();
}

function getAgentDir(repoRoot: string): string {
	return process.env.PI_CODING_AGENT_DIR || join(repoRoot, ".pi", "agent");
}

function readPidFile(): number | null {
	if (!existsSync(PID_FILE)) return null;
	try {
		const pid = parseInt(readFileSync(PID_FILE, "utf8").trim(), 10);
		return Number.isFinite(pid) && pid > 0 ? pid : null;
	} catch {
		return null;
	}
}

function writePidFile(pid: number): void {
	try {
		writeFileSync(PID_FILE, String(pid), "utf8");
	} catch {
		// 忽略：PID 文件只是辅助信息
	}
}

function clearPidFile(): void {
	try {
		if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
	} catch {
		// 忽略
	}
}

function isProcessAlive(pid: number): boolean {
	try {
		process.kill(pid, 0);
		return true;
	} catch {
		return false;
	}
}

async function waitForStartupByProbe(port: number, timeoutMs = WEBUI_STARTUP_TIMEOUT_MS): Promise<void> {
	const deadline = Date.now() + timeoutMs;
	while (Date.now() < deadline) {
		if ((await probePort(port)) === "running") return;
		await new Promise((r) => setTimeout(r, 200));
	}
	throw new Error("webui 启动超时");
}

function waitForStartup(port: number, child: ChildProcess): Promise<void> {
	return new Promise((resolve, reject) => {
		let settled = false;
		let timeout: NodeJS.Timeout | null = null;

		const finish = (err?: Error) => {
			if (settled) return;
			settled = true;
			if (timeout) clearTimeout(timeout);
			if (err) reject(err);
			else resolve();
		};

		const check = () => {
			if (child.exitCode !== null || child.signalCode !== null) {
				finish(new Error(`webui 子进程已退出，code=${child.exitCode ?? "null"}`));
				return;
			}

			const http = require("node:http");
			const req = http.get(
				{ hostname: "127.0.0.1", port, path: "/api/sessions", timeout: 1500 },
				(res: any) => {
					res.resume();
					if (res.statusCode === 200) finish();
					else setTimeout(check, 200);
				},
			);
			req.on("error", () => setTimeout(check, 200));
			req.on("timeout", () => {
				req.destroy();
				setTimeout(check, 200);
			});
		};

		timeout = setTimeout(() => finish(new Error("webui 启动超时")), WEBUI_STARTUP_TIMEOUT_MS);
		setTimeout(check, 300);
	});
}

function probePort(port: number): Promise<"free" | "running" | "occupied"> {
	return new Promise((resolve) => {
		const http = require("node:http");
		const req = http.get(
			{ hostname: "127.0.0.1", port, path: "/api/sessions", timeout: 1200 },
			(res: any) => {
				res.resume();
				resolve(res.statusCode === 200 ? "running" : "occupied");
			},
		);
		req.on("error", () => resolve("free"));
		req.on("timeout", () => {
			req.destroy();
			resolve("free");
		});
	});
}

function findNpm(): string {
	// On Windows prefer npm.cmd; the bare 'npm' is a bash script that Windows can't execute
	const candidates = process.platform === "win32" ? ["npm.cmd", "npm"] : ["npm"];

	for (const name of candidates) {
		const local = join(dirname(process.execPath), name);
		if (existsSync(local)) return local;
	}

	let dir = __dirname;
	for (let i = 0; i < 10; i++) {
		for (const name of candidates) {
			const candidate = join(dir, "node_modules", ".bin", name);
			if (existsSync(candidate)) return candidate;
		}
		const parent = dirname(dir);
		if (parent === dir) break;
		dir = parent;
	}

	return candidates[0];
}

function runNpm(
	args: string[],
	cwd: string,
): { ok: boolean; status: number | null; error?: string } {
	const result = spawnSync(findNpm(), args, {
		cwd,
		stdio: "inherit",
		env: process.env,
		shell: process.platform === "win32",
	});
	if (result.error) {
		return { ok: false, status: result.status, error: result.error.message };
	}
	if (result.status !== 0) {
		return { ok: false, status: result.status };
	}
	return { ok: true, status: 0 };
}

async function ensureBuilt(ctx: { ui: { notify: (msg: string, kind: string) => void } }): Promise<boolean> {
	const indexHtml = join(DIST_DIR, "index.html");
	if (existsSync(indexHtml)) return true;

	ctx.ui.notify("正在构建 WebUI 前端（build:web）...", "info");

	if (!existsSync(join(FRONTEND_DIR, "node_modules"))) {
		const install = runNpm(["install"], FRONTEND_DIR);
		if (!install.ok) {
			const detail = install.error ?? (install.status != null ? `exit ${install.status}` : "unknown");
			ctx.ui.notify(`WebUI 前端依赖安装失败: ${detail}`, "error");
			return false;
		}
	}

	const build = runNpm(["run", "build:web"], FRONTEND_DIR);
	if (!build.ok || !existsSync(indexHtml)) {
		const details: string[] = [];
		if (build.error) details.push(build.error);
		if (build.status != null && build.status !== 0) details.push(`exit ${build.status}`);
		if (!existsSync(indexHtml)) details.push(`未生成 ${indexHtml}`);
		ctx.ui.notify(`WebUI 前端构建失败${details.length ? `: ${details.join("; ")}` : ""}`, "error");
		return false;
	}

	ctx.ui.notify("WebUI 前端构建完成", "info");
	return true;
}

async function startServer(port: number, ctx: any): Promise<void> {
	if (!isSystemdManaged() && serverProcess) {
		ctx.ui.notify(`网页服务已在端口 ${serverPort} 运行`, "error");
		return;
	}

	const oldPid = readPidFile();
	if (oldPid && !isProcessAlive(oldPid)) {
		clearPidFile();
	}

	const state = await probePort(port);
	if (state === "running") {
		serverPort = port;
		const pids = findPidsByPort(port);
		if (pids.length) writePidFile(pids[0]);
		ctx.ui.notify(`网页服务已在端口 ${port} 运行`, "info");
		return;
	}
	if (state === "occupied") {
		ctx.ui.notify(`端口 ${port} 已被其他进程占用`, "error");
		return;
	}

	if (!(await ensureBuilt(ctx))) {
		return;
	}

	serverPort = port;

	if (isSystemdManaged() && systemdConfig) {
		syncSystemdPort(systemdConfig, port);
		const result = systemdControl("start");
		if (!result.ok) {
			ctx.ui.notify(`systemctl start 失败: ${result.output}`, "error");
			return;
		}
		try {
			await waitForStartupByProbe(port);
		} catch (err: unknown) {
			const message = err instanceof Error ? err.message : String(err);
			ctx.ui.notify(`网页服务启动失败: ${message}`, "error");
			return;
		}
		ctx.ui.notify(`网页服务已启动（systemd）→ http://smallmengya:${port}`, "info");
		notifyLanAddresses(ctx, port);
		return;
	}

	const repoRoot = findRepoRoot();
	const serverFile = join(__dirname, "backend", "main.ts");

	if (!existsSync(serverFile)) {
		ctx.ui.notify(`服务器文件未找到: ${serverFile}`, "error");
		return;
	}

	// On Windows, spawn node + tsx/dist/cli.mjs directly instead of tsx.cmd via shell:true.
	// The cmd.exe wrapper (shell:true) makes serverProcess point to cmd.exe, not the real
	// server process, breaking exit-code detection and causing startup probe timeouts.
	const [spawnCmd, spawnArgs] =
		process.platform === "win32"
			? [process.execPath, [findTsxMjs(), serverFile, "--port", String(port)]]
			: [findTsx(), [serverFile, "--port", String(port)]];

	serverProcess = spawn(spawnCmd, spawnArgs, {
		cwd: repoRoot,
		stdio: ["ignore", "inherit", "inherit"],
		env: {
			...process.env,
			PI_CODING_AGENT_DIR: getAgentDir(repoRoot),
		},
	});

	serverProcess.on("exit", () => {
		serverProcess = null;
		clearPidFile();
	});

	if (serverProcess.pid) {
		writePidFile(serverProcess.pid);
	}

	try {
		await waitForStartup(port, serverProcess);
	} catch (err: unknown) {
		// Kill the orphaned backend process so the port is freed for the next attempt
		if (serverProcess && serverProcess.exitCode === null) {
			killProcessTree(serverProcess.pid!);
		}
		clearPidFile();
		serverProcess = null;
		const message = err instanceof Error ? err.message : String(err);
		ctx.ui.notify(`网页服务启动失败: ${message}`, "error");
		return;
	}

	ctx.ui.notify(`网页服务已启动 → http://smallmengya:${port}`, "info");
	notifyLanAddresses(ctx, port);
}

function notifyLanAddresses(ctx: any, port: number): void {
	const { networkInterfaces } = require("node:os");
	const nets = networkInterfaces();
	for (const name of Object.keys(nets)) {
		for (const net of nets[name]) {
			if ((net.family === "IPv4" || net.family === 4) && !net.internal) {
				ctx.ui.notify(`  局域网: http://${net.address}:${port}`, "info");
			}
		}
	}
}

function findPidsByPort(port: number): number[] {
	try {
		if (process.platform === "win32") {
			const out = execSync(`netstat -ano`, { encoding: "utf8" });
			const pids = new Set<number>();
			for (const line of out.split(/\r?\n/)) {
				if (!line.includes(`:${port} `)) continue;
				const match = line.trim().match(/(\d+)\s*$/);
				if (match) {
					const pid = parseInt(match[1], 10);
					if (Number.isFinite(pid) && pid > 0) pids.add(pid);
				}
			}
			return [...pids];
		}
		const out = execSync(`ss -tlnp 'sport = :${port}'`, { encoding: "utf8" });
		const pids = new Set<number>();
		for (const match of out.matchAll(/pid=(\d+)/g)) {
			const pid = parseInt(match[1], 10);
			if (Number.isFinite(pid) && pid > 0) pids.add(pid);
		}
		return [...pids];
	} catch {
		return [];
	}
}

function killProcessTree(pid: number): void {
	try {
		if (process.platform === "win32") {
			spawnSync("taskkill", ["/F", "/T", "/PID", String(pid)], { encoding: "utf8" });
		} else {
			process.kill(pid, "SIGTERM");
		}
	} catch {
		/* ignore */
	}
}

async function waitForPortFree(port: number, timeoutMs = 5000): Promise<boolean> {
	const deadline = Date.now() + timeoutMs;
	while (Date.now() < deadline) {
		if ((await probePort(port)) === "free") return true;
		await new Promise((r) => setTimeout(r, 200));
	}
	return (await probePort(port)) === "free";
}

async function stopByPort(port: number, ctx: any): Promise<boolean> {
	const pids = findPidsByPort(port);
	if (!pids.length) return false;

	for (const pid of pids) {
		killProcessTree(pid);
	}
	await waitForPortFree(port);

	if ((await probePort(port)) === "free") {
		clearPidFile();
		ctx.ui.notify(`网页服务已停止（端口 ${port}，PID ${pids.join(", ")}）`, "info");
		return true;
	}

	ctx.ui.notify(`端口 ${port} 上的网页服务未能完全停止`, "error");
	return false;
}

async function stopServer(ctx: any, port = serverPort || DEFAULT_WEBUI_PORT): Promise<void> {
	if (isSystemdManaged()) {
		const state = await probePort(port);
		if (state === "free") {
			clearPidFile();
			ctx.ui.notify("网页服务未运行", "info");
			return;
		}
		const result = systemdControl("stop");
		if (!result.ok) {
			ctx.ui.notify(`systemctl stop 失败: ${result.output}`, "error");
			return;
		}
		await waitForPortFree(port);
		clearPidFile();
		serverPort = 0;
		ctx.ui.notify(`网页服务已停止（systemd，端口 ${port}）`, "info");
		return;
	}

	if (serverProcess?.pid) {
		killProcessTree(serverProcess.pid);
		serverProcess = null;
		serverPort = 0;
		clearPidFile();
		await waitForPortFree(port);
		ctx.ui.notify("网页服务已停止", "info");
		return;
	}

	const pid = readPidFile();
	if (pid && isProcessAlive(pid)) {
		try {
			killProcessTree(pid);
			await waitForPortFree(port);
			clearPidFile();
			ctx.ui.notify(`网页服务已停止（PID ${pid}）`, "info");
			return;
		} catch {
			clearPidFile();
		}
	}

	const state = await probePort(port);
	if (state === "running" || state === "occupied") {
		if (await stopByPort(port, ctx)) return;
		ctx.ui.notify(`端口 ${port} 仍被占用，请手动检查进程`, "error");
		return;
	}

	clearPidFile();
	ctx.ui.notify("网页服务未运行", "info");
}

async function reloadServer(ctx: any, port: number): Promise<void> {
	if (isSystemdManaged() && systemdConfig) {
		if (!(await ensureBuilt(ctx))) return;
		syncSystemdPort(systemdConfig, port);
		const result = systemdControl("restart");
		if (!result.ok) {
			ctx.ui.notify(`systemctl restart 失败: ${result.output}`, "error");
			return;
		}
		serverPort = port;
		try {
			await waitForStartupByProbe(port);
		} catch (err: unknown) {
			const message = err instanceof Error ? err.message : String(err);
			ctx.ui.notify(`网页服务重载失败: ${message}`, "error");
			return;
		}
		ctx.ui.notify(`网页服务已重载（systemd，端口 ${port}）`, "info");
		notifyLanAddresses(ctx, port);
		return;
	}

	await stopServer(ctx, port);
	await startServer(port, ctx);
}

export default function (pi: ExtensionAPI) {
	const repoRoot = findRepoRoot();
	systemdConfig = {
		extensionDir: __dirname,
		repoRoot,
		agentDir: getAgentDir(repoRoot),
		nodeBin: process.execPath,
		tsxCli: join(repoRoot, "node_modules", "tsx", "dist", "cli.mjs"),
	};

	if (ensureSystemdService(systemdConfig, serverPort || DEFAULT_WEBUI_PORT)) {
		console.log(`[webui] 已注册 systemd 保活服务: ${WEBUI_SERVICE_NAME}`);
	}

	pi.registerCommand("webui", {
		description: "通过 /webui on 启动、/webui off|down 停止、/webui reload 重载 Web 聊天界面",
		handler: async (args, ctx) => {
			const trimmed = args.trim();
			const [command = "", value = ""] = trimmed.split(/\s+/, 2);

			if (command === "off" || command === "stop" || command === "down" || command === "0") {
				const stopPort = value ? parseInt(value, 10) : serverPort || DEFAULT_WEBUI_PORT;
				await stopServer(ctx, Number.isFinite(stopPort) ? stopPort : DEFAULT_WEBUI_PORT);
				return;
			}

			if (command === "reload" || command === "restart") {
				const portArg = value ? parseInt(value, 10) : NaN;
				const reloadPort =
					Number.isFinite(portArg) && portArg >= 1 && portArg <= 65535
						? portArg
						: serverPort || DEFAULT_WEBUI_PORT;
				ctx.ui.notify(`正在重载 WebUI（端口 ${reloadPort}）…`, "info");
				await reloadServer(ctx, reloadPort);
				return;
			}

			const portInput = command === "on" ? value : command;
			const port = portInput ? parseInt(portInput, 10) : DEFAULT_WEBUI_PORT;
			if (isNaN(port) || port < 1 || port > 65535) {
				ctx.ui.notify(`无效端口: ${trimmed || "(空)"}，使用 ${DEFAULT_WEBUI_PORT}`, "error");
				return;
			}

			await startServer(port, ctx);
		},
	});
}


/**
 * 兼容旧启动路径：pi 扩展未重载时 index.ts 仍可能 spawn server.ts。
 * 实际逻辑在 backend/main.ts。
 */
import "./backend/main.ts";


#!/usr/bin/env node

/**
 * pi-mono WebUI Server
 *
 * 被 webui 扩展启动，作为独立子进程运行。
 * 内嵌 HTTP 服务 + pi RPC 子进程，提供浏览器聊天界面。
 *
 * 用法（由扩展自动调用）：
 *   tsx backend/main.ts --port 19133
 *
 * 前端静态文件位于 frontend/dist/ 下（Vite 构建产物）。
 */

import { existsSync, unlinkSync, writeFileSync } from "node:fs";
import { arch, hostname, platform, release, type } from "node:os";
import { parsePort } from "./config/cli.ts";
import { createWebUiPaths } from "./config/paths.ts";
import { closeWebuiDatabase, initWebuiDatabase } from "./db/index.ts";
import { createWebUiServer } from "./http/server.ts";
import { createPiClient } from "./rpc/pi-client.ts";
import type { WebUiContext } from "./types/context.ts";

const paths = createWebUiPaths();
const port = parsePort();

function writePidFile(): void {
	try {
		writeFileSync(paths.pidFile, String(process.pid), "utf8");
	} catch {
		/* ignore */
	}
}

function clearPidFile(): void {
	try {
		if (existsSync(paths.pidFile)) unlinkSync(paths.pidFile);
	} catch {
		/* ignore */
	}
}

function shutdown(exitCode = 0): never {
	closeWebuiDatabase();
	clearPidFile();
	process.exit(exitCode);
}

const webuiDb = initWebuiDatabase(paths.extensionRoot);
console.log(`[webui] 配置数据库: ${webuiDb.dbPath}`);
console.log(`[webui] 运行环境: Node.js ${process.version} | ${type()} ${release()} (${platform()}/${arch()}) | 主机: ${hostname()}`);

process.on("SIGTERM", () => shutdown(0));
process.on("SIGINT", () => shutdown(0));
writePidFile();

const piClient = createPiClient(paths, (code) => {
	if (code !== 0 && code !== null) {
		console.error(`[webui] pi RPC 进程异常退出 (code=${code})，WebUI 即将关闭`);
		setTimeout(() => shutdown(1), 500);
		return;
	}
	shutdown(1);
});

const ctx: WebUiContext = {
	config: { paths, port },
	rpc: {
		sendCmd: piClient.sendCmd,
		submitPrompt: piClient.submitPrompt,
		sendExtensionUiResponse: piClient.sendExtensionUiResponse,
		getRunSnapshot: piClient.getRunSnapshot,
		connectSseClient: piClient.connectSseClient,
		removeSseClient: piClient.removeSseClient,
	},
	db: webuiDb,
};

const server = createWebUiServer(ctx);

server.on("error", (err: NodeJS.ErrnoException) => {
	if (err.code === "EADDRINUSE") {
		console.error(`[webui] 端口 ${port} 已被占用`);
	} else {
		console.error(`[webui] HTTP 服务启动失败: ${err.message}`);
	}
	process.exit(1);
});

server.listen(port, "0.0.0.0", () => {
	console.log(`[webui] HTTP 服务已启动: http://localhost:${port}`);
	console.log(`[webui] 局域网访问: http://${hostname()}:${port}`);
});


让我继续读取 RPC 客户端和 HTTP 服务器的核心文件。


import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import type { ServerResponse } from "node:http";
import { resolvePiRpcLaunch, type WebUiPaths } from "../config/paths.ts";
import type { RunSnapshot, SendCmd, SubmitPromptOptions } from "../types/context.ts";

export interface PiClient {
	sendCmd: SendCmd;
	submitPrompt: (options: SubmitPromptOptions) => void;
	sendExtensionUiResponse: (id: string, response: Record<string, unknown>) => void;
	getRunSnapshot: () => RunSnapshot;
	connectSseClient: (res: ServerResponse) => void;
	removeSseClient: (res: ServerResponse) => void;
	child: ChildProcessWithoutNullStreams;
}

const BUFFERED_EVENT_TYPES = new Set([
	"agent_start",
	"agent_end",
	"message_start",
	"message_update",
	"message_end",
	"tool_execution_start",
	"tool_execution_update",
	"tool_execution_end",
	"compaction_start",
	"compaction_end",
	"queue_update",
	"auto_retry_start",
	"auto_retry_end",
	"bash_update",
]);

const DEFAULT_CMD_TIMEOUT_MS = 60_000;
const PROMPT_PREFLIGHT_TIMEOUT_MS = 30_000;
const PI_STDERR_BUFFER_MAX = 8192;

function formatSpawnArgs(args: string[]): string {
	return args.map((arg) => JSON.stringify(arg)).join(" ");
}

function summarizeStderr(buffer: string): string {
	const trimmed = buffer.trim();
	if (!trimmed) return "(无 stderr 输出)";
	const lines = trimmed.split(/\r?\n/).filter(Boolean);
	return lines.slice(-8).join("\n");
}

export function createPiClient(paths: WebUiPaths, onExit: (code: number | null) => void): PiClient {
	const piLaunch = resolvePiRpcLaunch(paths);
	console.log(
		`[webui] 启动 pi RPC (${piLaunch.mode}): ${piLaunch.command} ${formatSpawnArgs(piLaunch.args)}`,
	);

	const pi = spawn(piLaunch.command, piLaunch.args, {
		cwd: paths.repoRoot,
		stdio: ["pipe", "pipe", "pipe"],
		env: {
			...process.env,
			PI_CODING_AGENT_DIR: paths.agentDir,
		},
	});

	// Keep stdin pipe open; closing it on Windows can trigger RPC shutdown via stdin "end".
	pi.stdin.write("");

	let stderrBuffer = "";
	pi.stderr.on("data", (data: Buffer) => {
		const chunk = data.toString();
		stderrBuffer = (stderrBuffer + chunk).slice(-PI_STDERR_BUFFER_MAX);
		process.stderr.write(`[pi] ${data}`);
	});
	pi.on("exit", (code) => {
		console.log(`[webui] pi 退出, code=${code}`);
		if (code !== 0 && code !== null) {
			console.error(`[webui] pi RPC 启动失败 (code=${code}):\n${summarizeStderr(stderrBuffer)}`);
		}
		onExit(code);
	});

	let buffer = "";
	const pending = new Map<string, { resolve: (value: any) => void; reject: (error: Error) => void }>();
	const sseClients = new Set<ServerResponse>();
	let reqId = 0;

	let isStreaming = false;
	let sessionFile: string | undefined;
	let turnEventBuffer: Record<string, unknown>[] = [];

	function getRunSnapshot(): RunSnapshot {
		return {
			isStreaming,
			sessionFile,
			replay: [...turnEventBuffer],
		};
	}

	function broadcastSse(msg: Record<string, unknown>): void {
		const data = `data: ${JSON.stringify(msg)}\n\n`;
		for (const res of sseClients) res.write(data);
	}

	function trackAgentEvent(msg: Record<string, unknown>): void {
		const type = msg.type;
		if (typeof type !== "string") return;

		if (type === "agent_start") {
			isStreaming = true;
			turnEventBuffer = [msg];
			return;
		}

		if (type === "agent_end") {
			turnEventBuffer.push(msg);
			isStreaming = false;
			turnEventBuffer = [];
			return;
		}

		if (isStreaming && BUFFERED_EVENT_TYPES.has(type)) {
			turnEventBuffer.push(msg);
		}
	}

	function registerPending(
		id: string,
		handlers: { resolve: (value: any) => void; reject: (error: Error) => void },
		timeoutMs: number,
		timeoutLabel: string,
	): void {
		pending.set(id, handlers);
		setTimeout(() => {
			if (!pending.has(id)) return;
			pending.delete(id);
			handlers.reject(new Error(`命令超时: ${timeoutLabel}`));
		}, timeoutMs);
	}

	function onLine(line: string): void {
		if (!line.trim()) return;
		try {
			const msg = JSON.parse(line) as Record<string, unknown>;
			if (msg.type === "response" && msg.id && pending.has(String(msg.id))) {
				const id = String(msg.id);
				const p = pending.get(id)!;
				pending.delete(id);
				if (msg.command === "prompt" && msg.success === false) {
					broadcastSse({
						type: "prompt_rejected",
						error: String(msg.error || "prompt rejected"),
					});
				}
				if (msg.command === "get_state" && msg.success === true) {
					const data = msg.data as { sessionFile?: string } | undefined;
					if (data?.sessionFile) {
						sessionFile = data.sessionFile;
					}
				}
				p.resolve(msg);
				return;
			}
			trackAgentEvent(msg);
			broadcastSse(msg);
		} catch {
			/* ignore non-JSON lines */
		}
	}

	pi.stdout.on("data", (chunk: Buffer) => {
		buffer += chunk.toString();
		const lines = buffer.split("\n");
		buffer = lines.pop() || "";
		for (const line of lines) onLine(line);
	});

	const sendCmd: SendCmd = (command) =>
		new Promise((resolvePromise, reject) => {
			const id = `req_${++reqId}`;
			const line = JSON.stringify({ ...command, id }) + "\n";
			registerPending(
				id,
				{ resolve: resolvePromise, reject },
				DEFAULT_CMD_TIMEOUT_MS,
				String(command.type),
			);
			pi.stdin.write(line);
		});

	function submitPrompt(options: SubmitPromptOptions): void {
		const id = `req_${++reqId}`;
		const payload: Record<string, unknown> = {
			type: "prompt",
			message: options.message,
			images: options.images,
			id,
		};
		if (options.streamingBehavior) {
			payload.streamingBehavior = options.streamingBehavior;
		}
		const line = JSON.stringify(payload) + "\n";
		registerPending(
			id,
			{
				resolve: () => {
					/* preflight success: agent events arrive via SSE */
				},
				reject: (err) => {
					broadcastSse({ type: "prompt_rejected", error: err.message });
				},
			},
			PROMPT_PREFLIGHT_TIMEOUT_MS,
			"prompt",
		);
		pi.stdin.write(line);
	}

	function sendExtensionUiResponse(id: string, response: Record<string, unknown>): void {
		const line = JSON.stringify({ type: "extension_ui_response", id, ...response }) + "\n";
		pi.stdin.write(line);
	}

	function connectSseClient(res: ServerResponse): void {
		const snapshot = getRunSnapshot();
		res.write(
			`data: ${JSON.stringify({
				type: "connected",
				isStreaming: snapshot.isStreaming,
				replay: snapshot.replay,
			})}\n\n`,
		);
		sseClients.add(res);
	}

	return {
		sendCmd,
		submitPrompt,
		sendExtensionUiResponse,
		getRunSnapshot,
		connectSseClient,
		removeSseClient: (res) => sseClients.delete(res),
		child: pi,
	};
}


import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { applyCorsHeaders } from "./cors.ts";
import { serveStatic } from "./static.ts";
import { handleChatRoute } from "../routes/chat.ts";
import { handleExtensionUiRoute } from "../routes/extension-ui.ts";
import { handleCommandsRoute } from "../routes/commands.ts";
import { handleModelsRoute } from "../routes/models.ts";
import { handleSessionsRoute } from "../routes/sessions.ts";
import { handleSettingsRoute } from "../routes/settings.ts";
import { handleWebuiConfigRoute } from "../routes/webui-config.ts";
import type { WebUiContext } from "../types/context.ts";

export function createWebUiServer(ctx: WebUiContext) {
	return createServer((req: IncomingMessage, res: ServerResponse) => {
		const url = new URL(req.url!, `http://localhost:${ctx.config.port}`);
		const pathname = url.pathname;

		if (pathname.startsWith("/api/") && applyCorsHeaders(req, res)) {
			return;
		}

		if (req.method === "GET" && !pathname.startsWith("/api/")) {
			serveStatic(ctx.config.paths.publicDir, pathname, req, res, true);
			return;
		}

		if (handleChatRoute(req, res, ctx, pathname)) return;
		if (handleExtensionUiRoute(req, res, ctx, pathname)) return;
		if (handleSessionsRoute(req, res, ctx, pathname)) return;
		if (handleModelsRoute(req, res, ctx, pathname)) return;
		if (handleWebuiConfigRoute(req, res, ctx, pathname, url)) return;
		if (handleSettingsRoute(req, res, ctx, pathname)) return;
		if (handleCommandsRoute(req, res, ctx, pathname)) return;

		res.writeHead(404);
		res.end("Not found");
	});
}


让我继续读取路由层和配置层的文件，全面了解 HTTP API 和 RPC 通信。