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:
2026-03-04 01:45:36 +00:00
parent 72d1341cbc
commit ef13f96d36
34 changed files with 2394 additions and 1466 deletions
+93 -61
View File
@@ -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 {