Folio is an AI-native finance workbench: a desktop application that combines a professional financial workspace (watchlist, security header, K-line charts, overview, financials, news) with a context-aware agent copilot. The agent layer is built on a persistent Agent Kernel: sessions, runs, and agent events are first-class entities owned by the main process, streamed to the UI over IPC, and persisted on disk so the app can be restarted without losing conversation state.
Workbench Shell (Allotment: Sidebar | Finance Workspace | Agent Panel)
│
▼
Renderer atoms (view cache): activeSymbol / activeView / llmState
│ IPC (whitelisted: kernel:*, sessions:*, runs:*, agent:*, market:*,
│ llm:*, skills:*, alerts:*, longbridge:*)
▼
AgentKernelHost (main process)
│
├── AgentKernel
│ ├── SessionManager (persistence + lifecycle)
│ ├── RunManager (run lifecycle + event broadcast)
│ └── AgentRuntime
│ ├── PiRuntimeAdapter ──► Pi Runtime (JSONL/stdio)
│ │ ├── run → promptStreaming (workspace context + skill index)
│ │ └── LlmRuntimeApi (listModels / setModel / thinking / test)
│ └── LocalRuntimeAdapter (deterministic / tests)
│
├── MarketDataService (UI + Agent share one data layer)
├── CredentialStore (safeStorage-encrypted, main process only)
└── SkillHub V2 (SKILL.md packages + references, progressive loading)
│
▼
AgentEvent Stream (run_started … run_completed)
│
▼
Electron IPC ('agent:event' push channel)
│
▼
React UI (Jotai atoms as view cache only)
The kernel runs in the Electron main process. The renderer never writes sessions, runs, or messages directly; it hydrates from the kernel and projects the event stream into Jotai state.
| Process | Responsibility |
|---|---|
| Main Process | AgentKernel (SessionManager + RunManager + runtime), IPC surface, market data, alerts |
| Preload | Whitelisted contextBridge API (window.electronAPI) |
| Renderer | React UI; subscribes to agent:event, calls kernel IPC (sessions:*, runs:*) |
Security boundary is unchanged and enforced:
new BrowserWindow({
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
preload: path.join(__dirname, '../preload/index.cjs'),
},
});The renderer has no access to fs, child_process, the database, or the Pi runtime.
All types live in @finagent/core:
Session ← persisted conversation scope (id, title, status, runtimeSessionPath, recentSymbols)
├── Messages ← visible transcript (user / assistant), persisted per session
└── Runs ← one agent execution per user message
└── AgentEvents ← unified event protocol (streamed, not persisted)
└── ToolCalls ← live tool state inside events
type AgentEvent =
| run_started { run, userMessage }
| message_started
| message_delta { delta, answer }
| message_completed { answer }
| tool_started { toolCall }
| tool_completed { toolCall }
| run_completed { answer, toolCalls }
| run_failed { error }Every event carries id, sessionId, runId, timestamp, sequence. Pi-specific event shapes never cross the kernel boundary: raw Pi JSONL events are converted by PiEventAdapter in the PiRuntimeAdapter.
packages/shared/src/kernel/:
| Component | Responsibility |
|---|---|
SessionManager |
Create/list/delete sessions, persist messages, keep session metadata stats, assign each session a runtime session file path |
RunManager |
Start runs (persist user message + run record), drive the runtime event stream, broadcast events to subscribers, persist the final assistant message + run outcome, guarantee every run settles (completed / failed / cancelled) |
AgentKernel |
Composition root; owns SessionManager, RunManager, and the runtime adapter |
RunManager invariants:
- One run executes at a time (the Pi runtime processes one prompt at a time); a second
startRunfails withRUN_IN_PROGRESS. cancelRunmarks the run and callsruntime.cancel(); the run ends ascancelled(orcompletedif the runtime already finished).- Timeouts, runtime crashes, tool failures, and malformed protocol output all end the run — no run can stay
runningforever.
@finagent/core defines the provider-agnostic runtime contract:
interface AgentRuntime {
getTools(): Promise<ApiResult<ToolDefinition[]>>;
ensureSession({ id, title?, sessionPath?, recentSymbols? }): Promise<RuntimeSession>;
run({ sessionId, runId, content, workspaceContext? }): AsyncIterable<AgentEvent>;
cancel({ sessionId, runId }): Promise<void>;
disposeSession?(sessionId): Promise<void>;
dispose(): Promise<void>;
}workspaceContext carries the renderer's current financial-object focus
(activeSymbol, activeView, selectedPosition) — the ephemeral Workspace
context, deliberately separate from Session (conversation) state. It is never
persisted.
packages/shared/src/agent/:
| Component | Responsibility |
|---|---|
PiRpcClient |
JSONL/stdio transport: prompt, promptStreaming (live raw events), switchSession, getState, getAvailableModels, setModel, setThinkingLevel, restart, abortCurrentPrompt, health checks, timeouts, process restart |
PiEventAdapter |
Pure Pi-event → AgentEvent mapping per run |
PiRuntimeAdapter |
AgentRuntime implementation: Folio session ↔ Pi session file lifecycle, prompt construction (workspace context + progressive skill index), symbol memory, and the LlmRuntimeApi control plane |
Folio Session ↔ Pi conversation is a stable 1:1 mapping:
Folio Session A ──► <userData>/pi-sessions/<sessionA>.jsonl
Folio Session B ──► <userData>/pi-sessions/<sessionB>.jsonl
One Pi process is shared; the runtime switches conversations with the switch_session RPC command. The JSONL session file is Pi's own persistent conversation store, so:
- Session isolation: each Folio session has its own Pi session file — no cross-session context pollution.
- Resume:
switch_sessionreloads the file, restoring the full conversation. - Restart recovery: session files survive app restarts;
runtimeSessionIdis refreshed viaget_stateand stored on the Folio session.
Pi stdout (JSONL) tool_execution_start / tool_execution_end /
message_update(text_delta) / agent_end / error
│
▼
PiRpcClient.promptStreaming() live raw events + aggregated result
│
▼
PiEventAdapter.consume() AgentEvent sequence (tool_started,
message_delta, run_completed, …)
│
▼
PiRuntimeAdapter.run() AsyncIterable<AgentEvent>
│
▼
RunManager broadcast → IPC → UI; persistence
LocalRuntimeAdapter wraps LocalFinanceAgentBackend in the AgentRuntime contract so the full session → run → event → UI loop works without a Pi process and in tests. Tool calls are replayed as start/end events from the backend's own records; cancellation is best-effort (applies when the current tool call settles). Session context (recent symbols) is persisted on the session and restored on restart.
packages/shared/src/storage/ — atomic JSON file store with repository abstractions (SQLite-swappable):
| Repository | File | Contents |
|---|---|---|
SessionRepository |
sessions.json |
Session metadata index (SessionMeta incl. messageCount, runtimeSessionPath) |
MessageRepository |
sessions/<id>/messages.json |
Visible transcript |
RunRepository |
sessions/<id>/runs.json |
Run history |
Storage root: <userData>/store; Pi session files: <userData>/pi-sessions. Writes are atomic (tmp file + rename). The renderer never persists anything — Jotai atoms are a view cache hydrated from the kernel at startup.
| Channel | Direction | Purpose |
|---|---|---|
kernel:hydrate |
invoke | Session list on startup |
sessions:create / sessions:delete |
invoke | Session lifecycle |
sessions:getMessages / sessions:listRuns |
invoke | Lazy transcript / run history |
runs:start ({sessionId, content, workspaceContext?}) / runs:cancel |
invoke | Run lifecycle |
agent:event |
push | Live AgentEvent stream to the renderer |
agent:getTools |
invoke | Tool definitions |
market:getQuote / getKline / getPortfolio / getStaticInfo / getCalcIndex / getMarketStatus / getNews |
invoke | Market data (UI + Agent share this layer) |
llm:getState / listModels / setModel / listThinkingLevels / setThinkingLevel |
invoke | LLM control plane (Pi model registry) |
llm:getProviders / listCredentials / setCredential / removeCredential |
invoke | Provider status + credentials (secrets never cross IPC back to the renderer) |
llm:setCustomProvider / removeCustomProvider / testProvider |
invoke | OpenAI-compatible custom providers + connection tests |
skills:list / setEnabled / listResources / readResource |
invoke | SkillHub V2 surface |
longbridge:getStatus, alerts:load / alerts:save |
invoke | Status and alerts |
All handlers wrap results in the { ok, data | error } envelope (toIpcResult).
- Sessions list:
sessionsAtom(hydrated from kernel). - Messages:
messagesAtomFamilyper session, loaded lazily from the kernel. - Workspace context:
activeSymbolAtom(single source of truth for the focused security),activeViewAtom,navSectionAtom,agentPanelVisibleAtom, derivedworkspaceContextAtom— UI-side Jotai state, distinct from session state. - LLM control plane:
llmStateAtom+llmModelsAtom/llmProvidersAtomview caches hydrated from the Pi registry throughllm:*IPC. - Runs:
runViewAtom+applyAgentEventAtomreducer — a pure projection of theagent:eventstream.run_startedsurfaces the user message;tool_started/tool_completeddrive the live tool list;message_deltastreams the answer; terminal events finalize the assistant message and clear the run view. - The
KernelBridgecomponent hydrates on mount and subscribes to the event stream.
contextIsolation: true,nodeIntegration: false,sandbox: true.- IPC is whitelisted channel-by-channel in the preload (
index.tssource,index.cjsruntime, built withbun run build:preload). - CredentialStore: API keys and custom provider configs are encrypted at rest with Electron
safeStorage(<userData>/credentials.json). The renderer sends secrets in once and only ever receives metadata back. Secret material is redacted from errors, logs, and traces (redactSecrets). - Provider overrides flow main → Pi spawn env (
FINAGENT_PROVIDER_OVERRIDES) → the finagent extension'sregisterProvider— Folio-owned config, never touching the user's global Pi config. Credential changes restart the Pi subprocess (sessions survive). - LongBridge access stays behind
longbridge-tools(parameterized execa array arguments, symbol validation). - Skill resources are read through path-safe loaders (
SkillHub.readSkillResource/read_skill_resourcetool): absolute paths,..traversal, and symlink escapes are rejected. - The renderer cannot reach
fs, the database, the Pi process, or the LongBridge CLI directly.
finagent/
├── packages/
│ ├── core/ # Types only: capability/research/thesis/alert-rule/
│ │ # readiness/compare/portfolio-risk contracts +
│ │ # kernel types (Session, Run, AgentEvent, …)
│ ├── shared/
│ │ ├── providers/ # V4 provider platform: ProviderRouter (primary
│ │ │ # + fallback), registry, ConnectionStore,
│ │ │ # Longbridge financial-data + broker adapters,
│ │ │ # Massive (Polygon.io) fallback adapter, health
│ │ ├── diagnostics/ # Collector, redaction, support-bundle export,
│ │ │ # error ring buffer
│ │ ├── capabilities/ # Finance Capability Registry (single source of
│ │ │ # truth): manifests (phase-one + phase-two),
│ │ │ # registry, executor (concurrency/timeout/abort),
│ │ │ # Pi tool adapter, skill readiness
│ │ ├── research/ # Deep Research: planner, runner, synthesizers
│ │ │ # (agent-backed + deterministic local), repository
│ │ ├── thesis/ # InvestmentThesis: repository, converter,
│ │ │ # impact evaluators, service (re-evaluate)
│ │ ├── compare/ # Structured symbol comparison
│ │ ├── alerts/ # Alert Engine v2: rule repository (v1 migration),
│ │ │ # per-type evaluators, scheduler, event log
│ │ ├── portfolio-risk/ # Allocation/concentration/signals + synthesizer
│ │ ├── agent/ # PiRpcClient, adapters, LocalFinanceAgentBackend,
│ │ │ # FinanceToolRegistry (capability-backed), MarketDataService
│ │ ├── kernel/ # SessionManager, RunManager, AgentKernel
│ │ ├── resources/ # ResourceLocator (dev repo vs packaged resourcesPath)
│ │ └── storage/ # JsonFileStore, Session/Message/RunRepository
│ ├── ui/ # React app: atoms (workspace/research/thesis/compare/
│ │ │ # portfolio-risk/alert/skill-readiness/…),
│ │ │ # layout, workspace, chart, agent, settings,
│ │ │ # research/thesis/compare/portfolio components, client
│ ├── pi-extension/ # Pi tools GENERATED from the capability registry
│ │ # (+ skill-resource tools) + provider overrides
│ ├── longbridge-tools/ # LongBridge CLI wrapper: 20+ fetchers, parsers,
│ │ # typed results, real-CLI test fixtures
│ └── skill-hub/ # SkillHub V2: SKILL.md packages + references,
│ # path safety, enable/disable, capability map
├── skills/ # Vendored official Longbridge skills (MIT)
└── apps/electron/
├── src/
│ ├── main/ # index.ts (IPC), kernelHost.ts (V3 wiring:
│ │ # registry, services, alert engine, notifications),
│ │ # credentialStore.ts, loadEnv.ts
│ ├── preload/ # contextBridge API (source index.ts, built index.cjs)
│ └── renderer/ # React entry, finagentClient
└── e2e/ # Golden-path A–H (run.mjs) + packaged smoke
Longbridge CLI / Massive API ─► provider adapters (FinancialDataProvider,
BrokerAccountProvider — neutral domains)
│
ProviderRouter (primary + fallback)
│
ProviderResult { data, provenance{providerId, …} }
│
Capability Registry (20 caps, router-backed fetchers)
┌────────────────────┴──────────────────────┐
│ │ │
Pi tools (generated) UI availability + Product workflows:
via pi-extension Skill Readiness research / thesis /
alerts / compare /
portfolio risk
Each capability manifest declares id/name/description/category/risk/auth, a
TypeBox input schema, and an execute that returns { data, provenance, summary }. defineCapability wraps execute with input validation. The
CapabilityExecutor runs capabilities under timeout/abort with per-capability
failure isolation — research, re-evaluation, alerts, compare, and risk all
execute through it. All capabilities are read-only (riskLevel: 'read'); no
order/trading capability exists.
Business layers (research/agent/UI/portfolio/compare) never import a vendor package: they consume capability results whose provenance names the actual answering provider. Longbridge and Massive (Polygon.io, fallback) are the two adapters in V4; the router is the only bridge (spec §4).
User: clicks NVDA.US in the watchlist
↓
activeSymbolAtom = NVDA.US (single source of truth)
↓
SecurityHeader / Overview / Chart / News / AgentPanel context chip all follow
↓
User: "最近走势怎么样?" → AgentPanel input
↓
client.kernel.startRun(sessionId, content, workspaceContext)
↓
RunManager.startRun() → AgentRuntime.run({…, workspaceContext})
↓
PiRuntimeAdapter builds the prompt: workspace context + skill metadata index
→ Pi prompt (the agent loads relevant SKILL.md/references via
read_skill_resource when needed)
↓
tool_started / tool_completed / message_delta … (AgentEvent stream)
↓
IPC 'agent:event' → KernelBridge → run reducer → ToolActivity + streaming answer
↓
run_completed (or run_failed / cancelled) → answer persisted to the transcript
App restart: kernel:hydrate restores the session list, sessions:getMessages restores transcripts, and Pi session files restore runtime conversation context (including the session's model and thinking level, via get_state).