fix: Pi harness - dynamic models, correct RPC protocol, proper event handling
Backend: - /api/pi/models now calls 'pi --list-models' with stored API keys - pi-bridge.ts: callback-based event handling (matches pi-monorepo) - pi-bridge.ts: correct RPC format (type: 'prompt' not jsonrpc) - pi-bridge.ts: pass API keys to Pi process env - websocket.ts: event handler runs in background, no blocking - rest.ts: fix user home path (getHomeDir instead of hardcoded) Frontend: - Fix /api/ double prefix in useChatSessions, useChatGroups, useModels - Add PROVIDER_DISPLAY mapping in SystemSettings.tsx - Provider tabs show friendly names (e.g., 'OpenCode Zen') UI (from previous session): - Grouped session list with collapsible folders - CreateGroupDialog, GroupContextMenu, SessionContextMenu components
This commit is contained in:
+189
-119
@@ -1,35 +1,180 @@
|
||||
import type { Subprocess } from "bun";
|
||||
import type { PiEvent, MessageCost } from "./types";
|
||||
import { readApiKeys } from "../server-settings/pi-mono";
|
||||
import { logger } from "./logger";
|
||||
|
||||
export type PiEventHandler = (event: PiEvent) => void;
|
||||
|
||||
export async function spawnPi(
|
||||
cwd: string,
|
||||
model: string,
|
||||
env?: Record<string, string>
|
||||
onEvent: PiEventHandler
|
||||
): Promise<Subprocess> {
|
||||
const piProcess = Bun.spawn(
|
||||
[
|
||||
"pi",
|
||||
"--mode",
|
||||
"rpc",
|
||||
"--no-extensions",
|
||||
"--no-skills",
|
||||
"--model",
|
||||
model,
|
||||
],
|
||||
{
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
},
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "inherit",
|
||||
}
|
||||
);
|
||||
const storedKeys = await readApiKeys();
|
||||
|
||||
const args = ['pi', '--mode', 'rpc', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes'];
|
||||
if (model) args.push('--model', model);
|
||||
|
||||
return piProcess;
|
||||
const proc = Bun.spawn(args, {
|
||||
cwd,
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: { ...process.env, ...storedKeys },
|
||||
});
|
||||
|
||||
// Read stdout JSON event stream (runs in background)
|
||||
const reader = proc.stdout.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let streamBuffer = '';
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const event = JSON.parse(line) as Record<string, unknown>;
|
||||
const piEvent = parsePiEvent(event, streamBuffer);
|
||||
if (piEvent) {
|
||||
if (piEvent.type === 'delta') {
|
||||
streamBuffer += piEvent.text;
|
||||
} else if (piEvent.type === 'text' || piEvent.type === 'tool:start') {
|
||||
streamBuffer = '';
|
||||
}
|
||||
onEvent(piEvent);
|
||||
}
|
||||
} catch {
|
||||
// Skip unparseable lines
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Process ended
|
||||
}
|
||||
})();
|
||||
|
||||
// Stderr → debug log
|
||||
const stderrReader = proc.stderr.getReader();
|
||||
const stderrDecoder = new TextDecoder();
|
||||
(async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await stderrReader.read();
|
||||
if (done) break;
|
||||
const text = stderrDecoder.decode(value, { stream: true });
|
||||
if (text.trim()) logger.info('Pi stderr', { text: text.trim() });
|
||||
}
|
||||
} catch {
|
||||
// Process ended
|
||||
}
|
||||
})();
|
||||
|
||||
// Handle process exit
|
||||
proc.exited.then((code) => {
|
||||
logger.info('Pi process exited', { code });
|
||||
});
|
||||
|
||||
return proc;
|
||||
}
|
||||
|
||||
function parsePiEvent(event: Record<string, unknown>, currentStreamBuffer: string): PiEvent | null {
|
||||
const type = event.type as string;
|
||||
|
||||
// Handle response (success/failure for commands)
|
||||
if (type === 'response') {
|
||||
if (event.command === 'prompt' && !event.success) {
|
||||
const errorMsg = (event.error as string) ?? 'Prompt failed';
|
||||
return { type: 'error', message: errorMsg };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'agent_start':
|
||||
// No event to emit, just resets state
|
||||
return null;
|
||||
|
||||
case 'message_update': {
|
||||
const ame = event.assistantMessageEvent as Record<string, unknown> | undefined;
|
||||
if (ame?.type === 'text_delta') {
|
||||
const delta = ame.delta as string;
|
||||
return { type: 'delta', text: delta };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'message_end': {
|
||||
if (currentStreamBuffer) {
|
||||
return { type: 'text', text: currentStreamBuffer };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case 'tool_execution_start': {
|
||||
const toolCallId = (event.toolCallId as string) ?? '';
|
||||
const toolName = (event.toolName as string) ?? 'unknown';
|
||||
const args = (event.args as Record<string, unknown>) ?? {};
|
||||
|
||||
return {
|
||||
type: 'tool:start',
|
||||
toolCallId,
|
||||
toolName,
|
||||
toolInput: args,
|
||||
};
|
||||
}
|
||||
|
||||
case 'tool_execution_end': {
|
||||
const toolCallId = (event.toolCallId as string) ?? '';
|
||||
const result = event.result;
|
||||
const isError = (event.isError as boolean) ?? false;
|
||||
const output = result != null ? (typeof result === 'string' ? result : JSON.stringify(result)) : '';
|
||||
|
||||
return {
|
||||
type: 'tool:result',
|
||||
toolCallId,
|
||||
output,
|
||||
isError,
|
||||
};
|
||||
}
|
||||
|
||||
case 'agent_end': {
|
||||
// Pi doesn't provide cost info in agent_end, use zeros
|
||||
const cost: MessageCost = {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalUSD: 0,
|
||||
};
|
||||
return { type: 'result', cost };
|
||||
}
|
||||
|
||||
case 'extension_ui_request': {
|
||||
// Will be handled separately
|
||||
return null;
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeRpcCommand(proc: Subprocess, command: Record<string, unknown>): void {
|
||||
const stdin = proc.stdin;
|
||||
if (!stdin || typeof stdin === 'number') return;
|
||||
try {
|
||||
const writer = stdin as { write(data: string): void; flush(): void };
|
||||
writer.write(JSON.stringify(command) + '\n');
|
||||
writer.flush();
|
||||
} catch (err) {
|
||||
logger.error('writeRpcCommand error', { error: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
export function sendPrompt(
|
||||
@@ -37,113 +182,38 @@ export function sendPrompt(
|
||||
prompt: string,
|
||||
requestId: string
|
||||
): void {
|
||||
const request = {
|
||||
jsonrpc: "2.0",
|
||||
method: "chat",
|
||||
params: {
|
||||
prompt,
|
||||
},
|
||||
writeRpcCommand(process, {
|
||||
type: 'prompt',
|
||||
id: requestId,
|
||||
};
|
||||
|
||||
const writer = (process.stdin as any).getWriter();
|
||||
writer.write(
|
||||
new TextEncoder().encode(JSON.stringify(request) + "\n")
|
||||
);
|
||||
writer.releaseLock();
|
||||
message: prompt,
|
||||
});
|
||||
}
|
||||
|
||||
export function abort(
|
||||
process: Subprocess,
|
||||
requestId: string
|
||||
): void {
|
||||
const request = {
|
||||
jsonrpc: "2.0",
|
||||
method: "abort",
|
||||
params: {},
|
||||
writeRpcCommand(process, {
|
||||
type: 'abort',
|
||||
id: requestId,
|
||||
};
|
||||
|
||||
const writer = (process.stdin as any).getWriter();
|
||||
writer.write(
|
||||
new TextEncoder().encode(JSON.stringify(request) + "\n")
|
||||
);
|
||||
writer.releaseLock();
|
||||
});
|
||||
}
|
||||
|
||||
export async function* readEvents(
|
||||
process: Subprocess
|
||||
): AsyncGenerator<PiEvent> {
|
||||
if (!process.stdout) {
|
||||
throw new Error("Pi process stdout not available");
|
||||
}
|
||||
|
||||
const reader = (process.stdout as any).getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
try {
|
||||
const event = JSON.parse(line);
|
||||
|
||||
if (event.method === "text") {
|
||||
yield { type: "text", text: event.params.text };
|
||||
} else if (event.method === "delta") {
|
||||
yield { type: "delta", text: event.params.text };
|
||||
} else if (event.method === "tool:start") {
|
||||
yield {
|
||||
type: "tool:start",
|
||||
toolCallId: event.params.toolCallId,
|
||||
toolName: event.params.toolName,
|
||||
toolInput: event.params.toolInput,
|
||||
};
|
||||
} else if (event.method === "tool:result") {
|
||||
yield {
|
||||
type: "tool:result",
|
||||
toolCallId: event.params.toolCallId,
|
||||
output: event.params.output,
|
||||
isError: event.params.isError || false,
|
||||
};
|
||||
} else if (event.method === "result") {
|
||||
const cost: MessageCost = {
|
||||
inputTokens: event.params.cost?.inputTokens || 0,
|
||||
outputTokens: event.params.cost?.outputTokens || 0,
|
||||
totalUSD: event.params.cost?.totalUSD || 0,
|
||||
};
|
||||
yield { type: "result", cost };
|
||||
} else if (event.method === "error") {
|
||||
yield {
|
||||
type: "error",
|
||||
message: event.params.message || "Unknown error",
|
||||
};
|
||||
} else if (event.method === "stopped") {
|
||||
yield { type: "stopped" };
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("Failed to parse Pi event", { line, error: String(err) });
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
export function cancelExtensionUi(
|
||||
process: Subprocess,
|
||||
id: unknown
|
||||
): void {
|
||||
writeRpcCommand(process, {
|
||||
type: 'extension_ui_response',
|
||||
id,
|
||||
cancelled: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function killPi(process: Subprocess): void {
|
||||
process.kill();
|
||||
try {
|
||||
process.kill();
|
||||
} catch {
|
||||
// Already dead
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user