* Embedded Pi agent host.
*
* Runs **inside** the app: this process imports the pi SDK (not the `pi` CLI),
* builds an agent session for the open vault, and registers the TaskLens vault
* tools straight into it (`customTools`). It is started by the Rust core
* (`tasklens-core::agent`) as a child process and speaks the same newline-JSON
* protocol the rest of the app already understands:
*
* in : {"type":"start","config":{…}} | {"type":"prompt","message":…}
* | {"type":"steer","message":…} | {"type":"abort"} | {"type":"stop"}
* out: {"event":"status","state":"ready",…}
* {"event":"agent","data":<pi session event>}
* {"event":"error","message":…}
*
* Session events are forwarded verbatim, so the renderer keeps working with
* whatever events the installed pi version emits.
*/
import {
createAgentSession,
DefaultResourceLoader,
getAgentDir,
ModelRuntime,
SessionManager,
} from "@earendil-works/pi-coding-agent";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { VAULT_TOOLS } from "../pi-extension/tasklens.ts";
interface HostConfig {
vault: string;
provider: string;
model: string;
thinking: string;
apiKey: string;
baseUrl: string;
contextFiles: boolean;
sessionDir: string;
extensions: string[];
}
interface AgentSessionLike {
subscribe(listener: (event: unknown) => void): () => void;
prompt(text: string, options?: Record<string, unknown>): Promise<void>;
steer?(text: string): Promise<void>;
abort(): Promise<void>;
dispose(): void;
model?: { provider?: string; id?: string; name?: string };
}
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
function emit(value: unknown): void {
process.stdout.write(`${JSON.stringify(value)}\n`);
}
function emitAgent(event: unknown): void {
emit(event);
}
function note(text: string): void {
emit({ type: "tasklens_log", stream: "host", text });
}
function fail(message: string): void {
emit({ type: "error", message });
}
function emptyConfig(): HostConfig {
return {
vault: process.cwd(),
provider: "",
model: "",
thinking: "",
apiKey: "",
baseUrl: "",
contextFiles: true,
sessionDir: "",
extensions: [],
};
}
function readConfig(raw: unknown): HostConfig {
const config = { ...emptyConfig(), ...(raw as Partial<HostConfig> | undefined) };
if (!config.vault.trim()) config.vault = process.cwd();
return config;
}
let session: AgentSessionLike | null = null;
let unsubscribe: (() => void) | null = null;
let pendingStart: Promise<void> | null = null;
async function start(raw: unknown): Promise<void> {
stop();
const config = readConfig(raw);
if (config.baseUrl.trim()) {
process.env.OPENAI_BASE_URL = config.baseUrl.trim();
}
let runtime: ModelRuntime;
try {
runtime = await ModelRuntime.create();
} catch (error) {
fail(`无法初始化模型运行时:${error instanceof Error ? error.message : String(error)}`);
return;
}
if (config.provider.trim() && config.apiKey.trim()) {
try {
await runtime.setRuntimeApiKey(config.provider.trim(), config.apiKey.trim());
} catch (error) {
fail(`API Key 设置失败(${config.provider}):${error instanceof Error ? error.message : String(error)}`);
}
}
let model;
if (config.provider.trim() && config.model.trim()) {
model = runtime.getModel(config.provider.trim(), config.model.trim()) ?? undefined;
if (!model) {
const available = await runtime.getAvailable().catch(() => []);
const names = available
.map((entry) => `${entry.provider}/${entry.id}`)
.slice(0, 20)
.join("、");
fail(`找不到模型 ${config.provider}/${config.model}。可用:${names || "(无,先运行 pi --list-models)"}`);
return;
}
}
const loader = new DefaultResourceLoader({
cwd: config.vault,
agentDir: getAgentDir(),
noExtensions: true,
additionalExtensionPaths: config.extensions.length > 0 ? config.extensions : undefined,
noContextFiles: !config.contextFiles,
});
const requestedThinking = config.thinking.trim();
const thinking: ThinkingLevel | undefined = THINKING_LEVELS.includes(requestedThinking)
? (requestedThinking as ThinkingLevel)
: undefined;
try {
const created = await createAgentSession({
cwd: config.vault,
model,
thinkingLevel: thinking,
modelRuntime: runtime,
resourceLoader: loader,
customTools: VAULT_TOOLS,
sessionManager: config.sessionDir.trim()
? SessionManager.create(config.vault, config.sessionDir.trim())
: SessionManager.create(config.vault),
});
session = created.session as unknown as AgentSessionLike;
unsubscribe = session.subscribe((event) => emitAgent(event));
if (created.modelFallbackMessage) fail(created.modelFallbackMessage);
note(
`内置 Pi 会话就绪(${VAULT_TOOLS.length} 个知识库工具)· ${session.model?.provider ?? "?"}/${session.model?.id ?? "?"}`,
);
} catch (error) {
fail(`无法启动 Pi 会话:${error instanceof Error ? error.message : String(error)}`);
}
}
function stop(): void {
try {
unsubscribe?.();
} catch {
}
unsubscribe = null;
try {
session?.dispose();
} catch {
}
session = null;
}
async function handle(line: string): Promise<void> {
let request: Record<string, unknown>;
try {
request = JSON.parse(line) as Record<string, unknown>;
} catch (error) {
fail(`不是合法 JSON:${error instanceof Error ? error.message : String(error)}`);
return;
}
const type = typeof request.type === "string" ? request.type : "";
if (type === "start") {
pendingStart = start(request.config);
await pendingStart;
return;
}
if (type === "stop") {
await pendingStart?.catch(() => undefined);
stop();
note("内置 Pi 会话已停止");
return;
}
if (pendingStart) await pendingStart.catch(() => undefined);
if (!session) {
fail("agent 尚未启动:先发送 {\"type\":\"start\",\"config\":{…}}");
return;
}
try {
if (type === "prompt") {
const message = String(request.message ?? "");
if (!message.trim()) return;
await session.prompt(message, { streamingBehavior: "steer" });
return;
}
if (type === "steer" && session.steer) {
await session.steer(String(request.message ?? ""));
return;
}
if (type === "abort") {
await session.abort();
return;
}
fail(`未知指令:${type}`);
} catch (error) {
fail(`执行失败:${error instanceof Error ? error.message : String(error)}`);
}
}
process.stdin.setEncoding("utf8");
let buffer = "";
process.stdin.on("data", (chunk: string) => {
buffer += chunk;
let index = buffer.indexOf("\n");
while (index >= 0) {
const line = buffer.slice(0, index).trim();
buffer = buffer.slice(index + 1);
index = buffer.indexOf("\n");
if (line) void handle(line);
}
});
process.stdin.on("end", () => {
stop();
process.exit(0);
});
process.on("uncaughtException", (error) => {
fail(`宿主异常:${error.message}`);
});
process.on("unhandledRejection", (reason) => {
fail(`宿主未处理的拒绝:${reason instanceof Error ? reason.message : String(reason)}`);
});