Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions docs/plans/2026-03-09-doctor-health-check-design.md
Original file line number Diff line number Diff line change
@@ -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 |
102 changes: 102 additions & 0 deletions packages/app/src/screens/settings-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -1103,6 +1132,9 @@ function HostDetailModal({
if (!visible) {
setIsRestarting(false);
setDraftLabel("");
setDoctorReport(null);
setDoctorLoading(false);
setDoctorError(null);
}
}, [visible]);

Expand Down Expand Up @@ -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" }}>
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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())

Expand Down
97 changes: 97 additions & 0 deletions packages/cli/src/commands/doctor.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 15 additions & 0 deletions packages/server/src/server/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading