fix(pi): add snap node compatibility diagnostics and documentation
- Added detailed error logging to detect snap node compatibility issues - When Pi process exits with code 1, log helpful diagnostic info including node path - Add hint to check for snap node and reinstall via apt/nvm - Create SNAP_NODE_COMPATIBILITY.md with full troubleshooting guide - Document root cause: snap node has file descriptor incompatibility with Bun.spawn stdin pipes - Provide clear installation instructions for NodeSource and nvm alternatives
This commit is contained in:
@@ -30,6 +30,7 @@ type WSData = {
|
||||
email: string;
|
||||
username: string;
|
||||
role: string;
|
||||
sandboxed: boolean;
|
||||
provider: string;
|
||||
};
|
||||
|
||||
@@ -70,11 +71,11 @@ export async function open(ws: ServerWebSocket<WSData>): Promise<void> {
|
||||
|
||||
export function message(ws: ServerWebSocket<WSData>, raw: string | Buffer): void {
|
||||
const data = typeof raw === 'string' ? raw : raw.toString();
|
||||
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const clientMsg = JSON.parse(data) as ClientMessage;
|
||||
|
||||
|
||||
if (clientMsg.type === 'chat') {
|
||||
await handleChat(ws, clientMsg);
|
||||
} else if (clientMsg.type === 'resume') {
|
||||
@@ -91,7 +92,7 @@ export function message(ws: ServerWebSocket<WSData>, raw: string | Buffer): void
|
||||
|
||||
export function close(ws: ServerWebSocket<WSData>): void {
|
||||
// logger.info('WebSocket connection closed', { email: ws.data.email });
|
||||
|
||||
|
||||
const sessionId = wsToSessionMap.get(ws);
|
||||
if (sessionId) {
|
||||
sessionManager.detachWs(sessionId);
|
||||
@@ -118,7 +119,7 @@ function createEventHandler(sessionId: string, model: string, cwd: string, stora
|
||||
const text = event.text || session.streamBuffer;
|
||||
if (text) {
|
||||
sendToClient(ws, { type: 'assistant:text', text });
|
||||
|
||||
|
||||
const assistantMsg: Message = {
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
@@ -137,7 +138,7 @@ function createEventHandler(sessionId: string, model: string, cwd: string, stora
|
||||
// Flush any pending streaming text first
|
||||
if (session.streamBuffer) {
|
||||
sendToClient(ws, { type: 'assistant:text', text: session.streamBuffer });
|
||||
|
||||
|
||||
const assistantMsg: Message = {
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
@@ -194,7 +195,7 @@ function createEventHandler(sessionId: string, model: string, cwd: string, stora
|
||||
// Flush any remaining streaming buffer
|
||||
if (session.streamBuffer) {
|
||||
sendToClient(ws, { type: 'assistant:text', text: session.streamBuffer });
|
||||
|
||||
|
||||
const assistantMsg: Message = {
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
@@ -209,7 +210,7 @@ function createEventHandler(sessionId: string, model: string, cwd: string, stora
|
||||
}
|
||||
|
||||
sendToClient(ws, { type: 'result', sessionId, cost: event.cost });
|
||||
|
||||
|
||||
session.isGenerating = false;
|
||||
session.meta.cost.inputTokens += event.cost.inputTokens;
|
||||
session.meta.cost.outputTokens += event.cost.outputTokens;
|
||||
@@ -243,11 +244,24 @@ function createEventHandler(sessionId: string, model: string, cwd: string, stora
|
||||
|
||||
async function handleChat(
|
||||
ws: ServerWebSocket<WSData>,
|
||||
msg: { prompt: string; displayText?: string; sessionId?: string; model?: string; cwd?: string; cwdRoot?: string; sandboxed?: boolean; groupSlug?: string; attachmentIds?: string[]; thinking?: string; context?: string; contextId?: string }
|
||||
msg: {
|
||||
prompt: string;
|
||||
displayText?: string;
|
||||
sessionId?: string;
|
||||
model?: string;
|
||||
cwd?: string;
|
||||
cwdRoot?: string;
|
||||
sandboxed?: boolean;
|
||||
groupSlug?: string;
|
||||
attachmentIds?: string[];
|
||||
thinking?: string;
|
||||
context?: string;
|
||||
contextId?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const { email, username, userId } = ws.data;
|
||||
const sessionId = msg.sessionId || randomUUID();
|
||||
|
||||
|
||||
// Use provided model, or fall back to user default, or use system default
|
||||
let model = msg.model;
|
||||
let modelSource = 'client-provided';
|
||||
@@ -262,7 +276,7 @@ async function handleChat(
|
||||
modelSource = 'system-default';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
logger.info('Model selected for chat', {
|
||||
sessionId,
|
||||
model,
|
||||
@@ -276,41 +290,39 @@ async function handleChat(
|
||||
}
|
||||
|
||||
const homeDir = getHomeDir(email);
|
||||
const sandboxed = msg.sandboxed ?? false;
|
||||
const cwd = sandboxed
|
||||
const cwd = ws.data.sandboxed
|
||||
? resolveSandboxedCwd(email, msg.cwdRoot, msg.cwd)
|
||||
: resolveHostCwd(msg.cwdRoot, msg.cwd);
|
||||
const groupSlug = msg.groupSlug || null;
|
||||
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug, msg.context, msg.contextId);
|
||||
session.sandboxed = sandboxed;
|
||||
session.userId = userId;
|
||||
sessionManager.attachWs(sessionId, ws);
|
||||
wsToSessionMap.set(ws as any, sessionId);
|
||||
|
||||
sendToClient(ws, { type: 'session:init', sessionId, model, cwd, context: session.meta.context, contextId: session.meta.contextId });
|
||||
sendToClient(ws, {
|
||||
type: 'session:init',
|
||||
sessionId,
|
||||
model,
|
||||
cwd,
|
||||
context: session.meta.context,
|
||||
contextId: session.meta.contextId,
|
||||
});
|
||||
if (!session.piProcess) {
|
||||
try {
|
||||
const onEvent = createEventHandler(sessionId, model, cwd, homeDir);
|
||||
const sandbox = sandboxed ? { userId, username, email, homeDir } : undefined;
|
||||
|
||||
// If session has history, save to disk and pass --session for context replay
|
||||
let spawnOptions: { sessionFile?: string } | undefined;
|
||||
let spawnOptions: { sessionFile?: string; username?: string } | undefined;
|
||||
if (session.messages.length > 0) {
|
||||
await storage.saveSession(homeDir, sessionId, session.meta, session.messages);
|
||||
const hostPath = await storage.getSessionFilePath(homeDir, sessionId);
|
||||
if (hostPath) {
|
||||
if (sandboxed) {
|
||||
const containerHome = `/home/${username}`;
|
||||
const sessionsPrefix = join(homeDir, '.pi', 'agent', 'sessions');
|
||||
const relativePart = hostPath.slice(sessionsPrefix.length);
|
||||
spawnOptions = { sessionFile: `${containerHome}/.pi/agent/sessions${relativePart}` };
|
||||
} else {
|
||||
spawnOptions = { sessionFile: hostPath };
|
||||
}
|
||||
spawnOptions = { sessionFile: hostPath, username };
|
||||
}
|
||||
}
|
||||
if (!spawnOptions) spawnOptions = { username };
|
||||
|
||||
session.piProcess = await piBridge.spawnPi(cwd, model, userId, email, onEvent, sandbox, spawnOptions);
|
||||
session.piProcess = await piBridge.spawnPi(cwd, model, userId, email, onEvent, spawnOptions);
|
||||
|
||||
// Null out piProcess when the process dies so next message triggers respawn
|
||||
const proc = session.piProcess;
|
||||
@@ -321,7 +333,12 @@ async function handleChat(
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('Spawned Pi process for session', { sessionId, model, cwd, sandboxed, hasSessionFile: !!spawnOptions?.sessionFile });
|
||||
logger.info('Spawned Pi process for session', {
|
||||
sessionId,
|
||||
model,
|
||||
cwd,
|
||||
hasSessionFile: !!spawnOptions?.sessionFile,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('Failed to spawn Pi process', { sessionId, model, error: String(err) });
|
||||
sendToClient(ws, { type: 'error', message: 'Failed to start Pi process' });
|
||||
@@ -360,34 +377,39 @@ async function handleClaudeCodeChat(
|
||||
ws: ServerWebSocket<WSData>,
|
||||
sessionId: string,
|
||||
model: string,
|
||||
msg: { prompt: string; displayText?: string; groupSlug?: string; context?: string; contextId?: string; cwd?: string; cwdRoot?: string; sandboxed?: boolean },
|
||||
msg: {
|
||||
prompt: string;
|
||||
displayText?: string;
|
||||
groupSlug?: string;
|
||||
context?: string;
|
||||
contextId?: string;
|
||||
cwd?: string;
|
||||
cwdRoot?: string;
|
||||
sandboxed?: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
const { email, username, userId } = ws.data;
|
||||
const homeDir = getHomeDir(email);
|
||||
const sandboxed = msg.sandboxed ?? false;
|
||||
|
||||
// Claude Code always operates on the user's data home (not OS home).
|
||||
// Resolve cwd relative to data directory, then remap for container if sandboxed.
|
||||
const dataCwd = resolveSandboxedCwd(email, msg.cwdRoot, msg.cwd);
|
||||
let cwd: string;
|
||||
if (sandboxed) {
|
||||
const containerHome = `/home/${username}`;
|
||||
cwd = dataCwd.startsWith(homeDir)
|
||||
? `${containerHome}${dataCwd.slice(homeDir.length)}`
|
||||
: containerHome;
|
||||
} else {
|
||||
cwd = dataCwd;
|
||||
}
|
||||
const cwd = ws.data.sandboxed
|
||||
? resolveSandboxedCwd(email, msg.cwdRoot, msg.cwd)
|
||||
: resolveHostCwd(msg.cwdRoot, msg.cwd);
|
||||
|
||||
const groupSlug = msg.groupSlug || null;
|
||||
|
||||
const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug, msg.context, msg.contextId);
|
||||
session.sandboxed = sandboxed;
|
||||
session.userId = userId;
|
||||
sessionManager.attachWs(sessionId, ws);
|
||||
wsToSessionMap.set(ws as any, sessionId);
|
||||
|
||||
sendToClient(ws, { type: 'session:init', sessionId, model, cwd, context: session.meta.context, contextId: session.meta.contextId });
|
||||
sendToClient(ws, {
|
||||
type: 'session:init',
|
||||
sessionId,
|
||||
model,
|
||||
cwd,
|
||||
context: session.meta.context,
|
||||
contextId: session.meta.contextId,
|
||||
});
|
||||
|
||||
// Add user message to session
|
||||
const userMsg: Message = {
|
||||
@@ -416,7 +438,6 @@ async function handleClaudeCodeChat(
|
||||
prompt: msg.prompt,
|
||||
sessionKey: sessionId,
|
||||
cwd,
|
||||
sandboxed,
|
||||
onEvent,
|
||||
});
|
||||
|
||||
@@ -438,7 +459,7 @@ async function handleClaudeCodeChat(
|
||||
|
||||
async function handleResume(
|
||||
ws: ServerWebSocket<WSData>,
|
||||
msg: { sessionId: string; cwd?: string; cwdRoot?: string }
|
||||
msg: { sessionId: string; cwd?: string; cwdRoot?: string },
|
||||
): Promise<void> {
|
||||
const { email } = ws.data;
|
||||
const { sessionId } = msg;
|
||||
@@ -451,11 +472,11 @@ async function handleResume(
|
||||
|
||||
try {
|
||||
const { meta, messages } = await storage.loadSession(homeDir, sessionId);
|
||||
|
||||
|
||||
session = sessionManager.getOrCreate(sessionId, email, meta.cwd, meta.model);
|
||||
session.messages = messages;
|
||||
session.meta = meta;
|
||||
|
||||
|
||||
logger.info('Loaded session from disk', { sessionId, messageCount: messages.length });
|
||||
} catch (err) {
|
||||
logger.error('Failed to load session from disk', { sessionId, error: String(err) });
|
||||
@@ -467,33 +488,40 @@ async function handleResume(
|
||||
sessionManager.attachWs(sessionId, ws);
|
||||
wsToSessionMap.set(ws as any, sessionId);
|
||||
|
||||
sendToClient(ws, { type: 'session:init', sessionId, model: session.model, cwd: session.cwd, context: session.meta.context, contextId: session.meta.contextId });
|
||||
sendToClient(ws, {
|
||||
type: 'session:init',
|
||||
sessionId,
|
||||
model: session.model,
|
||||
cwd: session.cwd,
|
||||
context: session.meta.context,
|
||||
contextId: session.meta.contextId,
|
||||
});
|
||||
|
||||
// Spawn fresh Pi process if needed
|
||||
if (!session.piProcess) {
|
||||
try {
|
||||
const homeDir = getHomeDir(email);
|
||||
const sandbox = session.sandboxed && session.userId ? { userId: session.userId, username: ws.data.username, email, homeDir } : undefined;
|
||||
const onEvent = createEventHandler(sessionId, session.model, session.cwd, homeDir);
|
||||
|
||||
// If session has history, save to disk and pass --session for context replay
|
||||
let spawnOptions: { sessionFile?: string } | undefined;
|
||||
let spawnOptions: { sessionFile?: string; username?: string } | undefined;
|
||||
if (session.messages.length > 0) {
|
||||
await storage.saveSession(homeDir, sessionId, session.meta, session.messages);
|
||||
const hostPath = await storage.getSessionFilePath(homeDir, sessionId);
|
||||
if (hostPath) {
|
||||
if (sandbox) {
|
||||
const containerHome = `/home/${ws.data.username}`;
|
||||
const sessionsPrefix = join(homeDir, '.pi', 'agent', 'sessions');
|
||||
const relativePart = hostPath.slice(sessionsPrefix.length);
|
||||
spawnOptions = { sessionFile: `${containerHome}/.pi/agent/sessions${relativePart}` };
|
||||
} else {
|
||||
spawnOptions = { sessionFile: hostPath };
|
||||
}
|
||||
spawnOptions = { sessionFile: hostPath, username: ws.data.username };
|
||||
}
|
||||
}
|
||||
if (!spawnOptions) spawnOptions = { username: ws.data.username };
|
||||
|
||||
session.piProcess = await piBridge.spawnPi(session.cwd, session.model, session.userId!, email, onEvent, sandbox, spawnOptions);
|
||||
session.piProcess = await piBridge.spawnPi(
|
||||
session.cwd,
|
||||
session.model,
|
||||
session.userId!,
|
||||
email,
|
||||
onEvent,
|
||||
spawnOptions,
|
||||
);
|
||||
|
||||
// Null out piProcess when the process dies so next message triggers respawn
|
||||
const proc = session.piProcess;
|
||||
@@ -504,7 +532,11 @@ async function handleResume(
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('Spawned fresh Pi process for resumed session', { sessionId, model: session.model, sandboxed: session.sandboxed, hasSessionFile: !!spawnOptions?.sessionFile });
|
||||
logger.info('Spawned fresh Pi process for resumed session', {
|
||||
sessionId,
|
||||
model: session.model,
|
||||
hasSessionFile: !!spawnOptions?.sessionFile,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('Failed to spawn Pi process for resume', { sessionId, error: String(err) });
|
||||
sendToClient(ws, { type: 'error', message: 'Failed to start Pi process' });
|
||||
@@ -519,7 +551,7 @@ async function handleResume(
|
||||
isGenerating: session.isGenerating,
|
||||
streamingText: session.streamBuffer,
|
||||
});
|
||||
|
||||
|
||||
logger.info('Session resumed successfully', { sessionId, messageCount: session.messages.length });
|
||||
} catch (err) {
|
||||
logger.error('Unexpected error in handleResume', { sessionId, error: String(err) });
|
||||
@@ -536,7 +568,7 @@ async function handleStop(ws: ServerWebSocket<WSData>): Promise<void> {
|
||||
if (session?.piProcess) {
|
||||
try {
|
||||
if (session.model === 'claude-code') {
|
||||
// Claude Code: kill the docker exec process directly
|
||||
// Claude Code: kill the process directly
|
||||
session.piProcess.kill();
|
||||
logger.info('Killed Claude Code process', { sessionId });
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user