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
+165 -110
View File
@@ -31,21 +31,24 @@ type ProbeResult = {
};
type PiModelConfig = {
providers: Record<string, {
baseUrl: string;
apiKey?: string;
api: string;
models: {
id: string;
name: string;
reasoning: boolean;
input: string[];
contextWindow: number;
maxTokens: number;
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
}[];
_officer?: OfficerMeta;
}>;
providers: Record<
string,
{
baseUrl: string;
apiKey?: string;
api: string;
models: {
id: string;
name: string;
reasoning: boolean;
input: string[];
contextWindow: number;
maxTokens: number;
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
}[];
_officer?: OfficerMeta;
}
>;
};
type OfficerMeta = {
@@ -143,7 +146,7 @@ async function probeUrl(url: string, auth?: LocalProvider['auth']): Promise<Prob
if (oaiRes) {
if (oaiRes.status === 401 || oaiRes.status === 403) {
const wwwAuth = oaiRes.headers.get('www-authenticate') ?? '';
const authType = wwwAuth.toLowerCase().includes('basic') ? 'basic' as const : 'api-key' as const;
const authType = wwwAuth.toLowerCase().includes('basic') ? ('basic' as const) : ('api-key' as const);
return { success: true, apiType: 'openai-compatible', name: 'OpenAI-compatible', needsAuth: true, authType };
}
if (oaiRes.ok) {
@@ -152,7 +155,7 @@ async function probeUrl(url: string, auth?: LocalProvider['auth']): Promise<Prob
if (data.data) {
// LM Studio includes "lm-studio" in model IDs
const isLmStudio = data.data.some((m) => m.id.includes('lm-studio'));
const apiType = isLmStudio ? 'lmstudio' as const : 'openai-compatible' as const;
const apiType = isLmStudio ? ('lmstudio' as const) : ('openai-compatible' as const);
const name = isLmStudio ? 'LM Studio' : 'OpenAI-compatible';
return {
success: true,
@@ -172,7 +175,13 @@ async function probeUrl(url: string, auth?: LocalProvider['auth']): Promise<Prob
const bareRes = await tryFetch('/models');
if (bareRes) {
if (bareRes.status === 401 || bareRes.status === 403) {
return { success: true, apiType: 'openai-compatible', name: 'OpenAI-compatible', needsAuth: true, authType: 'api-key' };
return {
success: true,
apiType: 'openai-compatible',
name: 'OpenAI-compatible',
needsAuth: true,
authType: 'api-key',
};
}
if (bareRes.ok) {
try {
@@ -212,7 +221,8 @@ async function fetchModelsFromProvider(
const headers: Record<string, string> = {};
if (lp.auth?.type === 'api-key') headers['Authorization'] = `Bearer ${lp.auth.apiKey}`;
else if (lp.auth?.type === 'basic') headers['Authorization'] = `Basic ${btoa(`${lp.auth.username}:${lp.auth.password}`)}`;
else if (lp.auth?.type === 'basic')
headers['Authorization'] = `Basic ${btoa(`${lp.auth.username}:${lp.auth.password}`)}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
@@ -223,7 +233,12 @@ async function fetchModelsFromProvider(
const data = await res.json();
if (lp.apiType === 'ollama' && data.models) {
return data.models.map((m: { name: string }) => ({ id: m.name, name: m.name, contextWindow: 128000, maxTokens: 4096 }));
return data.models.map((m: { name: string }) => ({
id: m.name,
name: m.name,
contextWindow: 128000,
maxTokens: 4096,
}));
} else if (data.data) {
return data.data.map((m: { id: string }) => ({ id: m.id, name: m.id, contextWindow: 128000, maxTokens: 4096 }));
}
@@ -242,9 +257,7 @@ async function addLocalProviderToModelsConfig(lp: LocalProvider): Promise<void>
logger.warn('No models found for local provider', { provider: lp.name });
}
const baseUrl = lp.apiType === 'ollama'
? `${lp.url}/v1`
: lp.url.endsWith('/v1') ? lp.url : `${lp.url}/v1`;
const baseUrl = lp.apiType === 'ollama' ? `${lp.url}/v1` : lp.url.endsWith('/v1') ? lp.url : `${lp.url}/v1`;
const config = await readModelsConfig();
config.providers[`officer-local-${lp.id}`] = {
@@ -297,9 +310,7 @@ async function refreshLocalProviders(): Promise<void> {
};
const models = await fetchModelsFromProvider(lp);
const baseUrl = lp.apiType === 'ollama'
? `${lp.url}/v1`
: lp.url.endsWith('/v1') ? lp.url : `${lp.url}/v1`;
const baseUrl = lp.apiType === 'ollama' ? `${lp.url}/v1` : lp.url.endsWith('/v1') ? lp.url : `${lp.url}/v1`;
entry.baseUrl = baseUrl;
entry.apiKey = lp.auth?.type === 'api-key' ? lp.auth.apiKey : 'none';
@@ -350,8 +361,8 @@ export const PROVIDERS: { key: string; piId: string }[] = [
{ key: 'MiniMax', piId: 'minimax' },
{ key: 'Hugging Face', piId: 'huggingface' },
{ key: 'Azure OpenAI', piId: 'azure-openai-responses' },
{ key: 'OpenCode', piId: 'opencode' },
{ key: 'OpenCode Zen', piId: 'zai' },
{ key: 'OpenCode Zen', piId: 'opencode' },
{ key: 'ZAI', piId: 'zai' },
{ key: 'Cerebras', piId: 'cerebras' },
];
@@ -368,9 +379,10 @@ const maskValue = (value: string) => {
piMonoRouter.get('/api-keys', async (ctx) => {
const auth = await readAuthJson();
const keys = PROVIDERS
.filter((p) => auth[p.piId]?.key?.trim())
.map((p) => ({ provider: p.piId, value: maskValue(auth[p.piId]!.key) }));
const keys = PROVIDERS.filter((p) => auth[p.piId]?.key?.trim()).map((p) => ({
provider: p.piId,
value: maskValue(auth[p.piId]!.key),
}));
return ctx.json({ keys });
});
@@ -403,81 +415,114 @@ piMonoRouter.put('/access-policy', async (ctx) => {
return ctx.json(body);
});
const REMOTE_HEALTH_CONFIG: Record<string, {
url: string | ((key: string) => string);
headers: (key: string) => Record<string, string>;
}> = {
const REMOTE_HEALTH_CONFIG: Record<
string,
{
url: string | ((key: string) => string);
headers: (key: string) => Record<string, string>;
}
> = {
openai: { url: 'https://api.openai.com/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
anthropic: { url: 'https://api.anthropic.com/v1/models', headers: (k) => ({ 'x-api-key': k, 'anthropic-version': '2023-06-01' }) },
anthropic: {
url: 'https://api.anthropic.com/v1/models',
headers: (k) => ({ 'x-api-key': k, 'anthropic-version': '2023-06-01' }),
},
google: { url: (k) => `https://generativelanguage.googleapis.com/v1beta/models?key=${k}`, headers: () => ({}) },
groq: { url: 'https://api.groq.com/openai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
mistral: { url: 'https://api.mistral.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
xai: { url: 'https://api.x.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
openrouter: { url: 'https://openrouter.ai/api/v1/auth/key', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
cerebras: { url: 'https://api.cerebras.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
opencode: { url: 'https://opencode.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
zai: { url: 'https://opencode.ai/zen/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
opencode: { url: 'https://opencode.ai/zen/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
zai: { url: 'https://opencode.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) },
};
piMonoRouter.get('/api-keys/health', async (ctx) => {
const auth = await readAuthJson();
const results: Record<string, boolean | null> = {};
const checks = PROVIDERS
.filter((p) => auth[p.piId]?.key?.trim())
.map(async (p) => {
const config = REMOTE_HEALTH_CONFIG[p.piId];
if (!config) {
results[p.piId] = null;
return;
}
const key = auth[p.piId]!.key;
const url = typeof config.url === 'function' ? config.url(key) : config.url;
const headers = config.headers(key);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3000);
try {
const res = await fetch(url, { headers, signal: controller.signal });
results[p.piId] = res.ok;
} catch {
results[p.piId] = false;
} finally {
clearTimeout(timer);
}
});
const checks = PROVIDERS.filter((p) => auth[p.piId]?.key?.trim()).map(async (p) => {
const config = REMOTE_HEALTH_CONFIG[p.piId];
if (!config) {
results[p.piId] = null;
return;
}
const key = auth[p.piId]!.key;
const url = typeof config.url === 'function' ? config.url(key) : config.url;
const headers = config.headers(key);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3000);
try {
const res = await fetch(url, { headers, signal: controller.signal });
results[p.piId] = res.ok;
} catch {
results[p.piId] = false;
} finally {
clearTimeout(timer);
}
});
await Promise.all(checks);
return ctx.json(results);
});
const GLOBAL_DIRS = ['/usr/local/bin', '/usr/bin'];
/**
* Resolve the full path to the `pi` binary.
* Checks PATH first, then falls back to the npm global bin directory
* (which may not be in PATH when the server is managed by pm2).
*/
/**
* Finds the Pi package directory (containing package.json).
* Checks: PATH → npm global prefix → ~/.npm-global fallback.
*/
async function resolvePiPackageDir(): Promise<string | null> {
const candidates: string[] = [];
const getPaths = async () => {
// 1. Try PATH
try {
const proc = Bun.spawn(['which', '-a', 'pi'], { stdout: 'pipe', stderr: 'pipe' });
const output = await new Response(proc.stdout).text();
const proc = Bun.spawn(['which', 'pi'], { stdout: 'pipe', stderr: 'pipe' });
const output = (await new Response(proc.stdout).text()).trim();
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 };
if (proc.exitCode === 0 && output) {
// Resolve symlink: bin/pi -> ../lib/node_modules/.../dist/cli.js
const resolved = (await Bun.file(output).exists()) ? output : null;
if (resolved) {
// Walk up from bin to find the package
const npmGlobalLib = join(output, '..', '..', 'lib', 'node_modules', '@mariozechner', 'pi-coding-agent');
candidates.push(npmGlobalLib);
}
}
} catch {}
// 2. Common locations
const home = process.env.HOME ?? '';
candidates.push(
join(home, '.npm-global', 'lib', 'node_modules', '@mariozechner', 'pi-coding-agent'),
'/usr/local/lib/node_modules/@mariozechner/pi-coding-agent',
);
for (const dir of candidates) {
const pkgFile = join(dir, 'package.json');
if (await Bun.file(pkgFile).exists()) return dir;
}
};
return null;
}
/** Read Pi version directly from its package.json — avoids shebang/spawn issues. */
async function getPiVersion(): Promise<{ version: string | null; path: string | null }> {
const dir = await resolvePiPackageDir();
if (!dir) return { version: null, path: null };
try {
const pkg = await Bun.file(join(dir, 'package.json')).json();
return { version: pkg.version ?? null, path: dir };
} catch {
return { version: null, path: dir };
}
}
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 });
}
const { version, path } = await getPiVersion();
return ctx.json({ version, path, globalPath: path });
});
piMonoRouter.post('/install', async (ctx) => {
@@ -486,16 +531,16 @@ piMonoRouter.post('/install', async (ctx) => {
stdout: 'pipe',
stderr: 'pipe',
});
const stderr = await new Response(proc.stderr).text();
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 });
const { version, path } = await getPiVersion();
if (!version)
return ctx.json({ version: null, path, globalPath: path, error: 'Installed but package.json not found' }, 500);
return ctx.json({ version, path, globalPath: path });
} catch {
return ctx.json({ version: null, path: null, globalPath: null, error: 'Installation failed' }, 500);
}
@@ -505,10 +550,12 @@ piMonoRouter.post('/install', async (ctx) => {
piMonoRouter.get('/local-providers', async (ctx) => {
const providers = await readLocalProviders();
return ctx.json(providers.map((p) => ({
...p,
auth: p.auth ? { type: p.auth.type } : undefined,
})));
return ctx.json(
providers.map((p) => ({
...p,
auth: p.auth ? { type: p.auth.type } : undefined,
})),
);
});
piMonoRouter.post('/local-providers/probe', async (ctx) => {
@@ -519,7 +566,12 @@ piMonoRouter.post('/local-providers/probe', async (ctx) => {
});
piMonoRouter.post('/local-providers', async (ctx) => {
const body = await ctx.req.json<{ url: string; name?: string; apiType: LocalProvider['apiType']; auth?: LocalProvider['auth'] }>();
const body = await ctx.req.json<{
url: string;
name?: string;
apiType: LocalProvider['apiType'];
auth?: LocalProvider['auth'];
}>();
const provider: LocalProvider = {
id: crypto.randomUUID(),
@@ -549,24 +601,27 @@ piMonoRouter.get('/local-providers/health', async (ctx) => {
const providers = await readLocalProviders();
const results: Record<string, boolean> = {};
await Promise.all(providers.map(async (p) => {
const base = p.url.replace(/\/+$/, '');
const path = p.apiType === 'ollama' ? '/api/tags' : '/v1/models';
const headers: Record<string, string> = {};
if (p.auth?.type === 'api-key') headers['Authorization'] = `Bearer ${p.auth.apiKey}`;
else if (p.auth?.type === 'basic') headers['Authorization'] = `Basic ${btoa(`${p.auth.username}:${p.auth.password}`)}`;
await Promise.all(
providers.map(async (p) => {
const base = p.url.replace(/\/+$/, '');
const path = p.apiType === 'ollama' ? '/api/tags' : '/v1/models';
const headers: Record<string, string> = {};
if (p.auth?.type === 'api-key') headers['Authorization'] = `Bearer ${p.auth.apiKey}`;
else if (p.auth?.type === 'basic')
headers['Authorization'] = `Basic ${btoa(`${p.auth.username}:${p.auth.password}`)}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3000);
try {
const res = await fetch(`${base}${path}`, { headers, signal: controller.signal });
results[p.id] = res.ok;
} catch {
results[p.id] = false;
} finally {
clearTimeout(timer);
}
}));
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3000);
try {
const res = await fetch(`${base}${path}`, { headers, signal: controller.signal });
results[p.id] = res.ok;
} catch {
results[p.id] = false;
} finally {
clearTimeout(timer);
}
}),
);
return ctx.json(results);
});