gorazenxu_pimate/PiAgentClient.ts
gorazen 5596294c44 优(model-sync): 以 Pi getState() 为权威数据源,思考档位弹窗按 thinkingLevelMap 动态渲染
- PiAgentClient 新增 PiModel/PiAgentState/AvailableModelsResult/SetModelResult 类型; sendCommand<T> 泛型化
- 新增 syncSeq 单调计数器废弃过时 getState() 响应;废弃旧 piModelMeta local 类型改用 Pi 返回的 PiModel
- updateActiveTabModel 改以 setModel RPC 响应中的 Pi 确认状态写入 tab/settings,避免 local 值与 Pi clamp 结果不一致
- 新增 syncTabStateFromPi() 统一同步入口,替代散落在各处的局部写入
- thinking_level_changed / model_changed 事件触发 syncTabStateFromPi() 权威同步
- 设置页 provider/model/effort 联动改走 updateActiveTabModel 统一入口,catch setModel 异常防崩溃
- 思考档位弹窗不再用静态硬编码列表,改为从 model.thinkingLevelMap keys 动态渲染;非推理模型显示 informational 行
- getStaticLevelDescription() 提供已知档位的中英文 label 映射

- 文档(CHANGELOG): archive working 条目到 v1.0.43
2026-07-11 02:38:04 +08:00

