Compare commits

...
Author SHA1 Message Date
Zi Makki de7960ec60 fix: show full health check results table, not just summary
The check IDs use dots (provider.claude.binary) not dashes, so the
grouping filter never matched and only the summary line was visible.
Fixed the prefix matching and made detail text selectable so users
can copy paths (e.g., Claude binary location).
2026-03-09 13:46:37 +01:00
Zi Makki 90c37e2f95 feat: add diagnostics section to HostDetailModal
Add a "Diagnostics" section that lets users run a health check against
their connected daemon via GET /api/doctor. Displays grouped check
results with status icons and a summary line.
2026-03-09 13:40:00 +01:00
Zi Makki 59e0b2ec62 fix: address code review feedback for doctor health check
- Fix TypeScript error: extract host field for getDaemonHost call
- Use async execFile + Promise.all for parallel provider checks
- Load config once in runConfigChecks instead of twice
2026-03-09 13:08:15 +01:00
Zi Makki 15d7763d0b fix: address doctor health check code review findings 2026-03-09 12:55:44 +01:00
Zi Makki 3a4b463deb feat: add paseo doctor health check (server + CLI)
Adds a unified diagnostic system for Paseo setup:
- Doctor module in packages/server with provider, config, and runtime checks
- GET /api/doctor HTTP endpoint on the daemon
- `paseo doctor` CLI command (local checks by default, --remote for daemon)
- Tests for the doctor report shape and summary correctness
2026-03-09 12:51:40 +01:00
Zi Makki bb737dea52 docs: add doctor health check design doc 2026-03-09 12:44:23 +01:00
13 changed files with 620 additions and 0 deletions
@@ -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 |
@@ -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<DoctorReport | null>(null);
const [doctorLoading, setDoctorLoading] = useState(false);
const [doctorError, setDoctorError] = useState<string | null>(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({
</View>
) : null}
{/* Diagnostics */}
{host ? (
<View style={styles.formField}>
<Text style={styles.label}>Diagnostics</Text>
<Button
variant="secondary"
size="sm"
onPress={() => { void runHealthCheck(); }}
disabled={doctorLoading || !isConnected}
>
{doctorLoading ? "Running..." : doctorReport ? "Re-run" : "Run Health Check"}
</Button>
{doctorError ? (
<Text style={{ color: theme.colors.destructive, fontSize: theme.fontSize.xs, marginTop: theme.spacing[2] }}>
{doctorError}
</Text>
) : null}
{doctorReport ? (
<View style={{ marginTop: theme.spacing[2], gap: theme.spacing[3] }}>
{/* Summary */}
<Text style={{ fontSize: theme.fontSize.sm }}>
{doctorReport.summary.ok > 0 ? (
<Text style={{ color: theme.colors.palette.green[400] }}>{doctorReport.summary.ok} passed</Text>
) : null}
{doctorReport.summary.ok > 0 && (doctorReport.summary.warn > 0 || doctorReport.summary.error > 0) ? " · " : ""}
{doctorReport.summary.warn > 0 ? (
<Text style={{ color: theme.colors.palette.amber[500] }}>{doctorReport.summary.warn} warning{doctorReport.summary.warn !== 1 ? "s" : ""}</Text>
) : null}
{doctorReport.summary.warn > 0 && doctorReport.summary.error > 0 ? " · " : ""}
{doctorReport.summary.error > 0 ? (
<Text style={{ color: theme.colors.destructive }}>{doctorReport.summary.error} error{doctorReport.summary.error !== 1 ? "s" : ""}</Text>
) : null}
</Text>
{/* 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 (
<View key={prefix} style={{ gap: theme.spacing[1] }}>
<Text style={{ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, textTransform: "uppercase", marginBottom: theme.spacing[1] }}>
{groupLabel}
</Text>
{groupChecks.map((check) => (
<View key={check.id} style={{ flexDirection: "row", alignItems: "flex-start", gap: theme.spacing[2], paddingVertical: 3 }}>
<Text style={{
fontSize: theme.fontSize.sm,
width: 16,
color: check.status === "ok"
? theme.colors.palette.green[400]
: check.status === "warn"
? theme.colors.palette.amber[500]
: theme.colors.destructive,
}}>
{check.status === "ok" ? "✓" : check.status === "warn" ? "⚠" : "✗"}
</Text>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: theme.fontSize.sm, color: theme.colors.foreground }}>{check.label}</Text>
<Text style={{ fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted }} selectable>{check.detail}</Text>
</View>
</View>
))}
</View>
);
})}
</View>
) : null}
</View>
) : null}
{/* Save/Cancel + Advanced */}
<View style={{ borderTopWidth: 1, borderTopColor: theme.colors.border, marginTop: theme.spacing[2], paddingTop: theme.spacing[4] }}>
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "center" }}>
+9
View File
@@ -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 <host>', 'Daemon host target (used with --remote)')
.action(withOutput(runDoctorCommand))
// Advanced agent commands (less common operations)
program.addCommand(createAgentCommand())
+97
View File
@@ -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<DoctorRow> {
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<DoctorReport> {
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<DoctorRow>
export async function runDoctorCommand(
options: CommandOptions,
_command: Command
): Promise<DoctorResult> {
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),
}
}
+15
View File
@@ -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
@@ -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<DoctorCheckResult[]> {
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)];
}
@@ -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<string | null> {
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<string | null> {
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<DoctorCheckResult> {
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<DoctorCheckResult[]> {
const binaryPath = await whichCommand(provider.command);
return [checkBinary(provider, binaryPath), await checkVersion(provider, binaryPath)];
}
export async function runProviderChecks(): Promise<DoctorCheckResult[]> {
const perProvider = await Promise.all(PROVIDERS.map(checkProvider));
return perProvider.flat();
}
@@ -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<DoctorCheckResult[]> {
return [checkNodeVersion(), checkPaseoVersion(options?.version)];
}
@@ -0,0 +1,2 @@
export { runDoctorChecks } from "./run-doctor-checks.js";
export type { CheckStatus, DoctorCheckResult, DoctorReport } from "./types.js";
@@ -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");
});
});
@@ -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<DoctorReport> {
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() };
}
@@ -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;
}
+8
View File
@@ -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,