From bb737dea52facf807639bf0c1aa8b42054d1ed78 Mon Sep 17 00:00:00 2001 From: Zi Makki Date: Mon, 9 Mar 2026 12:44:23 +0100 Subject: [PATCH 1/6] docs: add doctor health check design doc --- .../2026-03-09-doctor-health-check-design.md | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 docs/plans/2026-03-09-doctor-health-check-design.md 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 | From 3a4b463deb91040a247cb90657036c96c20a55c0 Mon Sep 17 00:00:00 2001 From: Zi Makki Date: Mon, 9 Mar 2026 12:51:40 +0100 Subject: [PATCH 2/6] 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 --- packages/cli/src/cli.ts | 9 ++ packages/cli/src/commands/doctor.ts | 96 +++++++++++++++++++ packages/server/src/server/bootstrap.ts | 15 +++ .../src/server/doctor/checks/config-checks.ts | 72 ++++++++++++++ .../server/doctor/checks/provider-checks.ts | 87 +++++++++++++++++ .../server/doctor/checks/runtime-checks.ts | 43 +++++++++ packages/server/src/server/doctor/index.ts | 2 + .../server/doctor/run-doctor-checks.test.ts | 86 +++++++++++++++++ .../src/server/doctor/run-doctor-checks.ts | 26 +++++ packages/server/src/server/doctor/types.ts | 14 +++ packages/server/src/server/exports.ts | 8 ++ 11 files changed, 458 insertions(+) create mode 100644 packages/cli/src/commands/doctor.ts create mode 100644 packages/server/src/server/doctor/checks/config-checks.ts create mode 100644 packages/server/src/server/doctor/checks/provider-checks.ts create mode 100644 packages/server/src/server/doctor/checks/runtime-checks.ts create mode 100644 packages/server/src/server/doctor/index.ts create mode 100644 packages/server/src/server/doctor/run-doctor-checks.test.ts create mode 100644 packages/server/src/server/doctor/run-doctor-checks.ts create mode 100644 packages/server/src/server/doctor/types.ts 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..b65e7562f --- /dev/null +++ b/packages/cli/src/commands/doctor.ts @@ -0,0 +1,96 @@ +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) => { + if (value === 'ok') return 'green' + if (value === 'warn') return 'yellow' + if (value === '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(options) + 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..196ee4db8 --- /dev/null +++ b/packages/server/src/server/doctor/checks/config-checks.ts @@ -0,0 +1,72 @@ +import { loadPersistedConfig } 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(paseoHome: string): DoctorCheckResult { + try { + loadPersistedConfig(paseoHome); + return { + id: "config.valid", + label: "Config file", + status: "ok", + detail: "Valid", + }; + } catch (err) { + return { + id: "config.valid", + label: "Config file", + status: "error", + detail: err instanceof Error ? err.message : String(err), + }; + } +} + +function checkListenAddress(paseoHome: string): DoctorCheckResult { + try { + const config = loadPersistedConfig(paseoHome); + 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, + }; + } catch (err) { + return { + id: "config.listen", + label: "Listen address", + status: "error", + detail: err instanceof Error ? err.message : String(err), + }; + } +} + +export async function runConfigChecks(paseoHome: string): Promise { + return [checkConfigValid(paseoHome), checkListenAddress(paseoHome)]; +} 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..9fb9814fa --- /dev/null +++ b/packages/server/src/server/doctor/checks/provider-checks.ts @@ -0,0 +1,87 @@ +import { execFileSync } from "node:child_process"; + +import type { DoctorCheckResult } from "../types.js"; + +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" }, +]; + +function whichCommand(command: string): string | null { + try { + return execFileSync("which", [command], { encoding: "utf8" }).trim() || null; + } catch { + return null; + } +} + +function getVersion(binaryPath: string): string | null { + try { + return execFileSync(binaryPath, ["--version"], { encoding: "utf8" }).trim() || null; + } catch { + return null; + } +} + +function checkBinary(provider: ProviderDef): DoctorCheckResult { + const binaryPath = whichCommand(provider.command); + 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", + }; +} + +function checkVersion(provider: ProviderDef): DoctorCheckResult { + const binaryPath = whichCommand(provider.command); + if (!binaryPath) { + return { + id: `provider.${provider.name}.version`, + label: `${provider.label} version`, + status: "error", + detail: "Binary not found", + }; + } + + const version = 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", + }; +} + +export async function runProviderChecks(): Promise { + const results: DoctorCheckResult[] = []; + for (const provider of PROVIDERS) { + results.push(checkBinary(provider)); + results.push(checkVersion(provider)); + } + return results; +} 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, From 15d7763d0b5e8f2b0871d94e03cb004502c2fe32 Mon Sep 17 00:00:00 2001 From: Zi Makki Date: Mon, 9 Mar 2026 12:55:44 +0100 Subject: [PATCH 3/6] fix: address doctor health check code review findings --- packages/cli/src/commands/doctor.ts | 13 +++++++------ .../server/doctor/checks/provider-checks.ts | 18 ++++++++++-------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index b65e7562f..1427c8d72 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -16,11 +16,11 @@ interface DoctorRow { function statusIndicator(status: DoctorCheckResult['status']): string { switch (status) { case 'ok': - return 'ok' + return '✓ ok' case 'warn': - return 'warn' + return '⚠ warn' case 'error': - return 'error' + return '✗ error' } } @@ -41,9 +41,10 @@ function createDoctorSchema(report: DoctorReport): OutputSchema { header: 'STATUS', field: 'status', color: (value) => { - if (value === 'ok') return 'green' - if (value === 'warn') return 'yellow' - if (value === 'error') return 'red' + 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 }, }, diff --git a/packages/server/src/server/doctor/checks/provider-checks.ts b/packages/server/src/server/doctor/checks/provider-checks.ts index 9fb9814fa..15635a6c7 100644 --- a/packages/server/src/server/doctor/checks/provider-checks.ts +++ b/packages/server/src/server/doctor/checks/provider-checks.ts @@ -14,9 +14,12 @@ const PROVIDERS: ProviderDef[] = [ { name: "opencode", command: "opencode", label: "OpenCode CLI" }, ]; +const EXEC_TIMEOUT_MS = 5000; + function whichCommand(command: string): string | null { + const whichBin = process.platform === "win32" ? "where" : "which"; try { - return execFileSync("which", [command], { encoding: "utf8" }).trim() || null; + return execFileSync(whichBin, [command], { encoding: "utf8", timeout: EXEC_TIMEOUT_MS }).trim() || null; } catch { return null; } @@ -24,14 +27,13 @@ function whichCommand(command: string): string | null { function getVersion(binaryPath: string): string | null { try { - return execFileSync(binaryPath, ["--version"], { encoding: "utf8" }).trim() || null; + return execFileSync(binaryPath, ["--version"], { encoding: "utf8", timeout: EXEC_TIMEOUT_MS }).trim() || null; } catch { return null; } } -function checkBinary(provider: ProviderDef): DoctorCheckResult { - const binaryPath = whichCommand(provider.command); +function checkBinary(provider: ProviderDef, binaryPath: string | null): DoctorCheckResult { if (binaryPath) { return { id: `provider.${provider.name}.binary`, @@ -48,8 +50,7 @@ function checkBinary(provider: ProviderDef): DoctorCheckResult { }; } -function checkVersion(provider: ProviderDef): DoctorCheckResult { - const binaryPath = whichCommand(provider.command); +function checkVersion(provider: ProviderDef, binaryPath: string | null): DoctorCheckResult { if (!binaryPath) { return { id: `provider.${provider.name}.version`, @@ -80,8 +81,9 @@ function checkVersion(provider: ProviderDef): DoctorCheckResult { export async function runProviderChecks(): Promise { const results: DoctorCheckResult[] = []; for (const provider of PROVIDERS) { - results.push(checkBinary(provider)); - results.push(checkVersion(provider)); + const binaryPath = whichCommand(provider.command); + results.push(checkBinary(provider, binaryPath)); + results.push(checkVersion(provider, binaryPath)); } return results; } From 59e0b2ec624b69b0a1b37bee4cc7b97f37ec548e Mon Sep 17 00:00:00 2001 From: Zi Makki Date: Mon, 9 Mar 2026 13:08:15 +0100 Subject: [PATCH 4/6] 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 --- packages/cli/src/commands/doctor.ts | 2 +- .../src/server/doctor/checks/config-checks.ts | 61 ++++++++++--------- .../server/doctor/checks/provider-checks.ts | 33 +++++----- 3 files changed, 53 insertions(+), 43 deletions(-) diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 1427c8d72..8dab85ca8 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -83,7 +83,7 @@ export async function runDoctorCommand( let report: DoctorReport if (remote) { - const host = getDaemonHost(options) + const host = getDaemonHost({ host: options.host as string | undefined }) report = await fetchRemoteReport(host) } else { report = await runDoctorChecks() diff --git a/packages/server/src/server/doctor/checks/config-checks.ts b/packages/server/src/server/doctor/checks/config-checks.ts index 196ee4db8..8072c6e52 100644 --- a/packages/server/src/server/doctor/checks/config-checks.ts +++ b/packages/server/src/server/doctor/checks/config-checks.ts @@ -1,4 +1,4 @@ -import { loadPersistedConfig } from "../../persisted-config.js"; +import { loadPersistedConfig, type PersistedConfig } from "../../persisted-config.js"; import type { DoctorCheckResult } from "../types.js"; /** @@ -20,53 +20,58 @@ function isValidListenString(listen: string): boolean { return Number.isFinite(parseInt(listen, 10)); } -function checkConfigValid(paseoHome: string): DoctorCheckResult { - try { - loadPersistedConfig(paseoHome); +function checkConfigValid(config: PersistedConfig | null, loadError: string | null): DoctorCheckResult { + if (config) { return { id: "config.valid", label: "Config file", status: "ok", detail: "Valid", }; - } catch (err) { - return { - id: "config.valid", - label: "Config file", - status: "error", - detail: err instanceof Error ? err.message : String(err), - }; } + return { + id: "config.valid", + label: "Config file", + status: "error", + detail: loadError ?? "Unknown error", + }; } -function checkListenAddress(paseoHome: string): DoctorCheckResult { - try { - const config = loadPersistedConfig(paseoHome); - 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}`, - }; - } +function checkListenAddress(config: PersistedConfig | null): DoctorCheckResult { + if (!config) { return { id: "config.listen", label: "Listen address", - status: "ok", - detail: listen, + status: "error", + detail: "Cannot check (config failed to load)", }; - } catch (err) { + } + + const listen = config.daemon?.listen ?? "127.0.0.1:6767"; + if (!isValidListenString(listen)) { return { id: "config.listen", label: "Listen address", status: "error", - detail: err instanceof Error ? err.message : String(err), + detail: `Malformed listen address: ${listen}`, }; } + return { + id: "config.listen", + label: "Listen address", + status: "ok", + detail: listen, + }; } export async function runConfigChecks(paseoHome: string): Promise { - return [checkConfigValid(paseoHome), checkListenAddress(paseoHome)]; + 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 index 15635a6c7..c9d0a743e 100644 --- a/packages/server/src/server/doctor/checks/provider-checks.ts +++ b/packages/server/src/server/doctor/checks/provider-checks.ts @@ -1,7 +1,10 @@ -import { execFileSync } from "node:child_process"; +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; @@ -16,18 +19,20 @@ const PROVIDERS: ProviderDef[] = [ const EXEC_TIMEOUT_MS = 5000; -function whichCommand(command: string): string | null { +async function whichCommand(command: string): Promise { const whichBin = process.platform === "win32" ? "where" : "which"; try { - return execFileSync(whichBin, [command], { encoding: "utf8", timeout: EXEC_TIMEOUT_MS }).trim() || null; + const { stdout } = await execFileAsync(whichBin, [command], { encoding: "utf8", timeout: EXEC_TIMEOUT_MS }); + return stdout.trim() || null; } catch { return null; } } -function getVersion(binaryPath: string): string | null { +async function getVersion(binaryPath: string): Promise { try { - return execFileSync(binaryPath, ["--version"], { encoding: "utf8", timeout: EXEC_TIMEOUT_MS }).trim() || null; + const { stdout } = await execFileAsync(binaryPath, ["--version"], { encoding: "utf8", timeout: EXEC_TIMEOUT_MS }); + return stdout.trim() || null; } catch { return null; } @@ -50,7 +55,7 @@ function checkBinary(provider: ProviderDef, binaryPath: string | null): DoctorCh }; } -function checkVersion(provider: ProviderDef, binaryPath: string | null): DoctorCheckResult { +async function checkVersion(provider: ProviderDef, binaryPath: string | null): Promise { if (!binaryPath) { return { id: `provider.${provider.name}.version`, @@ -60,7 +65,7 @@ function checkVersion(provider: ProviderDef, binaryPath: string | null): DoctorC }; } - const version = getVersion(binaryPath); + const version = await getVersion(binaryPath); if (version) { return { id: `provider.${provider.name}.version`, @@ -78,12 +83,12 @@ function checkVersion(provider: ProviderDef, binaryPath: string | null): DoctorC }; } +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 results: DoctorCheckResult[] = []; - for (const provider of PROVIDERS) { - const binaryPath = whichCommand(provider.command); - results.push(checkBinary(provider, binaryPath)); - results.push(checkVersion(provider, binaryPath)); - } - return results; + const perProvider = await Promise.all(PROVIDERS.map(checkProvider)); + return perProvider.flat(); } From 90c37e2f9505f1a855a7ca616a2aaabf0279b07c Mon Sep 17 00:00:00 2001 From: Zi Makki Date: Mon, 9 Mar 2026 13:40:00 +0100 Subject: [PATCH 5/6] 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. --- packages/app/src/screens/settings-screen.tsx | 101 +++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 162a7b02d..279e960da 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,75 @@ 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 */} From de7960ec6023de54b921626dc2a597f0a4740c12 Mon Sep 17 00:00:00 2001 From: Zi Makki Date: Mon, 9 Mar 2026 13:46:37 +0100 Subject: [PATCH 6/6] 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). --- packages/app/src/screens/settings-screen.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index 279e960da..781715252 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -1253,18 +1253,19 @@ function HostDetailModal({ {/* Grouped checks */} {(["provider", "config", "runtime"] as const).map((prefix) => { - const groupChecks = doctorReport.checks.filter((c) => c.id.startsWith(`${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.label} - {check.detail} + {check.detail} ))}