Follow the action “the user types a prompt in the chat box and hits enter,” from zero to the end.
You are not writing a terminal CLI—you are writing an Electron desktop app that runs a Next.js server inside, connected to three different agent engines, pushing the AI’s thinking to the user’s screen in real time over SSE.
This tutorial takes CodePilot v0.62.0 as the blueprint. It starts from the product requirements document (PRD) and lists every feature module in development order. For each module it makes clear: purpose, form of implementation, tech stack, feature-definition trade-offs, strengths and costs, and how they cooperate.
Chapter 0: PRD — product requirements document
0.1 Product position
What CodePilot is: a multi-model AI-agent desktop client. The user opens a window on the desktop, picks any AI model (Claude / GPT / GLM / Kimi / DeepSeek…), and starts talking. The AI can not only chat; it can also read and write files, run commands, generate images, run scheduled tasks, even be remotely controlled through Telegram / Feishu.
What it is not: - Not yet another coding agent (Claude Code / Codex already do that well) - Not yet another chat client (ChatGPT desktop is already enough) - Not yet another IDE plugin (Cline / Cursor already occupy that market)
What it is: move “AI agent” from the terminal onto the desktop, and at the same time extend “coding agent” into “general agent”—chat, coding, tasks, media, remote control, all in one window.
0.2 Target users
| User portrait | Need | Pain |
|---|---|---|
| Developer | Use AI to assist coding | Does not want to type commands in a terminal; wants a GUI |
| Non-developer | Use AI to finish daily tasks | Cannot use a terminal, but needs agent capability |
| Multi-model user | Wants to compare different models | One client per model is too much trouble |
| Remote user | Wants to control the desktop agent from a phone | No ready bridging scheme |
0.3 Core feature requirements
| Priority | Feature | Description |
|---|---|---|
| P0 | Multi-model chat | 17+ AI providers; switching does not drop context |
| P0 | Three runtimes | Claude Code SDK / Native / Codex, swappable |
| P0 | Tool use | AI can read and write files, run commands |
| P0 | Permission control | Dangerous operations need user confirmation |
| P1 | Session management | SQLite persistence, rewind to any checkpoint |
| P1 | Sub-agents | Run subtasks in parallel |
| P1 | MCP integration | Attach external tools |
| P2 | IM Bridge | Telegram / Feishu / Discord / QQ / WeChat remote control |
| P2 | Media generation | Gemini image generation |
| P2 | Task scheduling | cron scheduled tasks |
| P3 | Skill system | Reusable agent skills |
0.4 Non-functional requirements
| Requirement | Metric |
|---|---|
| Cross-platform | macOS / Windows / Linux |
| Offline usable | Local SQLite, no cloud dependency |
| Response speed | First token < 2s |
| Memory | < 500MB |
| Extensible | MCP / Skills / Plugins |
0.5 Technical constraints
| Constraint | Reason |
|---|---|
| Electron | Cross-platform desktop + system capabilities (pty / notifications / file dialogs) |
| Next.js | App Router does both frontend and API layer |
| SQLite | Local persistence, zero config |
| TypeScript | Type safety |
| Vercel AI SDK | Unified multi-model interface |
Chapter 1: Tech choice
1.1 Why Electron + Next.js
Electron: - ✅ Cross-platform desktop (macOS / Windows / Linux) - ✅ System capabilities (pty / notifications / file dialogs / Tray) - ✅ Mature ecosystem (VS Code / Slack / Discord all use it) - ❌ High memory - ❌ Large packaged size
Next.js: - ✅ App Router does both frontend and API layer - ✅ React 19 ecosystem - ✅ SSR / SSG - ✅ Can be packaged as a standalone server invoked by Electron utilityProcess - ❌ Learning curve
Why not Tauri: - Tauri is lighter, but the ecosystem is less mature than Electron - CodePilot needs pty (terminal emulator); Electron’s node-pty is more mature - CodePilot needs utilityProcess to run the Next.js server; Electron supports that natively
Why not pure Web: - Needs local filesystem access (read and write files) - Needs local SQLite - Needs system notifications - Needs pty
1.2 Why SQLite
- ✅ Zero config
- ✅ Synchronous API (better-sqlite3)
- ✅ WAL mode (concurrent read/write)
- ✅ Local-first (no cloud dependency)
- ❌ No multi-machine sync
1.3 Why three runtimes
This is CodePilot’s most distinctive architectural decision:
| Runtime | Why it is needed | Why it cannot be replaced |
|---|---|---|
| Claude Code SDK | Best experience for Claude models | Locked to Anthropic |
| Native | Non-Claude models need their own loop | Cannot copy all of Claude Code’s capabilities |
| Codex | OpenAI models need their own engine | Locked to OpenAI |
Why you cannot use only one runtime: - Claude SDK only: locked to Anthropic, cannot run other models - Native only: cannot copy all of Claude Code’s capabilities (apply_patch, sub-agents, permission details) - Codex only: locked to OpenAI, cannot run other models
What the three runtimes share: frontend, DB, permissions, Bridge, Harness—but the agent loop is completely different.
1.4 Tech-stack overview
| Layer | Technology | Version |
|---|---|---|
| Desktop shell | Electron | 40 |
| Frontend framework | Next.js | 16 (App Router) |
| UI library | React | 19 |
| Style | Tailwind CSS | 4 |
| Component library | Radix UI | — |
| Database | better-sqlite3 | 12 |
| AI SDK | Vercel AI SDK | 4 |
| Claude SDK | @anthropic-ai/claude-agent-sdk | 0.2 |
| Syntax highlighting | Shiki | 3 |
| Markdown | react-markdown / streamdown | — |
| Packaging | electron-builder | 26 |
| Testing | Playwright / tsx + node:test | — |
| IM integration | Telegram Bot API / Feishu SDK / Discord.js | — |
Chapter 2: Feature-module development order
2.1 Development-order overview
By dependency, feature modules develop in this order:
第 1 阶段:基础设施
├── 模块 1:项目脚手架(Electron + Next.js + SQLite)
├── 模块 2:数据库层(db.ts)
└── 模块 3:聊天 UI 基础(MessageList + MessageInput)
第 2 阶段:核心引擎
├── 模块 4:SSE 流管理(stream-session-manager)
├── 模块 5:Provider 系统(provider-catalog + provider-resolver)
├── 模块 6:Runtime 注册表(runtime/registry)
├── 模块 7:Native Runtime(agent-loop + tools + permission)
├── 模块 8:Claude SDK Runtime(claude-client)
└── 模块 9:Codex Runtime(codex/app-server-manager)
第 3 阶段:工具与权限
├── 模块 10:权限系统(permission)
├── 模块 11:Harness 能力契约(harness/context-compiler)
├── 模块 12:工具系统(tools + builtin-tools)
└── 模块 13:MCP 集成(mcp-tool-adapter)
第 4 阶段:高级功能
├── 模块 14:技能系统(skill-parser + skill-discovery + skill-executor)
├── 模块 15:子 Agent 系统(subagent-orchestration)
├── 模块 16:Bridge 子系统(bridge-manager + adapters + channels)
├── 模块 17:媒体生成(image-generator + job-executor)
├── 模块 18:任务调度(tasks + scheduler)
└── 模块 19:设置系统(settings)
第 5 阶段:完善与发布
├── 模块 20:错误分类与诊断(error-classifier + provider-doctor)
├── 模块 21:Electron 主进程(electron/main)
└── 模块 22:打包与分发(electron-builder)
2.2 Dependency graph
graph TD
M1[模块1: 项目脚手架] --> M2[模块2: 数据库层]
M1 --> M3[模块3: 聊天UI基础]
M2 --> M4[模块4: SSE流管理]
M2 --> M5[模块5: Provider系统]
M5 --> M6[模块6: Runtime注册表]
M6 --> M7[模块7: Native Runtime]
M6 --> M8[模块8: Claude SDK Runtime]
M6 --> M9[模块9: Codex Runtime]
M7 --> M10[模块10: 权限系统]
M8 --> M10
M9 --> M10
M10 --> M11[模块11: Harness能力契约]
M11 --> M12[模块12: 工具系统]
M12 --> M13[模块13: MCP集成]
M7 --> M14[模块14: 技能系统]
M7 --> M15[模块15: 子Agent系统]
M4 --> M16[模块16: Bridge子系统]
M7 --> M17[模块17: 媒体生成]
M2 --> M18[模块18: 任务调度]
M2 --> M19[模块19: 设置系统]
M5 --> M20[模块20: 错误分类与诊断]
M1 --> M21[模块21: Electron主进程]
M21 --> M22[模块22: 打包与分发]
Chapter 3: Module 1 — project scaffolding
3.1 Purpose
Stand up the Electron + Next.js + SQLite development environment so that npm run electron:dev can start the Next.js dev server and the Electron window at the same time.
3.2 Form of implementation
package.json: define dependencies and scriptselectron/main.ts: Electron main-process entryelectron/preload.ts: contextBridge exposurenext.config.ts: Next.js configtsconfig.json: TypeScript config
3.3 Tech stack
- Electron 40
- Next.js 16 (App Router)
- TypeScript
- better-sqlite3
3.4 Feature-definition trade-offs
Why use utilityProcess.fork to run the Next.js server: - ✅ The main process only does the shell (window / terminal / notifications); the Next.js server runs business logic - ✅ Process isolation; a Next.js crash does not take down the main process - ✅ The Next.js server can be restarted independently - ❌ One more process; memory slightly higher
Why not run Next.js directly in the main process: - The main process is a Node.js environment; Next.js needs its own runtime - utilityProcess is what Electron designed for this scene
3.5 Strengths and costs
| Strength | Cost |
|---|---|
| Cross-platform desktop | High memory |
| Complete system capabilities | Large packaged size |
| Mature ecosystem | Learning curve |
3.6 How they cooperate
- The Electron main process starts the Next.js server through
utilityProcess.fork - The Next.js server listens on a free port
- The main process loads the Next.js page through
mainWindow.loadURL - Main process and Next.js server communicate over HTTP / SSE
Chapter 4: Module 2 — database layer
4.1 Purpose
Persist all data with SQLite: chat sessions, messages, settings, tasks, provider config, media, Bridge bindings.
4.2 Form of implementation
src/lib/db.ts: database schema definition + CRUD + migration logic
4.3 Tech stack
- better-sqlite3 (synchronous API, WAL mode)
4.4 Feature-definition trade-offs
Why better-sqlite3 rather than Prisma / Drizzle: - ✅ Synchronous API; no need for async/await - ✅ Zero extra dependency; no extra ORM - ✅ Good performance - ❌ No type safety (need to write types by hand) - ❌ No migration tool (need to write migration logic by hand)
Why WAL mode: - ✅ Concurrent read/write (reads do not block writes, writes do not block reads) - ✅ Better performance - ❌ Needs extra -wal and -shm files
Why a file lock for migration:
- Several Next.js workers may start at once; need to prevent concurrent migration
- O_CREAT|O_EXCL file lock + 10s retry
4.5 Strengths and costs
| Strength | Cost |
|---|---|
| Zero config | No multi-machine sync |
| Synchronous API | No type safety |
| WAL mode | Need to write migrations by hand |
| Local-first | Single point of failure |
4.6 How they cooperate
- Every module gets the database connection through
getDb() initDb()initializes the schema on first callmigrateDb()migrates automatically when the schema changeswithMigrationLock()prevents concurrent migration
Chapter 5: Module 3 — chat UI basics
5.1 Purpose
Implement the chat UI’s basic components: message list (MessageList) and message input (MessageInput).
5.2 Form of implementation
src/components/chat/MessageList.tsx: message list (virtualized)src/components/chat/MessageItem.tsx: one message (dispatch render by block.type)src/components/chat/MessageInput.tsx: message inputsrc/components/chat/StreamingMessage.tsx: streaming message
5.3 Tech stack
- React 19
- Tailwind CSS 4
- Radix UI
5.4 Feature-definition trade-offs
Why virtualize: - A long session may have hundreds of messages; rendering them all will stutter - Virtualization only renders messages in the visible region
Why dispatch render by block.type: - One message may contain several kinds of content: text / thinking / tool_use / tool_result - Each kind needs a different render
5.5 Strengths and costs
| Strength | Cost |
|---|---|
| Virtualization performs well | Implementation is complex |
| Dispatch render is flexible | Need to maintain several renderers |
5.6 How they cooperate
MessageListgets message data from theuseSSEStreamhookMessageItemdispatches to different renderers byblock.typeMessageInputsends messages through POST/api/chat
Chapter 6: Module 4 — SSE stream management
6.1 Purpose
Manage the SSE stream lifecycle: create, pause, resume, destroy. Ensure that even if the user refreshes the page or switches sessions, the stream can resume correctly.
6.2 Form of implementation
src/lib/stream-session-manager.ts: SSE stream lifecycle management (1439 lines)
6.3 Tech stack
- Fetch API (ReadableStream)
- globalThis singleton (HMR-resistant)
6.4 Feature-definition trade-offs
Why a globalThis singleton: - Next.js HMR reloads modules, causing streams to be lost - A globalThis singleton ensures the stream still exists after HMR
Why a two-level idle budget: - 10min before first token: the model may need a long time to return the first token - 5.5min after that: if 5.5min with no new token, the stream may be stuck
Why a 2s force-abort safety net: - #578 fix: prevent an interrupt request hanging and leaving the stream stuck in active forever
6.5 Strengths and costs
| Strength | Cost |
|---|---|
| Stream lives independently of components | Implementation is complex |
| Two-level idle budget | Needs tuning |
| Force-abort safety net | May kill by mistake |
6.6 How they cooperate
startStreamcreates the stream,fetch('/api/chat'),consumeSSEStreamstopStreamWithstops the stream: first arm the 2s force-abort, then call/api/chat/interruptsubscribelets components subscribe to stream snapshots
Chapter 7: Module 5 — Provider system
7.1 Purpose
Manage configuration, switching, and resolution of 17+ AI providers. Ensure the user can switch models mid-conversation without losing context.
7.2 Form of implementation
src/lib/provider-catalog.ts: provider presets (2400 lines)src/lib/provider-resolver.ts: provider resolution (1793 lines)src/lib/ai-provider.ts: AI SDK multi-model wrapper
7.3 Tech stack
- Zod (validation)
- Vercel AI SDK
7.4 Feature-definition trade-offs
Why a preset catalog: - Each provider has its own baseUrl, authStyle, defaultModels, roleModels - The preset catalog saves the user from configuring by hand
Why five-level resolution priority:
- providerId > sessionProviderId > default_provider_id > active
- Ensures the provider the user explicitly chose has the highest priority
Why virtual providers:
- env / openai-oauth / xai-oauth / codex_account are not real providers, but need special handling at resolve time
7.5 Strengths and costs
| Strength | Cost |
|---|---|
| 17+ providers out of the box | The preset catalog needs maintenance |
| Switching does not drop context | Resolution logic is complex |
| Virtual providers are flexible | Need special handling |
7.6 How they cooperate
resolveProvider()resolves which provider should be used nowcreateModel()creates an AI SDK model from the providertoClaudeCodeEnv()converts provider config into Claude Code SDK environment variables
Chapter 8: Module 6 — Runtime registry
8.1 Purpose
Manage registration, selection, and switching of the three runtimes. Ensure the same frontend can run different agent engines.
8.2 Form of implementation
src/lib/runtime/registry.ts: runtime registry (152 lines)src/lib/runtime/types.ts: runtime type definitionssrc/lib/runtime/contract.ts: cross-runtime contract
8.3 Tech stack
- Map (the registry)
8.4 Feature-definition trade-offs
Why five-level selection:
- Codex explicit → explicit override → cli_enabled=false → global settings → auto
- Ensures the runtime the user explicitly chose has the highest priority
Why canonical run events: - The three runtimes’ event formats differ; they need to be unified into canonical events - The UI only consumes canonical events; it does not need to care about the concrete runtime
8.5 Strengths and costs
| Strength | Cost |
|---|---|
| Runtimes are swappable | Need to maintain three implementations |
| Canonical events unify | Need a translation layer |
8.6 How they cooperate
resolveRuntime()selects which runtime should be used now- Each runtime’s adapter translates its own events into canonical events
- The UI only consumes canonical events
Chapter 9: Module 7 — Native Runtime
9.1 Purpose
Implement a self-built agent loop, used to run non-Claude models.
9.2 Form of implementation
src/lib/agent-loop.ts: Native loop (1019 lines)src/lib/tools/: 8 coding toolssrc/lib/builtin-tools/: platform tools
9.3 Tech stack
- Vercel AI SDK
streamText
9.4 Feature-definition trade-offs
Why a manual while loop: - The AI SDK’s automatic mode cannot insert permission checks, DB persistence, timeout budgets between steps - A manual loop can do those things between every step
Why pruneOldToolResults:
- Context grows without bound; old tool_result need to be trimmed
Why doom-loop detection: - The model may fall into a death loop (the same tool called N times) - Need to detect and terminate inside the loop
9.5 Strengths and costs
| Strength | Cost |
|---|---|
| Fully autonomous and controllable | Need to maintain loop logic |
| Can insert guardrails between steps | Implementation is complex |
9.6 How they cooperate
runAgentLoop()is the entry; it returnsReadableStream<string>- Each step calls
streamText(), translating events one by one into SSE - Permission check before tool execution
- DB persistence after every step
Chapter 10: Module 8 — Claude SDK Runtime
10.1 Purpose
Wrap the Claude Agent SDK; translate events from the SDK child process into SSE.
10.2 Form of implementation
src/lib/claude-client.ts: SDK wrapper (3628 lines)
10.3 Tech stack
@anthropic-ai/claude-agent-sdk
10.4 Feature-definition trade-offs
Why an event pump rather than a real loop: - The SDK child process already has a complete agent loop inside - CodePilot only needs to translate events into SSE
Why lockId gating: - Prevent a late teardown of a superseded old turn from evicting the Query registered by the new turn (I1 race)
Why PTL compaction retry:
- CONTEXT_TOO_LONG triggers automatic compaction retry
- ptlRetryAttempted against a death loop
10.5 Strengths and costs
| Strength | Cost |
|---|---|
| Reuse the full capability of Claude Code | Locked to Anthropic |
| Do not need to implement the loop yourself | Cannot control loop details |
10.6 How they cooperate
streamClaudeSdk()creates an SDK conversationfor await (const message of conversation)consumes events one by oneconversation-registry.tsregisters active sessionsstream-session-manager.tsmanages the SSE stream
Chapter 11: Module 9 — Codex Runtime
11.1 Purpose
Integrate OpenAI Codex so Codex models can also run inside CodePilot.
11.2 Form of implementation
src/lib/codex/app-server-manager.ts: Codex app-server management (603 lines)src/lib/codex/runtime.ts: Codex Runtimesrc/lib/codex/proxy/: Codex proxy (local Responses API proxy)
11.3 Tech stack
codex app-server(stdio JSON-RPC)
11.4 Feature-definition trade-offs
Why app-server rather than calling the API directly: - Codex’s agent loop is inside app-server - Calling the API directly cannot copy Codex’s full capability
Why a proxy: - Let the Codex CLI engine run on any provider the user configured - Codex-account models do not go through the proxy; they go directly through app-server
11.5 Strengths and costs
| Strength | Cost |
|---|---|
| Reuse Codex’s full capability | Locked to OpenAI |
| The proxy can run any provider | Need to maintain the proxy |
11.6 How they cooperate
getCodexAppServer()finds thecodexbinary and spawnscodex app-serverthread/resume|thread/startcreates a sessionturn/startstarts one conversation turnevent-mapper.translateCodexNotificationtranslates into a canonical event, then sends SSE
Chapter 12: Module 10 — permission system
12.1 Purpose
Ensure the AI’s dangerous operations (write files, run commands) need user confirmation.
12.2 Form of implementation
src/lib/permission-checker.ts: rule enginesrc/lib/permission/profile.ts: session Profilesrc/lib/permission/approval-token.ts: HMAC against forgery
12.3 Tech stack
- HMAC (against forgery)
12.4 Feature-definition trade-offs
Why three layers of permission: - Rule engine: first line of defense, quickly intercept obviously dangerous operations - Modal at execution: second line of defense, let the user confirm uncertain operations - Session Profile: third line of defense, allow the user to set “fully automatic” or “fully manual”
Why HMAC against forgery: - The permission request’s approvalToken needs to be unforgeable, otherwise a malicious site can forge a permission confirmation
Why a 5-minute timeout: - The user may forget to confirm; need timeout auto-deny
12.5 Strengths and costs
| Strength | Cost |
|---|---|
| Three layers of defense are safe | Implementation is complex |
| HMAC against forgery | Need to manage keys |
| Timeout auto-deny | May kill by mistake |
12.6 How they cooperate
checkPermissionbefore tool execution- On
ask, write DBpermission_requests, sendpermission_requestSSE registerPendingPermissionparks waiting for the/api/chat/permissionreply- 5-minute timeout auto-deny
Chapter 13: Module 11 — Harness capability contract
13.1 Purpose
Guarantee that the capability description the three runtimes show the model does not drift.
13.2 Form of implementation
src/lib/harness/capability-contract.ts: capability contractsrc/lib/harness/context-compiler.ts: context compilationsrc/lib/harness/mutation-level.ts: tool mutation-level classification
13.3 Tech stack
- Pure functions
13.4 Feature-definition trade-offs
Why pure functions: - No IO, do not execute tools, do not make permission decisions - Only produce compiled context
Why canonical prompt fragments: - The three runtimes need to show the model a consistent capability description - Canonical fragments ensure no drift
13.5 Strengths and costs
| Strength | Cost |
|---|---|
| The three runtimes are consistent | Need to maintain canonical fragments |
| Pure functions are testable | Implementation is complex |
13.6 How they cooperate
compileContext(input)produces system prompt + tool surface + artifact contract- Each of the three runtimes’ adapters only “adapts the compiler output”
Chapter 14: Module 12 — tool system
14.1 Purpose
Implement tools the AI can call: read and write files, run commands, search, sub-agents.
14.2 Form of implementation
src/lib/tools/: 8 coding toolssrc/lib/builtin-tools/: platform tools
14.3 Tech stack
- Vercel AI SDK ToolSet
- Zod
14.4 Feature-definition trade-offs
Why a standard ToolSet: - Freely combinable, gating, wrap at runtime - Seamless integration with the AI SDK
Why two layers of tool source: - Coding tools: Read / Write / Edit / Bash / Glob / Grep / Skill / Agent - Platform tools: notification / memory-search / dashboard / media / widget-guidelines / session-search / cli-tools / ask-user-question
14.5 Strengths and costs
| Strength | Cost |
|---|---|
| Standard ToolSet is flexible | Need to maintain two layers |
| Platform tools are rich | Implementation is complex |
14.6 How they cooperate
assembleTools()merges coding tools + platform tools + MCP toolswrapWithPermissions()wraps a layer of execute intercept- Hand to
streamText({ tools })
Chapter 15: Module 13 — MCP integration
15.1 Purpose
Attach external MCP servers so the AI can call external tools.
15.2 Form of implementation
src/lib/mcp-tool-adapter.ts: MCP tool adapter
15.3 Tech stack
- MCP protocol (stdio / sse / http)
15.4 Feature-definition trade-offs
Why support three transports: - stdio: local MCP server - sse: remote MCP server - http: remote MCP server
15.5 Strengths and costs
| Strength | Cost |
|---|---|
| Standardized protocol | Need to maintain the adapter |
| Three transports are flexible | Implementation is complex |
15.6 How they cooperate
buildMcpToolSet()aggregates tools from external MCP servers into the ToolSet- Together with coding tools and platform tools, handed to
streamText({ tools })
Chapter 16: Module 14 — skill system
16.1 Purpose
Let the user define reusable agent skills.
16.2 Form of implementation
src/lib/skill-parser.ts: skill parsesrc/lib/skill-discovery.ts: skill discoverysrc/lib/skill-executor.ts: skill execution
16.3 Tech stack
- YAML frontmatter + Markdown body
16.4 Feature-definition trade-offs
Why compatible with Claude Code format: - The user may already have some Claude Code skills - Format compatibility lowers migration cost
Why fork mode: - A skill can run as a sub-agent - Lands in the subagent_runs durable system
16.5 Strengths and costs
| Strength | Cost |
|---|---|
| Compatible with Claude Code | Need to maintain the parser |
| Fork mode is flexible | Implementation is complex |
16.6 How they cooperate
skill-discovery.tsscans the skill directoryskill-parser.tsparses skill filesskill-executor.tsexecutes skills (inline inject or fork a sub-agent)
Chapter 17: Module 15 — sub-agent system
17.1 Purpose
Let the AI spawn sub-agents to run tasks in parallel.
17.2 Form of implementation
src/lib/subagent-orchestration.ts: dependency orchestrationsrc/lib/subagent-models.ts: multi-model routingsrc/lib/db.ts: subagent_runs table
17.3 Tech stack
- SQLite (durable lifecycle)
17.4 Feature-definition trade-offs
Why a durable lifecycle: - A sub-agent may crash; state needs to be persisted - The user may refresh the page; need to restore the sub-agent
Why a declarative DAG: - Sub-agents may have dependencies - A declarative DAG makes dependencies explicit
17.5 Strengths and costs
| Strength | Cost |
|---|---|
| High reliability | Implementation is complex |
| Dependency orchestration is flexible | Need to maintain the DAG |
17.6 How they cooperate
- Before starting a child, first write
subagent_runs.running - After end,
settling → terminal - The resolver polls the durable table waiting for upstream terminal
Chapter 18: Module 16 — Bridge subsystem
18.1 Purpose
Let the user remotely control the desktop agent through Telegram / Feishu / Discord / QQ / WeChat.
18.2 Form of implementation
src/lib/bridge/bridge-manager.ts: Bridge manager (1371 lines)src/lib/bridge/channel-adapter.ts: channel-adapter base classsrc/lib/bridge/adapters/: 5 channel adapterssrc/lib/channels/: channel plugins
18.3 Tech stack
- Telegram Bot API
- Feishu SDK
- Discord.js
18.4 Feature-definition trade-offs
Why the adapter pattern: - Each IM platform’s API is different; adapters unify the interface - A new platform only needs to implement an adapter
Why permission forwarding: - A Claude stream will block waiting for permission; need to turn the permission request into an IM inline button - The user clicking the button in IM can confirm permission
18.5 Strengths and costs
| Strength | Cost |
|---|---|
| Remote control | Implementation is complex |
| Permission forwarding | Need to handle channel differences |
18.6 How they cooperate
adapter.consumeOne()receives the IM messagerouter.resolve()routes to a CodePilot sessionengine.processMessage()calls the SDKdelivery-layerformats + shards send back to IMpermission-brokerturns permission requests into IM inline buttons
Chapter 19: Module 17 — media generation
19.1 Purpose
Let the AI generate images.
19.2 Form of implementation
src/lib/image-generator.ts: image generationsrc/lib/job-executor.ts: batch job executor
19.3 Tech stack
- Gemini API
- Anthropic API
19.4 Feature-definition trade-offs
Why batch jobs: - The user may need to generate several images at once - Batch jobs can run in parallel
19.5 Strengths and costs
| Strength | Cost |
|---|---|
| Batch jobs | Need to manage a job queue |
| Gallery display | Need to store images |
19.6 How they cooperate
generateSingleImage()generates one imagejob-executorruns batch jobs- Gallery displays generated images
Chapter 20: Module 18 — task scheduling
20.1 Purpose
Let the user run tasks on a schedule.
20.2 Form of implementation
src/lib/tasks.ts: task managementsrc/lib/scheduler.ts: task scheduling
20.3 Tech stack
- cron expressions
20.4 Feature-definition trade-offs
Why cron: - A standard time expression - The user may already know it
20.5 Strengths and costs
| Strength | Cost |
|---|---|
| Standard cron | Need to parse cron expressions |
| Scheduled tasks | Need to manage the scheduler |
20.6 How they cooperate
ensureSchedulerRunningstarts on the first chat request- cron expression or interval scheduling
- Task results stored in the DB
Chapter 21: Module 19 — settings system
21.1 Purpose
Let the user configure application settings.
21.2 Form of implementation
src/app/settings/: settings pagessrc/lib/settings.ts: settings management
21.3 Tech stack
- React 19
- Tailwind CSS 4
21.4 Feature-definition trade-offs
Why key-value storage: - Settings are simple key-value pairs - Do not need a complex schema
21.5 Strengths and costs
| Strength | Cost |
|---|---|
| Simple | Does not support complex settings |
| Key-value storage | Need to manage by hand |
21.6 How they cooperate
- The
settingstable stores key-value pairs - Settings pages read/write settings
- Other modules read settings through
getSetting()
Chapter 22: Module 20 — error classification and diagnosis
22.1 Purpose
Let the user quickly diagnose and fix problems.
22.2 Form of implementation
src/lib/error-classifier.ts: error classification (589 lines)src/lib/provider-doctor.ts: provider diagnosis (1093 lines)
22.3 Tech stack
- Regular expressions
- Probe pattern
22.4 Feature-definition trade-offs
Why 28 classes of structured errors: - Different errors need different handling - Structured errors let the UI show concrete repair suggestions
Why 5 fast probes: - Run in parallel, diagnose quickly - Each probe only watches one aspect
22.5 Strengths and costs
| Strength | Cost |
|---|---|
| Structured errors | Need to maintain error patterns |
| Fast diagnosis | Need to maintain probes |
22.6 How they cooperate
classifyErrorclassifies errorsbuildRecoveryActionsgenerates repair suggestionsrunDiagnosisruns 5 probes in parallelcomputeRepairsgenerates repair actions
Chapter 23: Module 21 — Electron main process
23.1 Purpose
Manage the Electron window, terminal, notifications, file dialogs.
23.2 Form of implementation
electron/main.ts: Electron main process (2566 lines)electron/preload.ts: contextBridge exposure
23.3 Tech stack
- Electron 40
- node-pty
23.4 Feature-definition trade-offs
Why utilityProcess.fork: - The main process only does the shell; the Next.js server runs business logic - Process isolation; a Next.js crash does not take down the main process
Why node-pty: - Need a terminal emulator - node-pty is the most mature pty library in the Electron ecosystem
23.5 Strengths and costs
| Strength | Cost |
|---|---|
| Complete system capabilities | High memory |
| Process isolation | Large packaged size |
23.6 How they cooperate
utilityProcess.forkstarts the Next.js servermainWindow.loadURLloads the Next.js pagecontextBridge.exposeInMainWorldexposes system capabilitiesnode-ptyprovides the terminal emulator
Chapter 24: Module 22 — packaging and distribution
24.1 Purpose
Package the application into macOS / Windows / Linux executables.
24.2 Form of implementation
electron-builder.yml: packaging configscripts/build-electron.mjs: packaging script
24.3 Tech stack
- electron-builder 26
24.4 Feature-definition trade-offs
Why electron-builder: - A mature packaging tool - Supports macOS / Windows / Linux
24.5 Strengths and costs
| Strength | Cost |
|---|---|
| Mature | Large packaged size |
| Cross-platform | Long packaging time |
24.6 How they cooperate
npm run electron:buildbuilds Next.js + Electronnpm run electron:packpackages into executableselectron-buildergenerates DMG / NSIS / AppImage
Chapter 25: The complete workflow after the user types a prompt
This is the most important chapter of the whole tutorial. We follow the action “the user types a prompt in the chat box and hits enter,” from the frontmost React component, all the way to the deepest agent engine, then back to the frontmost screen render, one complete pass.
25.1 Panoramic flowchart
sequenceDiagram
participant U as 用户
participant UI as MessageInput
participant API as /api/chat
participant Lock as 会话锁
participant Ctx as assembleContext
participant RT as Runtime Registry
participant SDK as Claude SDK Runtime
participant NAT as Native Runtime
participant CDX as Codex Runtime
participant SSE as stream-session-manager
participant Hook as useSSEStream
participant ML as MessageList
participant DB as SQLite
U->>UI: 输入提示词,按回车
UI->>API: POST /api/chat
API->>Lock: acquireSessionLock
Lock-->>API: 409 SESSION_BUSY / 200 OK
API->>DB: addMessage
API->>Ctx: assembleContext
Ctx-->>API: system prompt + tools + messages
API->>RT: resolveRuntime
RT-->>API: claude-code-sdk / native / codex
alt Claude SDK Runtime
API->>SDK: streamClaudeSdk
SDK->>SDK: SDK query() 子进程
SDK-->>API: SSE 事件流
else Native Runtime
API->>NAT: runAgentLoop
NAT->>NAT: while (step < maxSteps)
NAT-->>API: SSE 事件流
else Codex Runtime
API->>CDX: codex app-server
CDX->>CDX: thread/start + turn/start
CDX-->>API: SSE 事件流
end
API-->>SSE: SSE 流式返回
SSE->>Hook: consumeSSEStream
Hook->>ML: 渲染消息
ML-->>U: 显示 AI 响应
SSE->>DB: collectStreamResponse 持久化
API->>Lock: 释放锁
25.2 Step one: user input
The user types a prompt in the MessageInput component and hits enter.
// src/components/chat/MessageInput.tsx
const handleSubmit = async (content: string) => {
// 1. 乐观更新 UI(立即显示用户消息)
addUserMessage(content)
// 2. 发送 POST 请求
await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({
session_id: currentSessionId,
content,
model: currentModel,
provider_id: currentProviderId,
})
})
}
Load-bearing points: - Optimistic update: the user message displays immediately, without waiting for the server - The POST request contains session_id, content, model, provider_id
25.3 Step two: API route handling
The /api/chat route (src/app/api/chat/route.ts) handles the request and triggers the agent. There is also /api/chat/messages, which only persists messages and does not run a model.
// src/app/api/chat/route.ts
export async function POST(req: Request) {
// 1. 校验 body
const { session_id, content, model, provider_id } = await req.json()
// 2. 前置检查:是否有可用的 Provider
if (!hasCodePilotProvider()) {
return new Response('NEEDS_PROVIDER_SETUP', { status: 412 })
}
// 3. 会话锁:防止同一会话并发请求
const lock = await acquireSessionLock(session_id)
if (!lock) {
return new Response('SESSION_BUSY', { status: 409 })
}
try {
// 4. 解析 Provider + Runtime
const provider = await resolveProvider(provider_id)
const runtime = await resolveRuntime(provider)
// 5. 消息入库
await addMessage(session_id, 'user', content)
// 6. 上下文组装
const context = await assembleContext(session_id, provider, runtime)
// 7. 分流到 Runtime
let stream: ReadableStream<string>
switch (runtime) {
case 'claude-code-sdk':
stream = await streamClaudeSdk(context)
break
case 'native':
stream = await runAgentLoop(context)
break
case 'codex':
stream = await runCodexRuntime(context)
break
}
// 8. SSE 流式返回
return new Response(stream, {
headers: { 'Content-Type': 'text/event-stream' }
})
} finally {
// 9. 释放锁
await releaseSessionLock(session_id)
}
}
Load-bearing points: - Session lock prevents concurrent requests on the same session - Runtime selection is five-level priority - Context assembly includes system prompt, tools, messages
25.4 Step three: context assembly
assembleContext packs user input, history messages, system prompt, and tool descriptions into the request sent to the model.
// src/lib/agent-tools.ts
const assembleContext = async (session_id, provider, runtime) => {
// 1. 获取历史消息
const messages = await getMessages(session_id)
// 2. 构建系统提示词
const systemPrompt = await buildSystemPrompt(provider, runtime)
// 3. 装工具
const tools = await assembleTools(provider, runtime)
// 4. 上下文压缩
const compressedMessages = await contextCompressor(messages)
return {
systemPrompt,
tools,
messages: compressedMessages,
}
}
Load-bearing points: - The Harness capability contract guarantees the three runtimes’ system prompts do not drift - Context compaction prevents exceeding the model window
25.5 Step four: Runtime split
resolveRuntime() selects a runtime by five-level priority:
// src/lib/runtime/registry.ts
const resolveRuntime = async (provider) => {
// 1. Codex 显式
if (provider.type === 'codex') return 'codex'
// 2. 显式 override
if (sessionRuntimeOverride) return sessionRuntimeOverride
// 3. cli_enabled=false
if (!cliEnabled) return 'native'
// 4. 全局设置
if (globalSettings.runtime) return globalSettings.runtime
// 5. auto:装了 Claude CLI 用 SDK,否则 Native
return hasClaudeCli ? 'claude-code-sdk' : 'native'
}
25.6 Step five A: Claude SDK Runtime
If Claude SDK Runtime was selected, streamClaudeSdk() creates an SDK conversation.
// src/lib/claude-client.ts
const streamClaudeSdk = async (context) => {
// 1. 创建 SDK conversation
const conversation = await query({
prompt: context.messages,
options: {
systemPrompt: context.systemPrompt,
tools: context.tools,
cwd: context.workingDirectory,
env: context.env,
}
})
// 2. 注册到会话注册表
registerConversation(session_id, conversation, lockId)
// 3. 事件泵:逐条消费 SDK 事件
const stream = new ReadableStream({
async start(controller) {
for await (const message of conversation) {
switch (message.type) {
case 'assistant':
// 提取 tool_use → SSE
controller.enqueue(formatToolUse(message))
break
case 'user':
// tool_result / 媒体块 / TodoWrite 同步
controller.enqueue(formatToolResult(message))
break
case 'stream_event':
// 文本 delta
controller.enqueue(formatTextDelta(message))
break
case 'result':
// extractTokenUsage + terminal_reason
controller.enqueue(formatResult(message))
break
}
}
controller.close()
}
})
return stream
}
Load-bearing points: - The real agent loop is inside the SDK child process - CodePilot only does event forwarding + guardrails - Guardrails include: two-level idle budget, PTL compaction retry, lockId gating
25.7 Step five B: Native Runtime
If Native Runtime was selected, runAgentLoop() starts a manual while loop.
// src/lib/agent-loop.ts
const runAgentLoop = async (context) => {
const stream = new ReadableStream({
async start(controller) {
let step = 0
let messages = context.messages
while (step < maxSteps) {
// 1. 清洗模型参数
const modelOptions = sanitizeClaudeModelOptions(context.provider)
// 2. 上下文裁剪
messages = pruneOldToolResults(messages)
// 3. 超时预算上膛
timeoutCtl.onStepRequest()
// 4. 单次 streamText()
const result = streamText({
model: context.model,
tools: context.tools,
messages,
...modelOptions,
})
// 5. 逐事件翻译为 SSE
let hasToolCalls = false
for await (const event of timeoutCtl.guardStream(result.fullStream)) {
switch (event.type) {
case 'text-delta':
controller.enqueue(formatText(event))
break
case 'reasoning-delta':
controller.enqueue(formatThinking(event))
break
case 'tool-call':
hasToolCalls = true
controller.enqueue(formatToolUse(event))
break
case 'tool-result':
controller.enqueue(formatToolResult(event))
break
}
}
// 6. 终止判断
if (!hasToolCalls) break
if (doomLoopDetected(messages)) break
// 7. 进入下一轮
messages = [...messages, ...result.response.messages]
step++
}
controller.close()
}
})
return stream
}
Load-bearing points: - A manual loop can insert permission checks, DB persistence, timeout budgets between steps - Doom-loop detection prevents a death loop
25.8 Step five C: Codex Runtime
If Codex Runtime was selected, runCodexRuntime() talks to Codex app-server.
// src/lib/codex/runtime.ts
const runCodexRuntime = async (context) => {
// 1. 获取 Codex app-server
const appServer = await getCodexAppServer()
// 2. 创建会话
const thread = await appServer.threadStart({
model: context.model,
instructions: context.systemPrompt,
})
// 3. 开始一轮对话
const turn = await thread.turnStart({
input: context.messages,
})
// 4. 订阅通知
const stream = new ReadableStream({
async start(controller) {
for await (const notification of turn.notifications()) {
const event = translateCodexNotification(notification)
controller.enqueue(formatSSE(event))
}
controller.close()
}
})
return stream
}
25.9 Step six: SSE stream management
stream-session-manager.ts manages the SSE stream lifecycle.
// src/lib/stream-session-manager.ts
const startStream = async (session_id) => {
// 1. 创建流
const response = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ session_id, content, model, provider_id })
})
// 2. 消费 SSE 流
const reader = response.body.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value)
const events = parseSSE(chunk)
for (const event of events) {
// 写入累积器
accumulator.write(event)
// 100ms 节流 emit 快照
throttledEmit(accumulator.snapshot())
}
}
}
Load-bearing points: - The stream lives independently of React components (globalThis singleton) - Two-level idle budget (10min before first token, 5.5min after) - 2s force-abort safety net
25.10 Step seven: frontend render
The useSSEStream hook subscribes to stream snapshots; MessageList renders messages.
// src/hooks/useSSEStream.ts
const useSSEStream = (session_id) => {
const [messages, setMessages] = useState([])
useEffect(() => {
const unsubscribe = streamSessionManager.subscribe(session_id, (snapshot) => {
setMessages(snapshot.messages)
})
return unsubscribe
}, [session_id])
return messages
}
// src/components/chat/MessageList.tsx
const MessageList = ({ session_id }) => {
const messages = useSSEStream(session_id)
return (
<div>
{messages.map(msg => (
<MessageItem key={msg.id} message={msg} />
))}
</div>
)
}
// src/components/chat/MessageItem.tsx
const MessageItem = ({ message }) => {
return (
<div>
{message.content.map(block => {
switch (block.type) {
case 'text':
return <MarkdownRenderer content={block.text} />
case 'thinking':
return <ThinkingBlock content={block.thinking} />
case 'tool_use':
return <ToolUseCard tool={block.tool} />
case 'tool_result':
return <ToolResultCard result={block.result} />
}
})}
</div>
)
}
Load-bearing points: - Virtualized render for long sessions - Dispatch render by block.type
25.11 Step eight: persistence
collectStreamResponse persists messages in the background, so even if the user refreshes the page they can recover.
// src/lib/stream-session-manager.ts
const collectStreamResponse = async (session_id, stream) => {
const reader = stream.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value)
const events = parseSSE(chunk)
for (const event of events) {
// 持久化到 SQLite
await addMessage(session_id, event.role, event.content)
}
}
}
25.12 Complete sequence
用户输入 "帮我写一个函数"
↓
MessageInput 乐观更新 UI
↓
POST /api/chat
↓
会话锁 acquireSessionLock
↓
addMessage 入库
↓
assembleContext
├── 获取历史消息
├── 构建系统提示词
├── 装工具
└── 上下文压缩
↓
resolveRuntime → claude-code-sdk
↓
streamClaudeSdk
├── 创建 SDK conversation
├── 注册到 conversation-registry
└── 事件泵:for await (message of conversation)
├── assistant → tool_use → SSE
├── user → tool_result → SSE
├── stream_event → text delta → SSE
└── result → token usage → SSE
↓
SSE 流式返回
↓
stream-session-manager
├── consumeSSEStream
├── 两级 idle 预算
└── 100ms 节流 emit 快照
↓
useSSEStream hook
↓
MessageList 渲染
├── text → MarkdownRenderer
├── thinking → ThinkingBlock
├── tool_use → ToolUseCard
└── tool_result → ToolResultCard
↓
用户看到 AI 响应
↓
collectStreamResponse 持久化到 SQLite
↓
释放会话锁
Chapter 26: Recap of key design decisions
26.1 Why three runtimes
CodePilot’s most core architectural decision is that runtimes are swappable. This is not “multi-provider” (openness at the model layer). It is “multi-engine” (openness at the loop layer).
26.2 Why a Harness capability contract
harness/context-compiler.ts is a pure-function compile layer, guaranteeing that the capability description the three runtimes show the model does not drift.
26.3 Why Bridge permission forwarding
permission-broker.ts turns permission requests into IM inline buttons, breaking the deadlock of “a Claude stream blocks waiting for permission.”
26.4 Why SQLite
Local-first, zero config, synchronous API, WAL mode.
26.5 Why Electron + Next.js
Cross-platform desktop + system capabilities + App Router doing both frontend and API layer.
Appendix: acceptance checklist
After finishing this tutorial, you should be able to:
- [ ] Explain CodePilot’s three runtimes and their essential differences
- [ ] Draw the complete workflow after the user types a prompt
- [ ] Explain what the Harness capability contract does
- [ ] Explain how Bridge permission forwarding breaks the deadlock
- [ ] Explain why SQLite rather than a cloud database
- [ ] Explain why Electron + Next.js rather than Tauri or pure Web
- [ ] Scaffold from scratch a minimum viable CodePilot prototype
This tutorial was written from a file-by-file analysis of CodePilot v0.62.0 source. Every file:line citation was re-checked with grep -n / sed -n.