756 lines
22 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { ChildProcess, spawn, type SpawnOptions } from "child_process";
import { StringDecoder } from "string_decoder";
import { EventEmitter } from "events";
import * as path from "path";
import * as fs from "fs";
// ─── Windows pi resolution ──────────────────────────────────────────────────
// On Windows, `pi` is a .cmd shim that calls `node cli.js`.
// We can't spawn `.cmd` without `shell: true` (Node limitation), and using
// `shell: true` spawns cmd.exe which makes pi a grandchild that survives
// Obsidian quit (orphan process problem).
//
// Solution: locate the actual `node` + `cli.js` pair and spawn node directly.
// This way `node.exe` (and pi inside it) is a direct child of Electron and
// Windows cleans it up when Obsidian dies.
function resolveWindowsSpawn(
userPiPath: string
): { cmd: string; scriptArgs: string[] } | null {
if (process.platform !== "win32") return null;
// If the user gave a full path or .exe, just use it as-is.
if (/[\\/]/.test(userPiPath) || /\.exe$/i.test(userPiPath)) return null;
const pathDirs = (process.env.PATH || "").split(path.delimiter);
for (const dir of pathDirs) {
if (!dir) continue;
const shim = path.join(dir, userPiPath + ".cmd");
if (!fs.existsSync(shim)) continue;
const shimDir = path.dirname(shim);
// npm shim 位于 `<install>/node_modules/.bin/`,真实包在
// `<install>/node_modules/@earendil-works/pi-coding-agent/`。
// 两种布局都试一下:
const installRoot = path.basename(shimDir).toLowerCase() === ".bin"
? path.dirname(shimDir)
: shimDir;
const candidates = [
path.join(
installRoot,
"node_modules",
"@earendil-works",
"pi-coding-agent",
"dist",
"cli.js"
),
path.join(
shimDir,
"node_modules",
"@earendil-works",
"pi-coding-agent",
"dist",
"cli.js"
),
];
for (const cliJs of candidates) {
if (!fs.existsSync(cliJs)) continue;
// 优先用 shim 同目录的 node.exenpm 会装一个),否则用 PATH 里的 node。
const localNode = path.join(shimDir, "node.exe");
const localNode2 = path.join(installRoot, "node.exe");
const nodeCmd = fs.existsSync(localNode)
? localNode
: fs.existsSync(localNode2)
? localNode2
: "node";
return { cmd: nodeCmd, scriptArgs: [cliJs] };
}
}
return null;
}
function resolvePosixNode(): string | null {
if (process.platform === "win32") return null;
const searchDirs = [
...(process.env.PATH || "").split(path.delimiter),
"/opt/homebrew/bin",
"/usr/local/bin",
];
const seen = new Set<string>();
for (const dir of searchDirs) {
if (!dir || seen.has(dir)) continue;
seen.add(dir);
const candidate = path.join(dir, "node");
if (fs.existsSync(candidate)) return candidate;
}
return null;
}
function resolvePosixScript(candidate: string, nodePath: string | null): { cmd: string; scriptArgs: string[] } | null {
const realPath = fs.realpathSync(candidate);
if (/\.js$/i.test(realPath) && nodePath) {
return { cmd: nodePath, scriptArgs: [realPath] };
}
return null;
}
function resolvePosixSpawn(
userPiPath: string
): { cmd: string; scriptArgs: string[] } | null {
if (process.platform === "win32") return null;
const nodePath = resolvePosixNode();
if (/\.js$/i.test(userPiPath)) {
return nodePath ? { cmd: nodePath, scriptArgs: [userPiPath] } : null;
}
if (/[\\/]/.test(userPiPath)) {
if (!fs.existsSync(userPiPath)) return null;
return resolvePosixScript(userPiPath, nodePath);
}
const searchDirs = [
...(process.env.PATH || "").split(path.delimiter),
process.env.HOME ? path.join(process.env.HOME, ".local", "bin") : "",
"/opt/homebrew/bin",
"/usr/local/bin",
];
const seen = new Set<string>();
for (const dir of searchDirs) {
if (!dir || seen.has(dir)) continue;
seen.add(dir);
const candidate = path.join(dir, userPiPath);
if (!fs.existsSync(candidate)) continue;
return resolvePosixScript(candidate, nodePath) || { cmd: candidate, scriptArgs: [] };
}
return null;
}
function resolvePiSpawn(
userPiPath: string
): { cmd: string; scriptArgs: string[] } | null {
return resolveWindowsSpawn(userPiPath) || resolvePosixSpawn(userPiPath);
}
// ─── RPC Types ─────────────────────────────────────────────────────────────
export interface RpcRequest {
type: string;
id?: string;
[key: string]: unknown;
}
export interface RpcResponse<T = unknown> {
type: "response";
id?: string;
command: string;
success: boolean;
error?: string;
data?: T;
}
// ─── Pi model & state types ────────────────────────────────────────────────
// Pi 返回的完整 model 元数据。Pimate 仅依赖 `reasoning` 与 `thinkingLevelMap`
// 的键集来决定档位弹窗;`thinkingLevelMap` 的 value 类型由 Pi 内部约定,
// UI 暂不解释,仅透传给 Pi。
export interface PiModel {
id: string;
provider: string;
name?: string;
reasoning?: boolean;
thinkingLevelMap?: Record<string, unknown> | null;
}
export interface PiAgentState {
model?: PiModel;
thinkingLevel?: string;
isStreaming?: boolean;
[key: string]: unknown;
}
export interface AvailableModelsResult {
models: PiModel[];
}
export type SetModelResult = PiModel;
export interface RpcEvent {
type: string;
[key: string]: unknown;
}
// Message update delta types
export type DeltaType =
| "start"
| "text_start"
| "text_delta"
| "text_end"
| "thinking_start"
| "thinking_delta"
| "thinking_end"
| "toolcall_start"
| "toolcall_delta"
| "toolcall_end"
| "done"
| "error";
export interface AssistantMessageEvent {
type: DeltaType;
contentIndex?: number;
delta?: string;
partial?: unknown;
content?: string;
toolCall?: ToolCall;
reason?: string;
}
export interface ToolCall {
id: string;
name: string;
arguments: Record<string, unknown>;
}
export interface ToolResult {
toolCallId: string;
toolName: string;
content: Array<{ type: string; text: string }>;
isError: boolean;
details?: Record<string, unknown>;
}
export interface Message {
role: string;
content: string | Array<MessageContent>;
timestamp?: number;
[key: string]: unknown;
}
export interface MessageContent {
type: string;
text?: string;
thinking?: string;
id?: string;
name?: string;
arguments?: Record<string, unknown>;
}
// ─── RPC Client ─────────────────────────────────────────────────────────────
export interface PiAgentClientOptions {
piPath: string;
provider?: string;
modelId?: string;
thinkingLevel?: string;
apiKey?: string;
cwd?: string;
noSession?: boolean;
tools?: string[];
}
export class PiAgentClient extends EventEmitter {
private process: ChildProcess | null = null;
private buffer = "";
private decoder = new StringDecoder("utf8");
private nextId = 0;
private pendingRequests = new Map<
string,
{
resolve: (value: RpcResponse) => void;
reject: (error: Error) => void;
timeout: number;
}
>();
private options: PiAgentClientOptions;
private destroyed = false;
constructor(options: PiAgentClientOptions) {
super();
this.options = options;
}
/**
* Start the pi process and initialize
*/
async start(): Promise<void> {
if (this.destroyed) throw new Error("Client destroyed");
const args = ["--mode", "rpc"];
if (this.options.provider) {
args.push("--provider", this.options.provider);
}
if (this.options.modelId) {
args.push("--model", this.options.modelId);
}
if (this.options.thinkingLevel) {
args.push("--thinking", this.options.thinkingLevel);
}
if (this.options.noSession) {
args.push("--no-session");
}
if (this.options.tools?.length) {
args.push("--tools", this.options.tools.join(","));
}
const env: Record<string, string> = { ...process.env } as Record<
string,
string
>;
if (this.options.apiKey) {
// Set common API key env vars based on provider
const provider = this.options.provider || "anthropic";
const keyMap: Record<string, string> = {
anthropic: "ANTHROPIC_API_KEY",
openai: "OPENAI_API_KEY",
google: "GOOGLE_API_KEY",
deepseek: "DEEPSEEK_API_KEY",
groq: "GROQ_API_KEY",
xai: "XAI_API_KEY",
mistral: "MISTRAL_API_KEY",
// 自定义 provider在 ~/.pi/agent/models.json 里用 "$XXX_API_KEY" 鉴权):
// Pimate 面板"凭证配置区"填的 key 存 auth.json这里按 provider 注入
// 对应环境变量,让 pi 后端能解析 models.json 的 apiKey 引用。
"minimax": "MINIMAX_API_KEY",
// Pi 官方 env 映射:国内 MiniMax 使用 MINIMAX_CN_API_KEY。
// 之前误写成 MINIMAX_API_KEY会让国际 minimax 也被 getAvailable()
// 判定为已配置,导致模型 picker 同时出现 MINIMAX 和 MINIMAX-CN 两组。
"minimax-cn": "MINIMAX_CN_API_KEY",
"siliconflow": "SILICONFLOW_API_KEY",
"zhipu": "ZHIPU_API_KEY",
};
const envVar = keyMap[provider];
if (envVar) {
env[envVar] = this.options.apiKey;
}
}
return new Promise((resolve, reject) => {
try {
const spawnOptions: SpawnOptions = {
cwd: this.options.cwd || process.cwd(),
env,
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
};
// 直接 spawn不再走 cmd.exe shell。
// 在 Windows 上,`pi` 是 .cmd shim没 shell 跑不了——所以先
// 解析 shim 找到真正的 `node` + `cli.js`,直接 spawn node。
// 这样 pi 是 Electron 的亲生进程Obsidian 退出时 Windows 会清理。
// 中文路径无影响Node 在 Windows 上对 spawn 的 argv 走 UTF-16/UTF-8
// 安全传递pi 自己用 Node 也是 UTF-8。
let executable = this.options.piPath;
let execArgs = args;
const resolved = resolvePiSpawn(this.options.piPath);
if (resolved) {
executable = resolved.cmd;
execArgs = [...resolved.scriptArgs, ...args];
}
const child = spawn(executable, execArgs, spawnOptions);
this.process = child;
let settled = false;
const settle = (err?: Error) => {
if (settled) return;
settled = true;
if (err) reject(err instanceof Error ? err : new Error(String(err)));
else resolve();
};
// Handle stdout (events and responses)
child.stdout!.on("data", (chunk: Buffer) => {
this.handleData(chunk);
});
// Handle stderr
child.stderr!.on("data", (chunk: Buffer) => {
console.error("[pi-agent stderr]", chunk.toString());
});
// Handle process exit
child.on("error", (err) => {
console.error("[pi-agent] Process error:", err);
if (!settled) settle(err);
else this.emit("error", err);
});
child.on("close", (code) => {
console.log(`[pi-agent] Process closed with code ${code}`);
if (!settled) settle(new Error(`pi exited with code ${code}`));
else this.emit("close");
this.process = null;
});
// Consider ready after a short delay (pi initializes)
// 150ms 给 pi 足够时间完成工具加载和模型绑定,避免下一个 RPC
// 命令与初始化指令重载。Node 管道 buffer 会保留前面写入的指令。
window.setTimeout(() => settle(), 150);
} catch (err) {
reject(err instanceof Error ? err : new Error(String(err)));
}
});
}
/**
* Handle incoming data from pi stdout
*/
private handleData(chunk: Buffer): void {
this.buffer +=
typeof chunk === "string" ? chunk : this.decoder.write(chunk);
while (true) {
const newlineIndex = this.buffer.indexOf("\n");
if (newlineIndex === -1) break;
let line = this.buffer.slice(0, newlineIndex);
this.buffer = this.buffer.slice(newlineIndex + 1);
// Strip trailing \r
if (line.endsWith("\r")) {
line = line.slice(0, -1);
}
if (line.trim().length === 0) continue;
try {
const parsed = JSON.parse(line) as RpcResponse | RpcEvent;
if (parsed.type === "response") {
// Handle command response
const response = parsed as RpcResponse;
const pending = this.pendingRequests.get(response.id || "");
if (pending) {
this.pendingRequests.delete(response.id || "");
window.clearTimeout(pending.timeout);
pending.resolve(response);
}
} else {
// Handle event
this.emit("event", parsed as RpcEvent);
}
} catch (err) {
console.error("[pi-agent] Failed to parse JSON line:", line, err);
}
}
}
/**
* Send a command and wait for response
*/
private async sendCommand<T = unknown>(
command: RpcRequest
): Promise<RpcResponse<T>> {
if (!this.process || this.process.killed) {
throw new Error("Process not running");
}
const id = command.id || `cmd-${++this.nextId}`;
command.id = id;
return new Promise((resolve, reject) => {
const timeout = window.setTimeout(() => {
if (this.pendingRequests.has(id)) {
this.pendingRequests.delete(id);
reject(new Error(`Command ${command.type} timed out`));
}
}, 60_000);
this.pendingRequests.set(id, {
resolve: resolve as (value: RpcResponse<unknown>) => void,
reject,
timeout,
});
const payload = JSON.stringify(command) + "\n";
try {
this.process!.stdin!.write(payload);
} catch (err) {
this.pendingRequests.delete(id);
window.clearTimeout(timeout);
reject(err instanceof Error ? err : new Error(String(err)));
}
});
}
/**
* Send a fire-and-forget command (no response expected)
*/
private sendFireAndForget(command: RpcRequest): void {
if (!this.process || this.process.killed) {
console.warn("[pi-agent] Cannot send, process not running");
return;
}
if (!command.id) command.id = `ff-${++this.nextId}`;
try {
this.process.stdin!.write(JSON.stringify(command) + "\n");
} catch (err) {
console.error("[pi-agent] Failed to send command:", err);
}
}
// ─── Public API ────────────────────────────────────────────────────────
/**
* Send a prompt to the agent
*/
async prompt(
message: string,
options?: {
streamingBehavior?: "steer" | "followUp";
images?: Array<{ type: string; data: string; mimeType: string }>;
}
): Promise<RpcResponse> {
return this.sendCommand({
type: "prompt",
message,
...(options?.streamingBehavior && {
streamingBehavior: options.streamingBehavior,
}),
...(options?.images && { images: options.images }),
});
}
/**
* Queue a steering message during streaming
*/
async steer(
message: string,
options?: { images?: Array<{ type: string; data: string; mimeType: string }> }
): Promise<RpcResponse> {
return this.sendCommand({
type: "steer",
message,
...(options?.images && { images: options.images }),
});
}
/**
* Queue a follow-up message
*/
async followUp(
message: string,
options?: { images?: Array<{ type: string; data: string; mimeType: string }> }
): Promise<RpcResponse> {
return this.sendCommand({
type: "follow_up",
message,
...(options?.images && { images: options.images }),
});
}
/**
* Abort current agent operation
*/
abort(): void {
this.sendFireAndForget({ type: "abort" });
}
/**
* Get full Pi agent state, including current model and thinkingLevel.
* This is the authoritative source for clamping / sync decisions.
*/
async getState(): Promise<RpcResponse<PiAgentState>> {
return this.sendCommand({ type: "get_state" });
}
/**
* Get all messages
*/
async getMessages(): Promise<RpcResponse> {
return this.sendCommand({ type: "get_messages" });
}
/**
* Set model. Pi responds with the resolved model metadata
* (including `reasoning` and `thinkingLevelMap`), which the UI uses
* to derive the available thinking-level options.
*/
async setModel(
provider: string,
modelId: string
): Promise<RpcResponse<SetModelResult>> {
return this.sendCommand({ type: "set_model", provider, modelId });
}
/**
* Set thinking level. Pi may clamp the requested level to what the
* current model supports; the authoritative result comes back via
* `thinking_level_changed` events and `getState`.
*/
async setThinkingLevel(level: string): Promise<RpcResponse> {
return this.sendCommand({ type: "set_thinking_level", level });
}
/**
* List currently-available models for the configured providers.
*/
async getAvailableModels(): Promise<RpcResponse<AvailableModelsResult>> {
return this.sendCommand({ type: "get_available_models" });
}
/**
* Execute a bash command
*/
async bash(command: string): Promise<RpcResponse> {
return this.sendCommand({ type: "bash", command });
}
/**
* Get session stats (tokens, cost)
*/
async getSessionStats(): Promise<RpcResponse> {
return this.sendCommand({ type: "get_session_stats" });
}
async switchSession(sessionPath: string): Promise<RpcResponse> {
return this.sendCommand({ type: "switch_session", sessionPath });
}
async exportHtml(outputPath?: string): Promise<RpcResponse> {
return this.sendCommand({
type: "export_html",
...(outputPath ? { outputPath } : {}),
});
}
async getCommands(): Promise<RpcResponse> {
return this.sendCommand({ type: "get_commands" });
}
async getLastAssistantText(): Promise<RpcResponse> {
return this.sendCommand({ type: "get_last_assistant_text" });
}
async getForkMessages(): Promise<RpcResponse> {
return this.sendCommand({ type: "get_fork_messages" });
}
async fork(entryId: string): Promise<RpcResponse> {
return this.sendCommand({ type: "fork", entryId });
}
async clone(): Promise<RpcResponse> {
return this.sendCommand({ type: "clone" });
}
async promptAndWait(message: string): Promise<RpcResponse> {
await this.prompt(message);
return this.waitForAgentEnd().then(() => this.getLastAssistantText());
}
private waitForAgentEnd(timeoutMs = 120_000): Promise<void> {
return new Promise((resolve, reject) => {
const timeout = window.setTimeout(() => {
this.off("event", onEvent);
reject(new Error("Timed out waiting for assistant response"));
}, timeoutMs);
const onEvent = (event: RpcEvent) => {
if (event.type === "agent_end") {
window.clearTimeout(timeout);
this.off("event", onEvent);
resolve();
}
};
this.on("event", onEvent);
});
}
/**
* Start a new session
*/
async newSession(): Promise<RpcResponse> {
return this.sendCommand({ type: "new_session" });
}
/**
* Manual compaction
*/
async compact(customInstructions?: string): Promise<RpcResponse> {
const cmd: RpcRequest = { type: "compact" };
if (customInstructions) {
cmd.customInstructions = customInstructions;
}
return this.sendCommand(cmd);
}
/**
* Send extension UI response (for dialog handling)
*/
sendUIResponse(id: string, response: Record<string, unknown>): void {
this.sendFireAndForget({
type: "extension_ui_response",
id,
...response,
});
}
/**
* Request OAuth login URL.
* NOTE: This RPC does not exist in pi-coding-agent. OAuth/device-code login
* must be done by importing AuthStorage directly from pi-coding-agent in
* the settings tab. We keep this stub returning a failure so the caller
* surfaces a clear error instead of silently hanging.
*/
async oauthLogin(provider: string): Promise<RpcResponse> {
return {
type: "response",
command: "oauth_login",
success: false,
error:
"oauth_login is not a Pi RPC command. Use the device-code login flow from the Pimate settings tab instead.",
};
}
/**
* Check if the process is running
*/
isRunning(): boolean {
return this.process !== null && !this.process.killed;
}
/**
* Restart the pi process (e.g., after settings change)
*/
async restart(): Promise<void> {
await this.destroy();
this.destroyed = false;
await this.start();
}
/**
* Destroy the client and kill the process
*/
async destroy(): Promise<void> {
if (this.destroyed) return;
this.destroyed = true;
// Reject all pending requests
for (const [, pending] of this.pendingRequests) {
window.clearTimeout(pending.timeout);
pending.reject(new Error("Client destroyed"));
}
this.pendingRequests.clear();
if (this.process) {
const p = this.process;
this.process = null;
if (!p.killed) {
p.kill("SIGTERM");
// Give it a moment to exit gracefully
await new Promise((resolve) => window.setTimeout(resolve, 500));
if (!p.killed) {
p.kill("SIGKILL");
}
}
}
}
}