merge pi
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import { Hono } from 'hono';
|
||||
import type { HonoVariables } from '@@/create-router';
|
||||
|
||||
export const piMonoModelsRouter = new Hono<{ Variables: HonoVariables }>();
|
||||
|
||||
// Hardcoded fallback models — pi supports many providers but these are the most common
|
||||
const FALLBACK_MODELS = [
|
||||
{ id: 'claude-sonnet-4-5-20250514', name: 'Claude Sonnet 4.5', provider: 'anthropic', providerId: 'anthropic' },
|
||||
{ id: 'claude-opus-4-20250918', name: 'Claude Opus 4', provider: 'anthropic', providerId: 'anthropic' },
|
||||
{ id: 'gpt-4.1', name: 'GPT-4.1', provider: 'openai', providerId: 'openai' },
|
||||
{ id: 'o3', name: 'o3', provider: 'openai', providerId: 'openai' },
|
||||
{ id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', provider: 'google', providerId: 'google' },
|
||||
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', provider: 'google', providerId: 'google' },
|
||||
];
|
||||
|
||||
piMonoModelsRouter.get('/pi-mono/models', async (ctx) => {
|
||||
// Spawn a short-lived pi process to query available models
|
||||
try {
|
||||
const proc = Bun.spawn(['pi', '--list-models', '--mode', 'json'], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
const output = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
|
||||
if (proc.exitCode !== 0) return ctx.json(FALLBACK_MODELS);
|
||||
|
||||
// Parse the output — pi --list-models outputs model info
|
||||
const lines = output.trim().split('\n').filter(Boolean);
|
||||
const models: { id: string; name: string; provider: string; providerId: string }[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
if (data.id && data.provider) {
|
||||
models.push({
|
||||
id: data.id,
|
||||
name: data.name ?? data.id,
|
||||
provider: data.provider,
|
||||
providerId: data.provider,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// skip non-JSON lines
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.json(models.length > 0 ? models : FALLBACK_MODELS);
|
||||
} catch {
|
||||
return ctx.json(FALLBACK_MODELS);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,448 @@
|
||||
import type { ServerWebSocket } from 'bun';
|
||||
import type { Subprocess } from 'bun';
|
||||
import { mkdir, rename } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import {
|
||||
getPiMonoSessionDir,
|
||||
getTmpAttachmentsDir,
|
||||
getAttachmentsDir,
|
||||
getHomeDir,
|
||||
getNativeSkillsDir,
|
||||
getGlobalSkillsDir,
|
||||
getUserSkillsDir,
|
||||
} from '@@/data-path';
|
||||
import { readSkillDirs, parseFrontmatter } from '@@/api/skills/skills';
|
||||
import type { ClientMessage, ServerMessage, ImageData, TaskInfo } from '@@/api/chat-types';
|
||||
import { createTaskLog, appendToLog, finalizeLog } from '@@/api/task-logger';
|
||||
|
||||
type WSData = { userId: number; email: string };
|
||||
|
||||
type ConnectionState = {
|
||||
piProcess: Subprocess | null;
|
||||
sessionId: string | null;
|
||||
pendingTitle: string | null;
|
||||
selectedModel: string | null;
|
||||
pendingAttachmentIds: string[];
|
||||
cwd: string | null;
|
||||
resourceChatDir: string | null;
|
||||
logId: string | null;
|
||||
fullText: string;
|
||||
rpcReady: boolean;
|
||||
};
|
||||
|
||||
const connections = new Map<ServerWebSocket<WSData>, ConnectionState>();
|
||||
|
||||
function send(ws: ServerWebSocket<WSData>, msg: ServerMessage) {
|
||||
if (ws.readyState === 1) ws.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
function resolveRootDir(email: string, root?: string): string {
|
||||
if (!root || root === 'home') return getHomeDir(email);
|
||||
if (root === '~') return homedir();
|
||||
if (root === 'officer.dev') return join(process.cwd(), '..');
|
||||
return getHomeDir(email);
|
||||
}
|
||||
|
||||
async function buildSkillsPrompt(email: string): Promise<string> {
|
||||
const nativeSkills = await readSkillDirs(getNativeSkillsDir());
|
||||
const globalSkills = await readSkillDirs(getGlobalSkillsDir());
|
||||
const userSkills = await readSkillDirs(getUserSkillsDir(email));
|
||||
|
||||
const merged = new Map(nativeSkills);
|
||||
for (const [name, path] of globalSkills) merged.set(name, path);
|
||||
for (const [name, path] of userSkills) merged.set(name, path);
|
||||
|
||||
if (merged.size === 0) return '';
|
||||
|
||||
const lines = await Promise.all(
|
||||
Array.from(merged.entries()).map(async ([dirName, filePath]) => {
|
||||
const raw = await Bun.file(filePath).text();
|
||||
const { frontmatter } = parseFrontmatter(raw);
|
||||
const name = frontmatter.name || dirName;
|
||||
return `- ${name}: ${frontmatter.description} (read ${filePath} for full instructions)`;
|
||||
}),
|
||||
);
|
||||
|
||||
return `\n\nYou have access to the following skills. When a user's request matches a skill, read its SKILL.md file for detailed instructions before proceeding.\n\nAvailable skills:\n${lines.join('\n')}`;
|
||||
}
|
||||
|
||||
function writeRpcCommand(proc: Subprocess, command: Record<string, unknown>) {
|
||||
const writer = proc.stdin as WritableStream;
|
||||
const textEncoder = new TextEncoder();
|
||||
const w = writer.getWriter();
|
||||
w.write(textEncoder.encode(JSON.stringify(command) + '\n'));
|
||||
w.releaseLock();
|
||||
}
|
||||
|
||||
function spawnPiProcess(ws: ServerWebSocket<WSData>, state: ConnectionState, workingDir: string) {
|
||||
const args = ['pi', '--mode', 'rpc', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes'];
|
||||
|
||||
if (state.selectedModel) {
|
||||
args.push('--model', state.selectedModel);
|
||||
}
|
||||
|
||||
const proc = Bun.spawn(args, {
|
||||
cwd: workingDir,
|
||||
stdin: 'pipe',
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
state.piProcess = proc;
|
||||
|
||||
// Read stdout line-by-line for JSON events
|
||||
const reader = proc.stdout.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
const readLoop = 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);
|
||||
handlePiEvent(ws, state, event);
|
||||
} catch {
|
||||
// skip unparseable lines
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// process ended
|
||||
}
|
||||
};
|
||||
|
||||
readLoop();
|
||||
|
||||
// Read stderr for debugging
|
||||
const stderrReader = proc.stderr.getReader();
|
||||
const stderrDecoder = new TextDecoder();
|
||||
const readStderr = async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await stderrReader.read();
|
||||
if (done) break;
|
||||
const text = stderrDecoder.decode(value, { stream: true });
|
||||
if (text.trim()) console.log('[pi-mono-ws] stderr:', text.trim());
|
||||
}
|
||||
} catch {
|
||||
// process ended
|
||||
}
|
||||
};
|
||||
readStderr();
|
||||
|
||||
// Handle process exit
|
||||
proc.exited.then((code) => {
|
||||
console.log(`[pi-mono-ws] pi process exited with code ${code}`);
|
||||
if (state.piProcess === proc) {
|
||||
state.piProcess = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handlePiEvent(ws: ServerWebSocket<WSData>, state: ConnectionState, event: Record<string, unknown>) {
|
||||
const type = event.type as string;
|
||||
|
||||
// RPC responses (type === 'response')
|
||||
if (type === 'response') {
|
||||
const command = event.command as string;
|
||||
if (command === 'get_available_models' && event.success) {
|
||||
// Models are handled by the REST endpoint, not here
|
||||
}
|
||||
if (command === 'prompt' && !event.success) {
|
||||
send(ws, { type: 'error', message: (event.error as string) ?? 'Prompt failed' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'agent_start':
|
||||
state.fullText = '';
|
||||
break;
|
||||
|
||||
case 'message_update': {
|
||||
// message_update contains assistantMessageEvent with content deltas
|
||||
const ame = event.assistantMessageEvent as Record<string, unknown> | undefined;
|
||||
if (!ame) break;
|
||||
|
||||
const ameType = ame.type as string;
|
||||
if (ameType === 'text_delta') {
|
||||
const delta = ame.delta as string;
|
||||
state.fullText += delta;
|
||||
send(ws, { type: 'assistant:partial', text: delta });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'message_end': {
|
||||
// Full assistant message complete
|
||||
if (state.fullText) {
|
||||
send(ws, { type: 'assistant:text', text: state.fullText });
|
||||
if (state.logId) appendToLog(state.logId, { role: 'assistant', text: state.fullText });
|
||||
state.fullText = '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
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>) ?? {};
|
||||
|
||||
// Commit any streaming text before tool use
|
||||
if (state.fullText) {
|
||||
send(ws, { type: 'assistant:text', text: state.fullText });
|
||||
if (state.logId) appendToLog(state.logId, { role: 'assistant', text: state.fullText });
|
||||
state.fullText = '';
|
||||
}
|
||||
|
||||
send(ws, { type: 'tool:use', toolName, toolInput: args, toolUseId: toolCallId });
|
||||
if (state.logId) appendToLog(state.logId, { role: 'tool', toolName, toolInput: args, toolUseId: toolCallId });
|
||||
break;
|
||||
}
|
||||
|
||||
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)) : '';
|
||||
|
||||
send(ws, { type: 'tool:result', toolUseId: toolCallId, output, isError });
|
||||
if (state.logId)
|
||||
appendToLog(state.logId, {
|
||||
role: 'tool',
|
||||
toolName: '',
|
||||
toolInput: {},
|
||||
toolUseId: toolCallId,
|
||||
output,
|
||||
isError,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'agent_end': {
|
||||
// Commit any remaining streaming text
|
||||
if (state.fullText) {
|
||||
send(ws, { type: 'assistant:text', text: state.fullText });
|
||||
if (state.logId) appendToLog(state.logId, { role: 'assistant', text: state.fullText });
|
||||
state.fullText = '';
|
||||
}
|
||||
|
||||
send(ws, { type: 'result', costUsd: 0, durationMs: 0, numTurns: 0, isError: false });
|
||||
if (state.logId) {
|
||||
appendToLog(state.logId, { role: 'result', costUsd: 0, durationMs: 0, numTurns: 0, isError: false });
|
||||
finalizeLog(state.logId);
|
||||
state.logId = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'extension_ui_request': {
|
||||
// Auto-cancel extension UI requests since we don't support them
|
||||
if (state.piProcess && event.id) {
|
||||
writeRpcCommand(state.piProcess, { type: 'extension_ui_response', id: event.id, cancelled: true });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type HandleChatParams = {
|
||||
ws: ServerWebSocket<WSData>;
|
||||
prompt: string;
|
||||
sessionId?: string;
|
||||
model?: string;
|
||||
cwd?: { root?: string; path: string };
|
||||
attachmentIds?: string[];
|
||||
images?: ImageData[];
|
||||
resourceChatDir?: string;
|
||||
taskInfo?: TaskInfo;
|
||||
};
|
||||
|
||||
async function handleChat({
|
||||
ws,
|
||||
prompt,
|
||||
sessionId,
|
||||
model,
|
||||
cwd,
|
||||
attachmentIds,
|
||||
images,
|
||||
resourceChatDir,
|
||||
taskInfo,
|
||||
}: HandleChatParams) {
|
||||
const state = connections.get(ws);
|
||||
if (!state) return;
|
||||
|
||||
if (taskInfo && !state.logId) {
|
||||
state.logId = createTaskLog(ws.data.email, taskInfo, 'pi-mono', model ?? 'unknown');
|
||||
appendToLog(state.logId, { role: 'user', text: prompt });
|
||||
}
|
||||
|
||||
if (resourceChatDir) state.resourceChatDir = resourceChatDir;
|
||||
if (model) state.selectedModel = model;
|
||||
|
||||
if (!sessionId && !state.sessionId) {
|
||||
// New session — generate our own sessionId for officer tracking
|
||||
const newSessionId = crypto.randomUUID();
|
||||
state.sessionId = newSessionId;
|
||||
state.pendingTitle = prompt.slice(0, 100);
|
||||
if (attachmentIds?.length) state.pendingAttachmentIds = attachmentIds;
|
||||
|
||||
send(ws, { type: 'session:init', sessionId: newSessionId, model: model ?? 'pi-mono' });
|
||||
|
||||
if (state.resourceChatDir) {
|
||||
const chatDir = join(state.resourceChatDir, 'chat');
|
||||
const meta = { id: newSessionId, model: model ?? 'pi-mono' };
|
||||
mkdir(chatDir, { recursive: true })
|
||||
.then(() => Bun.write(join(chatDir, 'meta.json'), JSON.stringify(meta)))
|
||||
.catch(() => {});
|
||||
} else {
|
||||
const dir = getPiMonoSessionDir(ws.data.email, newSessionId);
|
||||
const meta = {
|
||||
id: newSessionId,
|
||||
title: state.pendingTitle ?? 'New chat',
|
||||
createdAt: Date.now(),
|
||||
model: model ?? 'pi-mono',
|
||||
};
|
||||
mkdir(dir, { recursive: true })
|
||||
.then(() => Bun.write(join(dir, 'meta.json'), JSON.stringify(meta)))
|
||||
.catch(() => {});
|
||||
|
||||
// Move tmp attachments to session dir
|
||||
if (state.pendingAttachmentIds.length > 0) {
|
||||
const tmpDir = getTmpAttachmentsDir(ws.data.email);
|
||||
const destDir = getAttachmentsDir(ws.data.email, 'pi-mono', newSessionId);
|
||||
mkdir(destDir, { recursive: true })
|
||||
.then(() =>
|
||||
Promise.all(
|
||||
state.pendingAttachmentIds.map((id) => rename(join(tmpDir, id), join(destDir, id)).catch(() => {})),
|
||||
),
|
||||
)
|
||||
.catch(() => {});
|
||||
state.pendingAttachmentIds = [];
|
||||
}
|
||||
}
|
||||
state.pendingTitle = null;
|
||||
} else if (sessionId && !state.sessionId) {
|
||||
state.sessionId = sessionId;
|
||||
}
|
||||
|
||||
// Ensure pi process is running
|
||||
const homeDir = getHomeDir(ws.data.email);
|
||||
if (cwd) state.cwd = join(resolveRootDir(ws.data.email, cwd.root), cwd.path);
|
||||
const workingDir = state.cwd ?? homeDir;
|
||||
|
||||
if (!state.piProcess) {
|
||||
spawnPiProcess(ws, state, workingDir);
|
||||
// Give pi a moment to initialize
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
|
||||
if (!state.piProcess) {
|
||||
send(ws, { type: 'error', message: 'Failed to start pi process' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Set model if specified
|
||||
if (model && model !== state.selectedModel) {
|
||||
state.selectedModel = model;
|
||||
// Model is set via CLI args on spawn, would need a new process to change
|
||||
}
|
||||
|
||||
// Build context and send prompt
|
||||
const skillsAppend = await buildSkillsPrompt(ws.data.email);
|
||||
const contextAppend = `\n\nThe user's home directory is: ${homeDir}` + skillsAppend;
|
||||
send(ws, { type: 'system:prompt', text: contextAppend });
|
||||
|
||||
const fullPrompt = `<system>${contextAppend}</system>\n\n${prompt}`;
|
||||
|
||||
const rpcCommand: Record<string, unknown> = {
|
||||
type: 'prompt',
|
||||
id: `req_${Date.now()}`,
|
||||
message: fullPrompt,
|
||||
};
|
||||
|
||||
writeRpcCommand(state.piProcess, rpcCommand);
|
||||
}
|
||||
|
||||
function handleStop(ws: ServerWebSocket<WSData>) {
|
||||
const state = connections.get(ws);
|
||||
if (!state?.piProcess) return;
|
||||
|
||||
writeRpcCommand(state.piProcess, { type: 'abort', id: `abort_${Date.now()}` });
|
||||
send(ws, { type: 'stopped' });
|
||||
}
|
||||
|
||||
function killPiProcess(state: ConnectionState) {
|
||||
if (state.piProcess) {
|
||||
try {
|
||||
state.piProcess.kill();
|
||||
} catch {
|
||||
// already dead
|
||||
}
|
||||
state.piProcess = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const piMonoWebsocket = {
|
||||
open(ws: ServerWebSocket<WSData>) {
|
||||
connections.set(ws, {
|
||||
piProcess: null,
|
||||
sessionId: null,
|
||||
pendingTitle: null,
|
||||
selectedModel: null,
|
||||
pendingAttachmentIds: [],
|
||||
cwd: null,
|
||||
resourceChatDir: null,
|
||||
logId: null,
|
||||
fullText: '',
|
||||
rpcReady: false,
|
||||
});
|
||||
},
|
||||
|
||||
message(ws: ServerWebSocket<WSData>, raw: string | Buffer) {
|
||||
let msg: ClientMessage;
|
||||
try {
|
||||
msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString()) as ClientMessage;
|
||||
} catch {
|
||||
send(ws, { type: 'error', message: 'Invalid JSON' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'chat') {
|
||||
handleChat({
|
||||
ws,
|
||||
prompt: msg.prompt,
|
||||
sessionId: msg.sessionId,
|
||||
model: typeof msg.model === 'string' ? msg.model : undefined,
|
||||
cwd: msg.cwd,
|
||||
attachmentIds: msg.attachmentIds,
|
||||
images: msg.images,
|
||||
resourceChatDir: msg.resourceChatDir,
|
||||
taskInfo: msg.taskInfo,
|
||||
});
|
||||
} else if (msg.type === 'stop') {
|
||||
handleStop(ws);
|
||||
}
|
||||
},
|
||||
|
||||
close(ws: ServerWebSocket<WSData>) {
|
||||
const state = connections.get(ws);
|
||||
if (state) {
|
||||
killPiProcess(state);
|
||||
}
|
||||
connections.delete(ws);
|
||||
},
|
||||
|
||||
drain() {},
|
||||
};
|
||||
@@ -33,7 +33,7 @@ scrapeRouter.post('/', async (ctx) => {
|
||||
const { url, sessionId, provider } = ctx.get('body') as {
|
||||
url: string;
|
||||
sessionId?: string;
|
||||
provider?: 'claude' | 'opencode';
|
||||
provider?: 'claude' | 'opencode' | 'pi-mono';
|
||||
};
|
||||
|
||||
if (!url) return ctx.json({ error: 'url is required' }, 400);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
|
||||
export const piMonoRouter = createRouter();
|
||||
|
||||
const GLOBAL_DIRS = ['/usr/local/bin', '/usr/bin'];
|
||||
|
||||
const getPaths = async () => {
|
||||
try {
|
||||
const proc = Bun.spawn(['which', '-a', 'pi'], { stdout: 'pipe', stderr: 'pipe' });
|
||||
const output = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
if (proc.exitCode !== 0) return { path: null, globalPath: null };
|
||||
const paths = [...new Set(output.trim().split('\n'))];
|
||||
const path = paths[0] ?? null;
|
||||
const globalPath = paths.find((p) => GLOBAL_DIRS.some((dir) => p.startsWith(dir))) ?? null;
|
||||
return { path, globalPath };
|
||||
} catch {
|
||||
return { path: null, globalPath: null };
|
||||
}
|
||||
};
|
||||
|
||||
piMonoRouter.get('/version', async (ctx) => {
|
||||
try {
|
||||
const proc = Bun.spawn(['pi', '--version'], { stdout: 'pipe', stderr: 'pipe' });
|
||||
const output = await new Response(proc.stdout).text();
|
||||
await proc.exited;
|
||||
if (proc.exitCode !== 0) return ctx.json({ version: null, path: null, globalPath: null });
|
||||
const { path, globalPath } = await getPaths();
|
||||
return ctx.json({ version: output.trim(), path, globalPath });
|
||||
} catch {
|
||||
return ctx.json({ version: null, path: null, globalPath: null });
|
||||
}
|
||||
});
|
||||
|
||||
piMonoRouter.post('/install', async (ctx) => {
|
||||
try {
|
||||
const proc = Bun.spawn(['npm', 'install', '-g', '@mariozechner/pi-coding-agent'], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
await proc.exited;
|
||||
if (proc.exitCode !== 0) {
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
return ctx.json({ version: null, path: null, globalPath: null, error: stderr.trim() }, 500);
|
||||
}
|
||||
const versionProc = Bun.spawn(['pi', '--version'], { stdout: 'pipe', stderr: 'pipe' });
|
||||
const output = await new Response(versionProc.stdout).text();
|
||||
await versionProc.exited;
|
||||
const { path, globalPath } = await getPaths();
|
||||
return ctx.json({ version: output.trim(), path, globalPath });
|
||||
} catch {
|
||||
return ctx.json({ version: null, path: null, globalPath: null, error: 'Installation failed' }, 500);
|
||||
}
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { join } from 'node:path';
|
||||
import { officerdb, count, Users } from 'officerdb';
|
||||
import { claudeCodeRouter } from './claude-code';
|
||||
import { opencodeRouter } from './opencode';
|
||||
import { piMonoRouter } from './pi-mono';
|
||||
import { applicationsRouter } from './applications';
|
||||
import { resourcesRouter } from './resources';
|
||||
|
||||
@@ -22,6 +23,7 @@ export const serverSettingsRouter = createRouter();
|
||||
|
||||
serverSettingsRouter.route('/claude-code', claudeCodeRouter);
|
||||
serverSettingsRouter.route('/opencode', opencodeRouter);
|
||||
serverSettingsRouter.route('/pi-mono', piMonoRouter);
|
||||
serverSettingsRouter.route('/applications', applicationsRouter);
|
||||
serverSettingsRouter.route('/resources', resourcesRouter);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import { mkdir, readdir, rename, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { getClaudeDir, getSessionDir, getArchivedSessionDir, getOpencodeDir, getOpencodeSessionDir } from '@@/data-path';
|
||||
import { getClaudeDir, getSessionDir, getArchivedSessionDir, getOpencodeDir, getOpencodeSessionDir, getPiMonoDir, getPiMonoSessionDir } from '@@/data-path';
|
||||
import type { HonoVariables } from '@@/create-router';
|
||||
|
||||
const OPENCODE_PORT = process.env.OPENCODE_PORT ?? '10006';
|
||||
@@ -14,9 +14,13 @@ export const sessionsRouter = new Hono<{ Variables: HonoVariables }>();
|
||||
sessionsRouter.get('/sessions', async (ctx) => {
|
||||
const { email } = ctx.get('user');
|
||||
|
||||
const [claudeSessions, opencodeSessions] = await Promise.all([fetchClaudeSessions(email), fetchOpencodeSessions(email)]);
|
||||
const [claudeSessions, opencodeSessions, piMonoSessions] = await Promise.all([
|
||||
fetchClaudeSessions(email),
|
||||
fetchOpencodeSessions(email),
|
||||
fetchPiMonoSessions(email),
|
||||
]);
|
||||
|
||||
const merged = [...claudeSessions, ...opencodeSessions].sort((a, b) => b.createdAt - a.createdAt);
|
||||
const merged = [...claudeSessions, ...opencodeSessions, ...piMonoSessions].sort((a, b) => b.createdAt - a.createdAt);
|
||||
return ctx.json(merged);
|
||||
});
|
||||
|
||||
@@ -37,6 +41,12 @@ sessionsRouter.get('/sessions/:provider/:id/messages', async (ctx) => {
|
||||
return ctx.json(await fetchOpencodeMessages(id));
|
||||
}
|
||||
|
||||
if (provider === 'pi-mono') {
|
||||
const file = Bun.file(join(getPiMonoSessionDir(email, id), 'messages.json'));
|
||||
if (!(await file.exists())) return ctx.json([]);
|
||||
return ctx.json(await file.json());
|
||||
}
|
||||
|
||||
return ctx.json({ error: 'invalid provider' }, 400);
|
||||
});
|
||||
|
||||
@@ -46,10 +56,10 @@ sessionsRouter.put('/sessions/:provider/:id/messages', async (ctx) => {
|
||||
const id = ctx.req.param('id');
|
||||
|
||||
if (provider === 'opencode') return ctx.json({ error: 'opencode sessions are read-only' }, 400);
|
||||
if (provider !== 'claude') return ctx.json({ error: 'invalid provider' }, 400);
|
||||
if (provider !== 'claude' && provider !== 'pi-mono') return ctx.json({ error: 'invalid provider' }, 400);
|
||||
|
||||
const messages = ctx.get('body');
|
||||
const dir = getSessionDir(email, id);
|
||||
const dir = provider === 'pi-mono' ? getPiMonoSessionDir(email, id) : getSessionDir(email, id);
|
||||
await Bun.write(join(dir, 'messages.json'), JSON.stringify(messages));
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
@@ -92,6 +102,16 @@ sessionsRouter.put('/sessions/:provider/:id', async (ctx) => {
|
||||
return ctx.json({ ok: true });
|
||||
}
|
||||
|
||||
if (provider === 'pi-mono') {
|
||||
const dir = getPiMonoSessionDir(email, id);
|
||||
const metaFile = Bun.file(join(dir, 'meta.json'));
|
||||
if (!(await metaFile.exists())) return ctx.json({ error: 'session not found' }, 404);
|
||||
const meta = await metaFile.json();
|
||||
meta.title = body.title.slice(0, 200);
|
||||
await Bun.write(join(dir, 'meta.json'), JSON.stringify(meta));
|
||||
return ctx.json({ ok: true });
|
||||
}
|
||||
|
||||
return ctx.json({ error: 'invalid provider' }, 400);
|
||||
});
|
||||
|
||||
@@ -124,6 +144,16 @@ sessionsRouter.delete('/sessions/:provider/:id', async (ctx) => {
|
||||
return ctx.json({ ok: true });
|
||||
}
|
||||
|
||||
if (provider === 'pi-mono') {
|
||||
const dir = getPiMonoSessionDir(email, id);
|
||||
try {
|
||||
await rm(dir, { recursive: true });
|
||||
} catch {
|
||||
// dir may not exist
|
||||
}
|
||||
return ctx.json({ ok: true });
|
||||
}
|
||||
|
||||
return ctx.json({ error: 'invalid provider' }, 400);
|
||||
});
|
||||
|
||||
@@ -134,7 +164,7 @@ sessionsRouter.post('/sessions/:provider/:id/archive', async (ctx) => {
|
||||
const provider = ctx.req.param('provider');
|
||||
const id = ctx.req.param('id');
|
||||
|
||||
if (provider === 'opencode') return ctx.json({ error: 'opencode sessions cannot be archived' }, 400);
|
||||
if (provider === 'opencode' || provider === 'pi-mono') return ctx.json({ error: `${provider} sessions cannot be archived` }, 400);
|
||||
if (provider !== 'claude') return ctx.json({ error: 'invalid provider' }, 400);
|
||||
|
||||
const src = getSessionDir(email, id);
|
||||
@@ -150,7 +180,7 @@ type SessionMeta = {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
provider: 'claude' | 'opencode';
|
||||
provider: 'claude' | 'opencode' | 'pi-mono';
|
||||
model?: string | null;
|
||||
};
|
||||
|
||||
@@ -200,6 +230,28 @@ async function fetchOpencodeSessions(email: string): Promise<SessionMeta[]> {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPiMonoSessions(email: string): Promise<SessionMeta[]> {
|
||||
const dir = getPiMonoDir(email);
|
||||
try {
|
||||
const entries = await readdir(dir);
|
||||
const sessions = await Promise.all(
|
||||
entries.map(async (id) => {
|
||||
try {
|
||||
const metaFile = Bun.file(join(dir, id, 'meta.json'));
|
||||
if (!(await metaFile.exists())) return null;
|
||||
const meta = await metaFile.json();
|
||||
return { ...meta, provider: 'pi-mono' as const };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return sessions.filter((s): s is SessionMeta => s !== null);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOpencodeMessages(id: string) {
|
||||
try {
|
||||
const res = await fetch(`${OPENCODE_BASE}/session/${id}/message`);
|
||||
|
||||
@@ -3,9 +3,16 @@ import { existsSync } from 'node:fs';
|
||||
import { cp, mkdir } from 'node:fs/promises';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import * as pty from 'node-pty';
|
||||
|
||||
const run = (cmd, args, opts = {}) =>
|
||||
new Promise((resolve) => {
|
||||
const proc = execFile(cmd, args, { stdio: 'ignore', ...opts }, () => resolve());
|
||||
proc.on('error', () => resolve());
|
||||
});
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const isDocker = existsSync('/opt/terminal-templates/.zshrc');
|
||||
|
||||
@@ -60,25 +67,14 @@ const ensureUserFiles = async (homeDir) => {
|
||||
if (ohMyZshSource && existsSync(ohMyZshSource)) {
|
||||
await cp(ohMyZshSource, ohMyZshPath, { recursive: true });
|
||||
} else {
|
||||
const proc = Bun.spawn({
|
||||
cmd: ['git', 'clone', '--depth=1', 'https://github.com/ohmyzsh/ohmyzsh.git', ohMyZshPath],
|
||||
stdout: 'ignore',
|
||||
stderr: 'ignore',
|
||||
});
|
||||
await proc.exited;
|
||||
await run('git', ['clone', '--depth=1', 'https://github.com/ohmyzsh/ohmyzsh.git', ohMyZshPath]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isDocker) {
|
||||
const starshipBin = join(homeDir, '.local', 'bin', 'starship');
|
||||
if (!existsSync(starshipBin)) {
|
||||
const installProc = Bun.spawn({
|
||||
cmd: ['sh', '-c', 'curl -sS https://starship.rs/install.sh | sh -s -- -y -b "$HOME/.local/bin"'],
|
||||
env: { ...process.env, HOME: homeDir },
|
||||
stdout: 'ignore',
|
||||
stderr: 'ignore',
|
||||
});
|
||||
await installProc.exited;
|
||||
await run('sh', ['-c', 'curl -sS https://starship.rs/install.sh | sh -s -- -y -b "$HOME/.local/bin"'], { env: { ...process.env, HOME: homeDir } });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -149,15 +145,36 @@ wss.on('connection', (ws) => {
|
||||
const cwd = msg.cwd ?? process.cwd();
|
||||
const homeDir = msg.homeDir ?? process.cwd();
|
||||
const userLabel = msg.userLabel ?? 'officer';
|
||||
const prompt = `${userLabel} in %~ %# `;
|
||||
const bashPrompt = `${userLabel} \\w \\$ `;
|
||||
const cols = msg.cols ?? 80;
|
||||
const rows = msg.rows ?? 24;
|
||||
const isHost = !!msg.host;
|
||||
|
||||
try {
|
||||
await ensureUserFiles(homeDir);
|
||||
} catch {
|
||||
// ignore
|
||||
let ptyEnv;
|
||||
if (isHost) {
|
||||
ptyEnv = { ...process.env, TERM: 'xterm-256color' };
|
||||
} else {
|
||||
const prompt = `${userLabel} in %~ %# `;
|
||||
const bashPrompt = `${userLabel} \\w \\$ `;
|
||||
|
||||
try {
|
||||
await ensureUserFiles(homeDir);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
ptyEnv = {
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
ZDOTDIR: homeDir,
|
||||
ZSH: `${homeDir}/.oh-my-zsh`,
|
||||
SHELL: shell.command,
|
||||
USER: userLabel,
|
||||
LOGNAME: userLabel,
|
||||
OFFICER_TERMINAL_USER: userLabel,
|
||||
PROMPT: prompt,
|
||||
PS1: bashPrompt,
|
||||
TERM: 'xterm-256color',
|
||||
};
|
||||
}
|
||||
|
||||
let term;
|
||||
@@ -167,19 +184,7 @@ wss.on('connection', (ws) => {
|
||||
cols,
|
||||
rows,
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
ZDOTDIR: homeDir,
|
||||
ZSH: `${homeDir}/.oh-my-zsh`,
|
||||
SHELL: shell.command,
|
||||
USER: userLabel,
|
||||
LOGNAME: userLabel,
|
||||
OFFICER_TERMINAL_USER: userLabel,
|
||||
PROMPT: prompt,
|
||||
PS1: bashPrompt,
|
||||
TERM: 'xterm-256color',
|
||||
},
|
||||
env: ptyEnv,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to start terminal';
|
||||
@@ -205,7 +210,8 @@ wss.on('connection', (ws) => {
|
||||
}
|
||||
});
|
||||
|
||||
term.onExit(() => {
|
||||
term.onExit(({ exitCode, signal }) => {
|
||||
console.log(`[sidecar] session ${sessionId} exited code=${exitCode} signal=${signal}`);
|
||||
if (session.ws) {
|
||||
sendJson(session.ws, { type: 'exit' });
|
||||
}
|
||||
@@ -228,7 +234,11 @@ wss.on('connection', (ws) => {
|
||||
if (msg.cols > 0 && msg.rows > 0) {
|
||||
session.cols = msg.cols;
|
||||
session.rows = msg.rows;
|
||||
session.term.resize(msg.cols, msg.rows);
|
||||
try {
|
||||
session.term.resize(msg.cols, msg.rows);
|
||||
} catch {
|
||||
// PTY may have already exited
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'cwd':
|
||||
|
||||
@@ -236,20 +236,28 @@ const sidecarAlive = async (port: number): Promise<boolean> => {
|
||||
}
|
||||
};
|
||||
|
||||
const startHostSidecar = async () => {
|
||||
if (await sidecarAlive(HOST_SIDECAR_PORT)) {
|
||||
console.log(`[terminal] host sidecar already running on port ${HOST_SIDECAR_PORT}`);
|
||||
return;
|
||||
const killSidecarOnPort = (port: number) => {
|
||||
try {
|
||||
const result = Bun.spawnSync({ cmd: ['fuser', '-k', `${port}/tcp`], stdout: 'ignore', stderr: 'ignore' });
|
||||
if (result.exitCode === 0) console.log(`[terminal] killed stale sidecar on port ${port}`);
|
||||
} catch {
|
||||
// fuser not available or failed
|
||||
}
|
||||
};
|
||||
|
||||
const startHostSidecar = async () => {
|
||||
if (hostSidecarProcess) {
|
||||
hostSidecarProcess.kill();
|
||||
await hostSidecarProcess.exited.catch(() => {});
|
||||
hostSidecarProcess = null;
|
||||
}
|
||||
|
||||
killSidecarOnPort(HOST_SIDECAR_PORT);
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const sidecarPath = fileURLToPath(new URL('./pty-sidecar.mjs', import.meta.url));
|
||||
hostSidecarProcess = Bun.spawn({
|
||||
cmd: ['bun', sidecarPath],
|
||||
cmd: ['node', sidecarPath],
|
||||
env: { ...process.env, TERMINAL_PTY_PORT: String(HOST_SIDECAR_PORT) },
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
@@ -323,6 +331,7 @@ export const terminalWebsocket = {
|
||||
sidecar.send(
|
||||
JSON.stringify({
|
||||
type: 'init',
|
||||
host: true,
|
||||
sessionId: ws.data.sessionId ?? `host-${ws.data.userId}`,
|
||||
shell: { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] },
|
||||
cwd: resolveCwd(process.env.HOME!, ws.data.cwd),
|
||||
|
||||
@@ -13,7 +13,7 @@ uploadRouter.post('/', async (ctx) => {
|
||||
|
||||
const file = body.file as File | null;
|
||||
const sessionId = (body.sessionId as string) || null;
|
||||
const provider = (body.provider as 'claude' | 'opencode') || null;
|
||||
const provider = (body.provider as 'claude' | 'opencode' | 'pi-mono') || null;
|
||||
|
||||
if (!file || !(file instanceof File)) {
|
||||
return ctx.json({ error: 'file is required' }, 400);
|
||||
|
||||
Reference in New Issue
Block a user