diff --git a/docs/plans/2026-03-09-doctor-health-check-design.md b/docs/plans/2026-03-09-doctor-health-check-design.md new file mode 100644 index 000000000..80099b29d --- /dev/null +++ b/docs/plans/2026-03-09-doctor-health-check-design.md @@ -0,0 +1,47 @@ +# Paseo Doctor / Health Check + +## Problem + +Users need a way to diagnose their Paseo setup: are agent binaries installed, what versions are running, is the config valid? Currently there's no unified diagnostic — errors only surface when you try to launch an agent and it fails. + +## Decision: Shared library + HTTP endpoint + +The check logic lives as a pure module in `packages/server` (no daemon dependency). It inspects the local filesystem and runs `which`/`--version` commands. + +- **Daemon** exposes it via `GET /api/doctor` (stateless, no WS session needed). +- **CLI** imports the module directly for offline use (`paseo doctor`), or calls the HTTP endpoint with `--remote`. +- **App** calls the HTTP endpoint from the settings screen. + +## Data Model + +```typescript +type CheckStatus = "ok" | "warn" | "error"; + +interface DoctorCheckResult { + id: string; // e.g. "provider.claude.binary" + label: string; // e.g. "Claude CLI" + status: CheckStatus; + detail: string; // e.g. "/usr/local/bin/claude (v1.0.42)" +} + +interface DoctorReport { + checks: DoctorCheckResult[]; + summary: { ok: number; warn: number; error: number }; + timestamp: string; +} +``` + +## Checks + +| ID | What it checks | ok | warn | error | +|---|---|---|---|---| +| provider.claude.binary | which claude | Found + path | — | Not found | +| provider.claude.version | claude --version | Version string | Parse failed | Binary missing | +| provider.codex.binary | which codex | Found + path | — | Not found | +| provider.codex.version | codex --version | Version string | Parse failed | Binary missing | +| provider.opencode.binary | which opencode | Found + path | — | Not found | +| provider.opencode.version | opencode --version | Version string | Parse failed | Binary missing | +| config.valid | Parse config.json | Valid | — | Parse/schema error | +| config.listen | Listen address format | Valid | — | Malformed | +| runtime.node | Node.js version | Version | — | Not found | +| runtime.paseo | Paseo daemon version | Version | — | Unknown | diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 162a7b02d..781715252 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -17,6 +17,8 @@ import { useAppSettings, type AppSettings } from "@/hooks/use-settings"; import { useDaemonRegistry, type HostProfile, type HostConnection } from "@/contexts/daemon-registry-context"; import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons"; import { confirmDialog } from "@/utils/confirm-dialog"; +import { buildDaemonWebSocketUrl } from "@/utils/daemon-endpoints"; +import type { DoctorReport } from "@server/server/doctor/types"; import { MenuHeader } from "@/components/headers/menu-header"; import { useSessionStore } from "@/stores/session-store"; import { @@ -980,6 +982,9 @@ function HostDetailModal({ const activeConnection = runtimeSnapshot?.activeConnection ?? null; const lastError = runtimeSnapshot?.lastError ?? null; const [isRestarting, setIsRestarting] = useState(false); + const [doctorReport, setDoctorReport] = useState(null); + const [doctorLoading, setDoctorLoading] = useState(false); + const [doctorError, setDoctorError] = useState(null); const isHostConnected = useCallback(() => { if (!host) { return false; @@ -1093,6 +1098,30 @@ function HostDetailModal({ setDraftLabel(nextValue); }, []); + const runHealthCheck = useCallback(async () => { + const endpoint = + activeConnection?.endpoint ?? + host?.connections.find((c) => c.type === "directTcp")?.endpoint ?? + null; + if (!endpoint) return; + + setDoctorLoading(true); + setDoctorError(null); + try { + const parsed = new URL(buildDaemonWebSocketUrl(endpoint)); + parsed.protocol = parsed.protocol === "wss:" ? "https:" : "http:"; + const baseUrl = parsed.toString().replace(/\/ws\/?$/, ""); + const res = await fetch(`${baseUrl}/api/doctor`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const report = (await res.json()) as DoctorReport; + setDoctorReport(report); + } catch (err) { + setDoctorError(err instanceof Error ? err.message : "Health check failed"); + } finally { + setDoctorLoading(false); + } + }, [activeConnection, host]); + useEffect(() => { if (!visible || !host) return; // Initialize once per modal open / host switch; keep user edits fully local while typing. @@ -1103,6 +1132,9 @@ function HostDetailModal({ if (!visible) { setIsRestarting(false); setDraftLabel(""); + setDoctorReport(null); + setDoctorLoading(false); + setDoctorError(null); } }, [visible]); @@ -1186,6 +1218,76 @@ function HostDetailModal({ ) : null} + {/* Diagnostics */} + {host ? ( + + Diagnostics + + {doctorError ? ( + + {doctorError} + + ) : null} + {doctorReport ? ( + + {/* Summary */} + + {doctorReport.summary.ok > 0 ? ( + {doctorReport.summary.ok} passed + ) : null} + {doctorReport.summary.ok > 0 && (doctorReport.summary.warn > 0 || doctorReport.summary.error > 0) ? " · " : ""} + {doctorReport.summary.warn > 0 ? ( + {doctorReport.summary.warn} warning{doctorReport.summary.warn !== 1 ? "s" : ""} + ) : null} + {doctorReport.summary.warn > 0 && doctorReport.summary.error > 0 ? " · " : ""} + {doctorReport.summary.error > 0 ? ( + {doctorReport.summary.error} error{doctorReport.summary.error !== 1 ? "s" : ""} + ) : null} + + {/* Grouped checks */} + {(["provider", "config", "runtime"] as const).map((prefix) => { + const groupChecks = doctorReport.checks.filter((c) => c.id.startsWith(`${prefix}.`)); + if (groupChecks.length === 0) return null; + const groupLabel = prefix === "provider" ? "Providers" : prefix === "config" ? "Config" : "Runtime"; + return ( + + + {groupLabel} + + {groupChecks.map((check) => ( + + + {check.status === "ok" ? "✓" : check.status === "warn" ? "⚠" : "✗"} + + + {check.label} + {check.detail} + + + ))} + + ); + })} + + ) : null} + + ) : null} + {/* Save/Cancel + Advanced */} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 2bb8e2095..9a6d55ae4 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -19,6 +19,7 @@ import { runSendCommand } from './commands/agent/send.js' import { runInspectCommand } from './commands/agent/inspect.js' import { runWaitCommand } from './commands/agent/wait.js' import { runAttachCommand } from './commands/agent/attach.js' +import { runDoctorCommand } from './commands/doctor.js' import { withOutput } from './output/index.js' import { onboardCommand } from './commands/onboard.js' @@ -192,6 +193,14 @@ export function createCli(): Command { ) .action(withOutput(runDaemonRestartCommand)) + program + .command('doctor') + .description('Diagnose your Paseo setup (agents, config, runtime)') + .option('--remote', 'Fetch diagnostics from the running daemon instead of checking locally') + .option('--json', 'Output in JSON format') + .option('--host ', 'Daemon host target (used with --remote)') + .action(withOutput(runDoctorCommand)) + // Advanced agent commands (less common operations) program.addCommand(createAgentCommand()) diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts new file mode 100644 index 000000000..8dab85ca8 --- /dev/null +++ b/packages/cli/src/commands/doctor.ts @@ -0,0 +1,97 @@ +import type { Command } from 'commander' +import { + runDoctorChecks, + type DoctorCheckResult, + type DoctorReport, +} from '@getpaseo/server' +import { getDaemonHost, resolveDaemonTarget } from '../utils/client.js' +import type { CommandOptions, ListResult, OutputSchema } from '../output/index.js' + +interface DoctorRow { + check: string + status: string + detail: string +} + +function statusIndicator(status: DoctorCheckResult['status']): string { + switch (status) { + case 'ok': + return '✓ ok' + case 'warn': + return '⚠ warn' + case 'error': + return '✗ error' + } +} + +function toDoctorRows(report: DoctorReport): DoctorRow[] { + return report.checks.map((c) => ({ + check: c.label, + status: statusIndicator(c.status), + detail: c.detail, + })) +} + +function createDoctorSchema(report: DoctorReport): OutputSchema { + return { + idField: 'check', + columns: [ + { header: 'CHECK', field: 'check' }, + { + header: 'STATUS', + field: 'status', + color: (value) => { + const v = typeof value === 'string' ? value : '' + if (v.includes('ok')) return 'green' + if (v.includes('warn')) return 'yellow' + if (v.includes('error')) return 'red' + return undefined + }, + }, + { header: 'DETAIL', field: 'detail' }, + ], + serialize: () => report, + } +} + +async function fetchRemoteReport(host: string): Promise { + const target = resolveDaemonTarget(host) + const baseUrl = + target.type === 'tcp' + ? target.url.replace(/^ws:\/\//, 'http://').replace(/\/ws$/, '') + : null + + if (!baseUrl) { + throw new Error('Remote doctor requires a TCP daemon target (not unix socket)') + } + + const response = await fetch(`${baseUrl}/api/doctor`) + if (!response.ok) { + const text = await response.text().catch(() => '') + throw new Error(`Doctor endpoint returned ${response.status}: ${text}`) + } + return (await response.json()) as DoctorReport +} + +export type DoctorResult = ListResult + +export async function runDoctorCommand( + options: CommandOptions, + _command: Command +): Promise { + const remote = Boolean(options.remote) + + let report: DoctorReport + if (remote) { + const host = getDaemonHost({ host: options.host as string | undefined }) + report = await fetchRemoteReport(host) + } else { + report = await runDoctorChecks() + } + + return { + type: 'list', + data: toDoctorRows(report), + schema: createDoctorSchema(report), + } +} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index eddfc337e..83284ec88 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -71,6 +71,7 @@ import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js"; import { startRelayTransport, type RelayTransportController } from "./relay-transport.js"; import { getOrCreateServerId } from "./server-id.js"; import { resolveDaemonVersion } from "./daemon-version.js"; +import { runDoctorChecks } from "./doctor/index.js"; import type { AgentClient, AgentProvider, @@ -248,6 +249,20 @@ export async function createPaseoDaemon( res.json({ status: "ok", timestamp: new Date().toISOString() }); }); + // Doctor diagnostic endpoint + app.get("/api/doctor", async (_req, res) => { + try { + const report = await runDoctorChecks({ + paseoHome: config.paseoHome, + version: daemonVersion, + }); + res.json(report); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + res.status(500).json({ error: message }); + } + }); + app.get("/api/files/download", async (req, res) => { const token = typeof req.query.token === "string" && req.query.token.trim().length > 0 diff --git a/packages/server/src/server/doctor/checks/config-checks.ts b/packages/server/src/server/doctor/checks/config-checks.ts new file mode 100644 index 000000000..8072c6e52 --- /dev/null +++ b/packages/server/src/server/doctor/checks/config-checks.ts @@ -0,0 +1,77 @@ +import { loadPersistedConfig, type PersistedConfig } from "../../persisted-config.js"; +import type { DoctorCheckResult } from "../types.js"; + +/** + * Validate that a listen string is parseable as a valid listen target. + * Inline check to avoid importing from bootstrap.ts (which has heavy transitive deps). + */ +function isValidListenString(listen: string): boolean { + // Named pipe + if (listen.startsWith("\\\\.\\pipe\\") || listen.startsWith("pipe://")) return true; + // Unix socket + if (listen.startsWith("/") || listen.startsWith("~") || listen.includes(".sock")) return true; + if (listen.startsWith("unix://")) return true; + // TCP host:port + if (listen.includes(":")) { + const port = parseInt(listen.split(":")[1]!, 10); + return Number.isFinite(port); + } + // Just a port + return Number.isFinite(parseInt(listen, 10)); +} + +function checkConfigValid(config: PersistedConfig | null, loadError: string | null): DoctorCheckResult { + if (config) { + return { + id: "config.valid", + label: "Config file", + status: "ok", + detail: "Valid", + }; + } + return { + id: "config.valid", + label: "Config file", + status: "error", + detail: loadError ?? "Unknown error", + }; +} + +function checkListenAddress(config: PersistedConfig | null): DoctorCheckResult { + if (!config) { + return { + id: "config.listen", + label: "Listen address", + status: "error", + detail: "Cannot check (config failed to load)", + }; + } + + const listen = config.daemon?.listen ?? "127.0.0.1:6767"; + if (!isValidListenString(listen)) { + return { + id: "config.listen", + label: "Listen address", + status: "error", + detail: `Malformed listen address: ${listen}`, + }; + } + return { + id: "config.listen", + label: "Listen address", + status: "ok", + detail: listen, + }; +} + +export async function runConfigChecks(paseoHome: string): Promise { + let config: PersistedConfig | null = null; + let loadError: string | null = null; + try { + config = loadPersistedConfig(paseoHome); + } catch (err) { + loadError = err instanceof Error ? err.message : String(err); + } + + return [checkConfigValid(config, loadError), checkListenAddress(config)]; +} diff --git a/packages/server/src/server/doctor/checks/provider-checks.ts b/packages/server/src/server/doctor/checks/provider-checks.ts new file mode 100644 index 000000000..c9d0a743e --- /dev/null +++ b/packages/server/src/server/doctor/checks/provider-checks.ts @@ -0,0 +1,94 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +import type { DoctorCheckResult } from "../types.js"; + +const execFileAsync = promisify(execFile); + +interface ProviderDef { + name: string; + command: string; + label: string; +} + +const PROVIDERS: ProviderDef[] = [ + { name: "claude", command: "claude", label: "Claude CLI" }, + { name: "codex", command: "codex", label: "Codex CLI" }, + { name: "opencode", command: "opencode", label: "OpenCode CLI" }, +]; + +const EXEC_TIMEOUT_MS = 5000; + +async function whichCommand(command: string): Promise { + const whichBin = process.platform === "win32" ? "where" : "which"; + try { + const { stdout } = await execFileAsync(whichBin, [command], { encoding: "utf8", timeout: EXEC_TIMEOUT_MS }); + return stdout.trim() || null; + } catch { + return null; + } +} + +async function getVersion(binaryPath: string): Promise { + try { + const { stdout } = await execFileAsync(binaryPath, ["--version"], { encoding: "utf8", timeout: EXEC_TIMEOUT_MS }); + return stdout.trim() || null; + } catch { + return null; + } +} + +function checkBinary(provider: ProviderDef, binaryPath: string | null): DoctorCheckResult { + if (binaryPath) { + return { + id: `provider.${provider.name}.binary`, + label: provider.label, + status: "ok", + detail: binaryPath, + }; + } + return { + id: `provider.${provider.name}.binary`, + label: provider.label, + status: "error", + detail: "Not found in PATH", + }; +} + +async function checkVersion(provider: ProviderDef, binaryPath: string | null): Promise { + if (!binaryPath) { + return { + id: `provider.${provider.name}.version`, + label: `${provider.label} version`, + status: "error", + detail: "Binary not found", + }; + } + + const version = await getVersion(binaryPath); + if (version) { + return { + id: `provider.${provider.name}.version`, + label: `${provider.label} version`, + status: "ok", + detail: version, + }; + } + + return { + id: `provider.${provider.name}.version`, + label: `${provider.label} version`, + status: "warn", + detail: "Installed but version could not be parsed", + }; +} + +async function checkProvider(provider: ProviderDef): Promise { + const binaryPath = await whichCommand(provider.command); + return [checkBinary(provider, binaryPath), await checkVersion(provider, binaryPath)]; +} + +export async function runProviderChecks(): Promise { + const perProvider = await Promise.all(PROVIDERS.map(checkProvider)); + return perProvider.flat(); +} diff --git a/packages/server/src/server/doctor/checks/runtime-checks.ts b/packages/server/src/server/doctor/checks/runtime-checks.ts new file mode 100644 index 000000000..a82bdef89 --- /dev/null +++ b/packages/server/src/server/doctor/checks/runtime-checks.ts @@ -0,0 +1,43 @@ +import { resolveDaemonVersion } from "../../daemon-version.js"; +import type { DoctorCheckResult } from "../types.js"; + +function checkNodeVersion(): DoctorCheckResult { + return { + id: "runtime.node", + label: "Node.js", + status: "ok", + detail: process.version, + }; +} + +function checkPaseoVersion(version?: string): DoctorCheckResult { + const resolved = version ?? tryResolveDaemonVersion(); + if (resolved) { + return { + id: "runtime.paseo", + label: "Paseo daemon", + status: "ok", + detail: resolved, + }; + } + return { + id: "runtime.paseo", + label: "Paseo daemon", + status: "error", + detail: "Version unknown", + }; +} + +function tryResolveDaemonVersion(): string | null { + try { + return resolveDaemonVersion(); + } catch { + return null; + } +} + +export async function runRuntimeChecks(options?: { + version?: string; +}): Promise { + return [checkNodeVersion(), checkPaseoVersion(options?.version)]; +} diff --git a/packages/server/src/server/doctor/index.ts b/packages/server/src/server/doctor/index.ts new file mode 100644 index 000000000..25bc33474 --- /dev/null +++ b/packages/server/src/server/doctor/index.ts @@ -0,0 +1,2 @@ +export { runDoctorChecks } from "./run-doctor-checks.js"; +export type { CheckStatus, DoctorCheckResult, DoctorReport } from "./types.js"; diff --git a/packages/server/src/server/doctor/run-doctor-checks.test.ts b/packages/server/src/server/doctor/run-doctor-checks.test.ts new file mode 100644 index 000000000..3bfd6b567 --- /dev/null +++ b/packages/server/src/server/doctor/run-doctor-checks.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from "vitest"; +import { runDoctorChecks } from "./run-doctor-checks.js"; +import type { DoctorReport, DoctorCheckResult } from "./types.js"; + +describe("runDoctorChecks", () => { + it("returns a valid DoctorReport shape", async () => { + const report = await runDoctorChecks(); + + expect(report).toHaveProperty("checks"); + expect(report).toHaveProperty("summary"); + expect(report).toHaveProperty("timestamp"); + expect(Array.isArray(report.checks)).toBe(true); + }); + + it("has summary counts matching checks array", async () => { + const report = await runDoctorChecks(); + + const okCount = report.checks.filter((c) => c.status === "ok").length; + const warnCount = report.checks.filter((c) => c.status === "warn").length; + const errorCount = report.checks.filter((c) => c.status === "error").length; + + expect(report.summary.ok).toBe(okCount); + expect(report.summary.warn).toBe(warnCount); + expect(report.summary.error).toBe(errorCount); + expect(okCount + warnCount + errorCount).toBe(report.checks.length); + }); + + it("has a valid ISO timestamp", async () => { + const report = await runDoctorChecks(); + const parsed = new Date(report.timestamp); + expect(parsed.toISOString()).toBe(report.timestamp); + }); + + it("each check has the expected shape", async () => { + const report = await runDoctorChecks(); + + for (const check of report.checks) { + expect(typeof check.id).toBe("string"); + expect(check.id.length).toBeGreaterThan(0); + expect(typeof check.label).toBe("string"); + expect(check.label.length).toBeGreaterThan(0); + expect(["ok", "warn", "error"]).toContain(check.status); + expect(typeof check.detail).toBe("string"); + expect(check.detail.length).toBeGreaterThan(0); + } + }); + + it("includes expected check IDs", async () => { + const report = await runDoctorChecks(); + const ids = report.checks.map((c) => c.id); + + // Provider checks + expect(ids).toContain("provider.claude.binary"); + expect(ids).toContain("provider.claude.version"); + expect(ids).toContain("provider.codex.binary"); + expect(ids).toContain("provider.codex.version"); + expect(ids).toContain("provider.opencode.binary"); + expect(ids).toContain("provider.opencode.version"); + + // Config checks + expect(ids).toContain("config.valid"); + expect(ids).toContain("config.listen"); + + // Runtime checks + expect(ids).toContain("runtime.node"); + expect(ids).toContain("runtime.paseo"); + }); + + it("runtime.node reports the current Node version", async () => { + const report = await runDoctorChecks(); + const nodeCheck = report.checks.find((c) => c.id === "runtime.node"); + + expect(nodeCheck).toBeDefined(); + expect(nodeCheck!.status).toBe("ok"); + expect(nodeCheck!.detail).toBe(process.version); + }); + + it("accepts a custom version option", async () => { + const report = await runDoctorChecks({ version: "1.2.3-test" }); + const paseoCheck = report.checks.find((c) => c.id === "runtime.paseo"); + + expect(paseoCheck).toBeDefined(); + expect(paseoCheck!.status).toBe("ok"); + expect(paseoCheck!.detail).toBe("1.2.3-test"); + }); +}); diff --git a/packages/server/src/server/doctor/run-doctor-checks.ts b/packages/server/src/server/doctor/run-doctor-checks.ts new file mode 100644 index 000000000..8b8e7a1a1 --- /dev/null +++ b/packages/server/src/server/doctor/run-doctor-checks.ts @@ -0,0 +1,26 @@ +import { resolvePaseoHome } from "../paseo-home.js"; +import { runProviderChecks } from "./checks/provider-checks.js"; +import { runConfigChecks } from "./checks/config-checks.js"; +import { runRuntimeChecks } from "./checks/runtime-checks.js"; +import type { DoctorReport } from "./types.js"; + +export async function runDoctorChecks(options?: { + paseoHome?: string; + version?: string; +}): Promise { + const paseoHome = options?.paseoHome ?? resolvePaseoHome(); + + const checks = [ + ...(await runProviderChecks()), + ...(await runConfigChecks(paseoHome)), + ...(await runRuntimeChecks({ version: options?.version })), + ]; + + const summary = { + ok: checks.filter((c) => c.status === "ok").length, + warn: checks.filter((c) => c.status === "warn").length, + error: checks.filter((c) => c.status === "error").length, + }; + + return { checks, summary, timestamp: new Date().toISOString() }; +} diff --git a/packages/server/src/server/doctor/types.ts b/packages/server/src/server/doctor/types.ts new file mode 100644 index 000000000..86b5be4dd --- /dev/null +++ b/packages/server/src/server/doctor/types.ts @@ -0,0 +1,14 @@ +export type CheckStatus = "ok" | "warn" | "error"; + +export interface DoctorCheckResult { + id: string; + label: string; + status: CheckStatus; + detail: string; +} + +export interface DoctorReport { + checks: DoctorCheckResult[]; + summary: { ok: number; warn: number; error: number }; + timestamp: string; +} diff --git a/packages/server/src/server/exports.ts b/packages/server/src/server/exports.ts index dee16d888..88dedef55 100644 --- a/packages/server/src/server/exports.ts +++ b/packages/server/src/server/exports.ts @@ -23,6 +23,14 @@ export { type SherpaLoaderEnvResolution, } from "./speech/providers/local/sherpa/sherpa-runtime-env.js"; +// Doctor health check +export { + runDoctorChecks, + type CheckStatus, + type DoctorCheckResult, + type DoctorReport, +} from "./doctor/index.js"; + // Agent SDK types for CLI commands export type { AgentMode,