From 61f5ed8bb755c073530001146230e6514637cdd8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 02:28:21 +0100 Subject: [PATCH] fix: improve gateway diagnostics --- CHANGELOG.md | 1 + docs/gateway/doctor.md | 13 +- docs/gateway/index.md | 1 + docs/gateway/troubleshooting.md | 30 +++ src/cli/daemon-cli.coverage.test.ts | 14 ++ src/cli/daemon-cli.ts | 129 +++++++++- src/cli/gateway-cli.coverage.test.ts | 1 + src/cli/gateway-cli.ts | 21 ++ src/commands/doctor-workspace.ts | 34 +++ src/commands/doctor.test.ts | 47 ++++ src/commands/doctor.ts | 84 +++++++ src/daemon/launchd.test.ts | 20 ++ src/daemon/launchd.ts | 77 ++++++ src/daemon/schtasks.test.ts | 19 ++ src/daemon/schtasks.ts | 66 +++++ src/daemon/service-runtime.ts | 13 + src/daemon/service.ts | 10 + src/daemon/systemd.test.ts | 54 ++-- src/daemon/systemd.ts | 75 ++++++ src/infra/ports.test.ts | 35 +++ src/infra/ports.ts | 356 +++++++++++++++++++++++++-- 21 files changed, 1037 insertions(+), 63 deletions(-) create mode 100644 src/daemon/launchd.test.ts create mode 100644 src/daemon/schtasks.test.ts create mode 100644 src/daemon/service-runtime.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7af33f6a8..2165735ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - Auto-reply: removed `autoReply` from Discord/Slack/Telegram channel configs; use `requireMention` instead (Telegram topics now support `requireMention` overrides). ### Fixes +- Doctor/Daemon: surface gateway runtime state + port collision diagnostics; warn on legacy workspace dirs. - Discord: format slow listener logs in seconds to match shared duration style. - CLI: show colored table output for `clawdbot cron list` (JSON behind `--json`). - CLI: add cron `create`/`remove`/`delete` aliases for job management. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 371811cea..0b10f8d56 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -47,8 +47,11 @@ cat ~/.clawdbot/clawdbot.json - Legacy config migration and normalization. - Legacy on-disk state migration (sessions/agent dir/WhatsApp auth). - State integrity and permissions checks (sessions, transcripts, state dir). +- Legacy workspace dir detection (`~/clawdis`, `~/clawdbot`). - Sandbox image repair when sandboxing is enabled. - Legacy service migration and extra gateway detection. +- Gateway runtime checks (service installed but not running; cached launchd label). +- Gateway port collision diagnostics (default `18789`). - Security warnings for open DM policies. - systemd linger check on Linux. - Writes updated config + wizard metadata. @@ -140,11 +143,17 @@ workspace. Doctor runs a health check and offers to restart the gateway when it looks unhealthy. -### 11) Config write + wizard metadata +### 11) Gateway runtime + port diagnostics +Doctor inspects the daemon runtime (PID, last exit status) and warns when the +service is installed but not actually running. It also checks for port collisions +on the gateway port (default `18789`) and reports likely causes (gateway already +running, SSH tunnel). + +### 12) Config write + wizard metadata Doctor persists any config changes and stamps wizard metadata to record the doctor run. -### 12) Workspace tips (backup + memory system) +### 13) Workspace tips (backup + memory system) Doctor suggests a workspace memory system when missing and prints a backup tip if the workspace is not already under git. diff --git a/docs/gateway/index.md b/docs/gateway/index.md index 416ee4682..8afd556b4 100644 --- a/docs/gateway/index.md +++ b/docs/gateway/index.md @@ -171,6 +171,7 @@ clawdbot daemon restart Notes: - `daemon status` probes the Gateway RPC by default (same URL/token defaults as `gateway status`). - `daemon status --deep` adds system-level scans (LaunchDaemons/system units). +- `daemon status` now reports runtime state (PID/exit status) and port collisions when the gateway isn’t reachable. - `gateway install|uninstall|start|stop|restart` remain supported as aliases; `daemon` is the dedicated manager. - `gateway daemon status` is an alias for `clawdbot daemon status`. - If other gateway-like services are detected, the CLI warns. We recommend **one gateway per machine**; one gateway can host multiple agents. diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index a93227572..0f052a3bf 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -9,6 +9,36 @@ When your CLAWDBOT misbehaves, here's how to fix it. ## Common Issues +### Service Installed but Nothing is Running + +If the gateway service is installed but the process exits immediately, the daemon +can appear “loaded” while nothing is running. + +**Check:** +```bash +clawdbot daemon status +clawdbot doctor +``` + +Doctor/daemon will show runtime state (PID/last exit) and log hints. + +**Logs:** +- macOS: `~/.clawdbot/logs/gateway.log` and `gateway.err.log` +- Linux: `journalctl --user -u clawdbot-gateway.service -n 200 --no-pager` +- Windows: `schtasks /Query /TN "Clawdbot Gateway" /V /FO LIST` + +### Address Already in Use (Port 18789) + +This means something is already listening on the gateway port. + +**Check:** +```bash +clawdbot daemon status +``` + +It will show the listener(s) and likely causes (gateway already running, SSH tunnel). +If needed, stop the service or pick a different port. + ### "Agent was aborted" The agent was interrupted mid-response. diff --git a/src/cli/daemon-cli.coverage.test.ts b/src/cli/daemon-cli.coverage.test.ts index 1c8fedb5d..e7fe47396 100644 --- a/src/cli/daemon-cli.coverage.test.ts +++ b/src/cli/daemon-cli.coverage.test.ts @@ -11,7 +11,14 @@ const serviceStop = vi.fn().mockResolvedValue(undefined); const serviceRestart = vi.fn().mockResolvedValue(undefined); const serviceIsLoaded = vi.fn().mockResolvedValue(false); const serviceReadCommand = vi.fn().mockResolvedValue(null); +const serviceReadRuntime = vi.fn().mockResolvedValue({ status: "running" }); const findExtraGatewayServices = vi.fn(async () => []); +const inspectPortUsage = vi.fn(async () => ({ + port: 18789, + status: "free", + listeners: [], + hints: [], +})); const runtimeLogs: string[] = []; const runtimeErrors: string[] = []; @@ -43,6 +50,7 @@ vi.mock("../daemon/service.js", () => ({ restart: serviceRestart, isLoaded: serviceIsLoaded, readCommand: serviceReadCommand, + readRuntime: serviceReadRuntime, }), })); @@ -55,6 +63,11 @@ vi.mock("../daemon/inspect.js", () => ({ findExtraGatewayServices(env, opts), })); +vi.mock("../infra/ports.js", () => ({ + inspectPortUsage: (port: number) => inspectPortUsage(port), + formatPortDiagnostics: () => ["Port 18789 is already in use."], +})); + vi.mock("../runtime.js", () => ({ defaultRuntime, })); @@ -81,6 +94,7 @@ describe("daemon-cli coverage", () => { expect.objectContaining({ method: "status" }), ); expect(findExtraGatewayServices).toHaveBeenCalled(); + expect(inspectPortUsage).toHaveBeenCalled(); }); it("passes deep scan flag for daemon status", async () => { diff --git a/src/cli/daemon-cli.ts b/src/cli/daemon-cli.ts index 0d6403482..dcce4f61b 100644 --- a/src/cli/daemon-cli.ts +++ b/src/cli/daemon-cli.ts @@ -17,10 +17,17 @@ import { findExtraGatewayServices, renderGatewayServiceCleanupHints, } from "../daemon/inspect.js"; +import { resolveGatewayLogPaths } from "../daemon/launchd.js"; import { findLegacyGatewayServices } from "../daemon/legacy.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; import { resolveGatewayService } from "../daemon/service.js"; import { callGateway } from "../gateway/call.js"; +import { + formatPortDiagnostics, + inspectPortUsage, + type PortListener, + type PortUsageStatus, +} from "../infra/ports.js"; import { defaultRuntime } from "../runtime.js"; import { createDefaultDeps } from "./deps.js"; @@ -34,6 +41,25 @@ type DaemonStatus = { programArguments: string[]; workingDirectory?: string; } | null; + runtime?: { + status?: string; + state?: string; + subState?: string; + pid?: number; + lastExitStatus?: number; + lastExitReason?: string; + lastRunResult?: string; + lastRunTime?: string; + detail?: string; + cachedLabel?: boolean; + missingUnit?: boolean; + }; + }; + port?: { + port: number; + status: PortUsageStatus; + listeners: PortListener[]; + hints: string[]; }; rpc?: { ok: boolean; @@ -96,6 +122,61 @@ async function probeGatewayStatus(opts: GatewayRpcOpts) { } } +function formatRuntimeStatus(runtime: DaemonStatus["service"]["runtime"]) { + if (!runtime) return null; + const status = runtime.status ?? "unknown"; + const details: string[] = []; + if (runtime.pid) details.push(`pid ${runtime.pid}`); + if (runtime.state && runtime.state.toLowerCase() !== status) { + details.push(`state ${runtime.state}`); + } + if (runtime.subState) details.push(`sub ${runtime.subState}`); + if (runtime.lastExitStatus !== undefined) { + details.push(`last exit ${runtime.lastExitStatus}`); + } + if (runtime.lastExitReason) { + details.push(`reason ${runtime.lastExitReason}`); + } + if (runtime.lastRunResult) { + details.push(`last run ${runtime.lastRunResult}`); + } + if (runtime.lastRunTime) { + details.push(`last run time ${runtime.lastRunTime}`); + } + if (runtime.detail) details.push(runtime.detail); + return details.length > 0 ? `${status} (${details.join(", ")})` : status; +} + +function shouldReportPortUsage( + status: PortUsageStatus | undefined, + rpcOk?: boolean, +) { + if (status !== "busy") return false; + if (rpcOk === true) return false; + return true; +} + +function renderRuntimeHints( + runtime: DaemonStatus["service"]["runtime"], +): string[] { + if (!runtime) return []; + const hints: string[] = []; + if (runtime.status === "stopped") { + if (process.platform === "darwin") { + const logs = resolveGatewayLogPaths(process.env); + hints.push(`Logs: ${logs.stdoutPath}`); + hints.push(`Errors: ${logs.stderrPath}`); + } else if (process.platform === "linux") { + hints.push( + "Logs: journalctl --user -u clawdbot-gateway.service -n 200 --no-pager", + ); + } else if (process.platform === "win32") { + hints.push('Logs: schtasks /Query /TN "Clawdbot Gateway" /V /FO LIST'); + } + } + return hints; +} + function renderGatewayServiceStartHints(): string[] { switch (process.platform) { case "darwin": @@ -117,10 +198,27 @@ async function gatherDaemonStatus(opts: { deep?: boolean; }): Promise { const service = resolveGatewayService(); - const [loaded, command] = await Promise.all([ + const [loaded, command, runtime] = await Promise.all([ service.isLoaded({ env: process.env }).catch(() => false), service.readCommand(process.env).catch(() => null), + service.readRuntime(process.env).catch(() => undefined), ]); + let portStatus: DaemonStatus["port"] | undefined; + try { + const cfg = loadConfig(); + if (cfg.gateway?.mode !== "remote") { + const port = resolveGatewayPort(cfg, process.env); + const diagnostics = await inspectPortUsage(port); + portStatus = { + port: diagnostics.port, + status: diagnostics.status, + listeners: diagnostics.listeners, + hints: diagnostics.hints, + }; + } + } catch { + portStatus = undefined; + } const legacyServices = await findLegacyGatewayServices(process.env); const extraServices = await findExtraGatewayServices(process.env, { deep: opts.deep, @@ -134,7 +232,9 @@ async function gatherDaemonStatus(opts: { loadedText: service.loadedText, notLoadedText: service.notLoadedText, command, + runtime, }, + port: portStatus, rpc, legacyServices, extraServices, @@ -159,6 +259,10 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) { if (service.command?.workingDirectory) { defaultRuntime.log(`Working dir: ${service.command.workingDirectory}`); } + const runtimeLine = formatRuntimeStatus(service.runtime); + if (runtimeLine) { + defaultRuntime.log(`Runtime: ${runtimeLine}`); + } if (rpc) { if (rpc.ok) { defaultRuntime.log("RPC probe: ok"); @@ -166,6 +270,29 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) { defaultRuntime.error(`RPC probe: failed (${rpc.error})`); } } + if (service.loaded && service.runtime?.status === "stopped") { + defaultRuntime.error( + "Service is loaded but not running (likely exited immediately).", + ); + for (const hint of renderRuntimeHints(service.runtime)) { + defaultRuntime.error(hint); + } + } + if (service.runtime?.cachedLabel) { + defaultRuntime.error( + `LaunchAgent label cached but plist missing. Clear with: launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}`, + ); + } + if (status.port && shouldReportPortUsage(status.port.status, rpc?.ok)) { + for (const line of formatPortDiagnostics({ + port: status.port.port, + status: status.port.status, + listeners: status.port.listeners, + hints: status.port.hints, + })) { + defaultRuntime.error(line); + } + } if (legacyServices.length > 0) { defaultRuntime.error("Legacy Clawdis services detected:"); diff --git a/src/cli/gateway-cli.coverage.test.ts b/src/cli/gateway-cli.coverage.test.ts index 9bdef0027..3a70d4086 100644 --- a/src/cli/gateway-cli.coverage.test.ts +++ b/src/cli/gateway-cli.coverage.test.ts @@ -90,6 +90,7 @@ vi.mock("../daemon/service.js", () => ({ restart: serviceRestart, isLoaded: serviceIsLoaded, readCommand: vi.fn(), + readRuntime: vi.fn().mockResolvedValue({ status: "running" }), }), })); diff --git a/src/cli/gateway-cli.ts b/src/cli/gateway-cli.ts index 72e5badd2..44b3375c0 100644 --- a/src/cli/gateway-cli.ts +++ b/src/cli/gateway-cli.ts @@ -20,6 +20,7 @@ import { } from "../gateway/ws-logging.js"; import { setVerbose } from "../globals.js"; import { GatewayLockError } from "../infra/gateway-lock.js"; +import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js"; import { createSubsystemLogger } from "../logging.js"; import { defaultRuntime } from "../runtime.js"; import { @@ -368,6 +369,16 @@ export function registerGatewayCli(program: Command) { defaultRuntime.error( `Gateway failed to start: ${errMessage}\nIf the gateway is supervised, stop it with: clawdbot gateway stop`, ); + try { + const diagnostics = await inspectPortUsage(port); + if (diagnostics.status === "busy") { + for (const line of formatPortDiagnostics(diagnostics)) { + defaultRuntime.error(line); + } + } + } catch { + // ignore diagnostics failures + } await maybeExplainGatewayServiceStop(); defaultRuntime.exit(1); return; @@ -578,6 +589,16 @@ export function registerGatewayCli(program: Command) { defaultRuntime.error( `Gateway failed to start: ${errMessage}\nIf the gateway is supervised, stop it with: clawdbot gateway stop`, ); + try { + const diagnostics = await inspectPortUsage(port); + if (diagnostics.status === "busy") { + for (const line of formatPortDiagnostics(diagnostics)) { + defaultRuntime.error(line); + } + } + } catch { + // ignore diagnostics failures + } await maybeExplainGatewayServiceStop(); defaultRuntime.exit(1); return; diff --git a/src/commands/doctor-workspace.ts b/src/commands/doctor-workspace.ts index 952f719f6..0a3ea2ed0 100644 --- a/src/commands/doctor-workspace.ts +++ b/src/commands/doctor-workspace.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { DEFAULT_AGENTS_FILENAME } from "../agents/workspace.js"; @@ -39,3 +40,36 @@ export async function shouldSuggestMemorySystem( return true; } + +export type LegacyWorkspaceDetection = { + activeWorkspace: string; + legacyDirs: string[]; +}; + +export function detectLegacyWorkspaceDirs(params: { + workspaceDir: string; + homedir?: () => string; + exists?: (value: string) => boolean; +}): LegacyWorkspaceDetection { + const homedir = params.homedir ?? os.homedir; + const exists = params.exists ?? fs.existsSync; + const home = homedir(); + const activeWorkspace = path.resolve(params.workspaceDir); + const candidates = [path.join(home, "clawdis"), path.join(home, "clawdbot")]; + const legacyDirs = candidates.filter((candidate) => { + if (!exists(candidate)) return false; + return path.resolve(candidate) !== activeWorkspace; + }); + return { activeWorkspace, legacyDirs }; +} + +export function formatLegacyWorkspaceWarning( + detection: LegacyWorkspaceDetection, +): string { + return [ + "Legacy workspace directories detected (may contain old agent files):", + ...detection.legacyDirs.map((dir) => `- ${dir}`), + `Active workspace: ${detection.activeWorkspace}`, + "If unused, archive or move to Trash (e.g. trash ~/clawdis).", + ].join("\n"); +} diff --git a/src/commands/doctor.test.ts b/src/commands/doctor.test.ts index 61c32d14e..f5be00569 100644 --- a/src/commands/doctor.test.ts +++ b/src/commands/doctor.test.ts @@ -157,6 +157,7 @@ vi.mock("../daemon/service.js", () => ({ restart: serviceRestart, isLoaded: serviceIsLoaded, readCommand: vi.fn(), + readRuntime: vi.fn().mockResolvedValue({ status: "running" }), }), })); @@ -492,6 +493,52 @@ describe("doctor", () => { ), ).toBe(true); }); + + it("warns when legacy workspace directories exist", async () => { + readConfigFileSnapshot.mockResolvedValue({ + path: "/tmp/clawdbot.json", + exists: true, + raw: "{}", + parsed: {}, + valid: true, + config: { + agent: { workspace: "/Users/steipete/clawd" }, + }, + issues: [], + legacyIssues: [], + }); + + note.mockClear(); + const homedirSpy = vi + .spyOn(os, "homedir") + .mockReturnValue("/Users/steipete"); + const realExists = fs.existsSync; + const existsSpy = vi.spyOn(fs, "existsSync").mockImplementation((value) => { + if (value === "/Users/steipete/clawdis") return true; + return realExists(value as never); + }); + + const { doctorCommand } = await import("./doctor.js"); + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + await doctorCommand(runtime, { nonInteractive: true }); + + expect( + note.mock.calls.some( + ([message, title]) => + title === "Legacy workspace" && + typeof message === "string" && + message.includes("/Users/steipete/clawdis"), + ), + ).toBe(true); + + homedirSpy.mockRestore(); + existsSpy.mockRestore(); + }); it("falls back to legacy sandbox image when missing", async () => { readConfigFileSnapshot.mockResolvedValue({ path: "/tmp/clawdbot.json", diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 9dcd0a7fc..44c625809 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -5,10 +5,14 @@ import { CONFIG_PATH_CLAWDBOT, migrateLegacyConfig, readConfigFileSnapshot, + resolveGatewayPort, writeConfigFile, } from "../config/config.js"; import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js"; +import { resolveGatewayLogPaths } from "../daemon/launchd.js"; import { resolveGatewayService } from "../daemon/service.js"; +import type { GatewayServiceRuntime } from "../daemon/service-runtime.js"; +import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { resolveUserPath, sleep } from "../utils.js"; @@ -36,6 +40,8 @@ import { runLegacyStateMigrations, } from "./doctor-state-migrations.js"; import { + detectLegacyWorkspaceDirs, + formatLegacyWorkspaceWarning, MEMORY_SYSTEM_PROMPT, shouldSuggestMemorySystem, } from "./doctor-workspace.js"; @@ -51,6 +57,62 @@ function resolveMode(cfg: ClawdbotConfig): "local" | "remote" { return cfg.gateway?.mode === "remote" ? "remote" : "local"; } +function formatRuntimeSummary( + runtime: GatewayServiceRuntime | undefined, +): string | null { + if (!runtime) return null; + const status = runtime.status ?? "unknown"; + const details: string[] = []; + if (runtime.pid) details.push(`pid ${runtime.pid}`); + if (runtime.state && runtime.state.toLowerCase() !== status) { + details.push(`state ${runtime.state}`); + } + if (runtime.subState) details.push(`sub ${runtime.subState}`); + if (runtime.lastExitStatus !== undefined) { + details.push(`last exit ${runtime.lastExitStatus}`); + } + if (runtime.lastExitReason) { + details.push(`reason ${runtime.lastExitReason}`); + } + if (runtime.lastRunResult) { + details.push(`last run ${runtime.lastRunResult}`); + } + if (runtime.lastRunTime) { + details.push(`last run time ${runtime.lastRunTime}`); + } + if (runtime.detail) details.push(runtime.detail); + return details.length > 0 ? `${status} (${details.join(", ")})` : status; +} + +function buildGatewayRuntimeHints( + runtime: GatewayServiceRuntime | undefined, +): string[] { + const hints: string[] = []; + if (!runtime) return hints; + if (runtime.cachedLabel && process.platform === "darwin") { + hints.push( + `LaunchAgent label cached but plist missing. Clear with: launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}`, + ); + } + if (runtime.status === "stopped") { + hints.push( + "Service is loaded but not running (likely exited immediately).", + ); + if (process.platform === "darwin") { + const logs = resolveGatewayLogPaths(process.env); + hints.push(`Logs: ${logs.stdoutPath}`); + hints.push(`Errors: ${logs.stderrPath}`); + } else if (process.platform === "linux") { + hints.push( + "Logs: journalctl --user -u clawdbot-gateway.service -n 200 --no-pager", + ); + } else if (process.platform === "win32") { + hints.push('Logs: schtasks /Query /TN "Clawdbot Gateway" /V /FO LIST'); + } + } + return hints; +} + export async function doctorCommand( runtime: RuntimeEnv = defaultRuntime, options: DoctorOptions = {}, @@ -168,6 +230,10 @@ export async function doctorCommand( const workspaceDir = resolveUserPath( cfg.agent?.workspace ?? DEFAULT_WORKSPACE, ); + const legacyWorkspace = detectLegacyWorkspaceDirs({ workspaceDir }); + if (legacyWorkspace.legacyDirs.length > 0) { + note(formatLegacyWorkspaceWarning(legacyWorkspace), "Legacy workspace"); + } const skillsReport = buildWorkspaceSkillStatus(workspaceDir, { config: cfg }); note( [ @@ -198,11 +264,29 @@ export async function doctorCommand( } if (!healthOk) { + if (resolveMode(cfg) === "local") { + const port = resolveGatewayPort(cfg, process.env); + const diagnostics = await inspectPortUsage(port); + if (diagnostics.status === "busy") { + note(formatPortDiagnostics(diagnostics).join("\n"), "Gateway port"); + } + } const service = resolveGatewayService(); const loaded = await service.isLoaded({ env: process.env }); if (!loaded) { note("Gateway daemon not installed.", "Gateway"); } else { + const serviceRuntime = await service + .readRuntime(process.env) + .catch(() => undefined); + const summary = formatRuntimeSummary(serviceRuntime); + const hints = buildGatewayRuntimeHints(serviceRuntime); + if (summary || hints.length > 0) { + const lines = []; + if (summary) lines.push(`Runtime: ${summary}`); + lines.push(...hints); + note(lines.join("\n"), "Gateway"); + } if (process.platform === "darwin") { note( `LaunchAgent loaded; stopping requires "clawdbot gateway stop" or launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}.`, diff --git a/src/daemon/launchd.test.ts b/src/daemon/launchd.test.ts new file mode 100644 index 000000000..a996da479 --- /dev/null +++ b/src/daemon/launchd.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { parseLaunchctlPrint } from "./launchd.js"; + +describe("launchd runtime parsing", () => { + it("parses state, pid, and exit status", () => { + const output = [ + "state = running", + "pid = 4242", + "last exit status = 1", + "last exit reason = exited", + ].join("\n"); + expect(parseLaunchctlPrint(output)).toEqual({ + state: "running", + pid: 4242, + lastExitStatus: 1, + lastExitReason: "exited", + }); + }); +}); diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 3a6fc3ced..6a58055f0 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -7,6 +7,7 @@ import { GATEWAY_LAUNCH_AGENT_LABEL, LEGACY_GATEWAY_LAUNCH_AGENT_LABELS, } from "./constants.js"; +import type { GatewayServiceRuntime } from "./service-runtime.js"; const execFileAsync = promisify(execFile); function resolveHomeDir(env: Record): string { @@ -196,6 +197,38 @@ function resolveGuiDomain(): string { return `gui/${process.getuid()}`; } +export type LaunchctlPrintInfo = { + state?: string; + pid?: number; + lastExitStatus?: number; + lastExitReason?: string; +}; + +export function parseLaunchctlPrint(output: string): LaunchctlPrintInfo { + const info: LaunchctlPrintInfo = {}; + for (const rawLine of output.split("\n")) { + const line = rawLine.trim(); + if (!line) continue; + const match = line.match(/^([a-zA-Z\s]+?)\s*=\s*(.+)$/); + if (!match) continue; + const key = match[1]?.trim().toLowerCase(); + const value = match[2]?.trim(); + if (!key || value === undefined) continue; + if (key === "state") { + info.state = value; + } else if (key === "pid") { + const pid = Number.parseInt(value, 10); + if (Number.isFinite(pid)) info.pid = pid; + } else if (key === "last exit status") { + const status = Number.parseInt(value, 10); + if (Number.isFinite(status)) info.lastExitStatus = status; + } else if (key === "last exit reason") { + info.lastExitReason = value; + } + } + return info; +} + export async function isLaunchAgentLoaded(): Promise { const domain = resolveGuiDomain(); const label = GATEWAY_LAUNCH_AGENT_LABEL; @@ -203,6 +236,50 @@ export async function isLaunchAgentLoaded(): Promise { return res.code === 0; } +async function hasLaunchAgentPlist( + env: Record, +): Promise { + const plistPath = resolveLaunchAgentPlistPath(env); + try { + await fs.access(plistPath); + return true; + } catch { + return false; + } +} + +export async function readLaunchAgentRuntime( + env: Record, +): Promise { + const domain = resolveGuiDomain(); + const label = GATEWAY_LAUNCH_AGENT_LABEL; + const res = await execLaunchctl(["print", `${domain}/${label}`]); + if (res.code !== 0) { + return { + status: "unknown", + detail: (res.stderr || res.stdout).trim() || undefined, + missingUnit: true, + }; + } + const parsed = parseLaunchctlPrint(res.stdout || res.stderr || ""); + const plistExists = await hasLaunchAgentPlist(env); + const state = parsed.state?.toLowerCase(); + const status = + state === "running" || parsed.pid + ? "running" + : state + ? "stopped" + : "unknown"; + return { + status, + state: parsed.state, + pid: parsed.pid, + lastExitStatus: parsed.lastExitStatus, + lastExitReason: parsed.lastExitReason, + cachedLabel: !plistExists, + }; +} + export type LegacyLaunchAgent = { label: string; plistPath: string; diff --git a/src/daemon/schtasks.test.ts b/src/daemon/schtasks.test.ts new file mode 100644 index 000000000..e1af12976 --- /dev/null +++ b/src/daemon/schtasks.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; + +import { parseSchtasksQuery } from "./schtasks.js"; + +describe("schtasks runtime parsing", () => { + it("parses status and last run info", () => { + const output = [ + "TaskName: \\Clawdbot Gateway", + "Status: Ready", + "Last Run Time: 1/8/2026 1:23:45 AM", + "Last Run Result: 0x0", + ].join("\r\n"); + expect(parseSchtasksQuery(output)).toEqual({ + status: "Ready", + lastRunTime: "1/8/2026 1:23:45 AM", + lastRunResult: "0x0", + }); + }); +}); diff --git a/src/daemon/schtasks.ts b/src/daemon/schtasks.ts index 94ba8cb7a..91a8ab821 100644 --- a/src/daemon/schtasks.ts +++ b/src/daemon/schtasks.ts @@ -7,6 +7,7 @@ import { GATEWAY_WINDOWS_TASK_NAME, LEGACY_GATEWAY_WINDOWS_TASK_NAMES, } from "./constants.js"; +import type { GatewayServiceRuntime } from "./service-runtime.js"; const execFileAsync = promisify(execFile); @@ -102,6 +103,33 @@ export async function readScheduledTaskCommand( } } +export type ScheduledTaskInfo = { + status?: string; + lastRunTime?: string; + lastRunResult?: string; +}; + +export function parseSchtasksQuery(output: string): ScheduledTaskInfo { + const info: ScheduledTaskInfo = {}; + for (const rawLine of output.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) continue; + const idx = line.indexOf(":"); + if (idx <= 0) continue; + const key = line.slice(0, idx).trim().toLowerCase(); + const value = line.slice(idx + 1).trim(); + if (!value) continue; + if (key === "status") { + info.status = value; + } else if (key === "last run time") { + info.lastRunTime = value; + } else if (key === "last run result") { + info.lastRunResult = value; + } + } + return info; +} + function buildTaskScript({ programArguments, workingDirectory, @@ -274,6 +302,44 @@ export async function isScheduledTaskInstalled(): Promise { const res = await execSchtasks(["/Query", "/TN", GATEWAY_WINDOWS_TASK_NAME]); return res.code === 0; } + +export async function readScheduledTaskRuntime(): Promise { + try { + await assertSchtasksAvailable(); + } catch (err) { + return { + status: "unknown", + detail: String(err), + }; + } + const res = await execSchtasks([ + "/Query", + "/TN", + GATEWAY_WINDOWS_TASK_NAME, + "/V", + "/FO", + "LIST", + ]); + if (res.code !== 0) { + const detail = (res.stderr || res.stdout).trim(); + const missing = detail.toLowerCase().includes("cannot find the file"); + return { + status: missing ? "stopped" : "unknown", + detail: detail || undefined, + missingUnit: missing, + }; + } + const parsed = parseSchtasksQuery(res.stdout || ""); + const statusRaw = parsed.status?.toLowerCase(); + const status = + statusRaw === "running" ? "running" : statusRaw ? "stopped" : "unknown"; + return { + status, + state: parsed.status, + lastRunTime: parsed.lastRunTime, + lastRunResult: parsed.lastRunResult, + }; +} export type LegacyScheduledTask = { name: string; scriptPath: string; diff --git a/src/daemon/service-runtime.ts b/src/daemon/service-runtime.ts new file mode 100644 index 000000000..8589af4bc --- /dev/null +++ b/src/daemon/service-runtime.ts @@ -0,0 +1,13 @@ +export type GatewayServiceRuntime = { + status?: "running" | "stopped" | "unknown"; + state?: string; + subState?: string; + pid?: number; + lastExitStatus?: number; + lastExitReason?: string; + lastRunResult?: string; + lastRunTime?: string; + detail?: string; + cachedLabel?: boolean; + missingUnit?: boolean; +}; diff --git a/src/daemon/service.ts b/src/daemon/service.ts index c2799cc71..4e09e41a7 100644 --- a/src/daemon/service.ts +++ b/src/daemon/service.ts @@ -2,6 +2,7 @@ import { installLaunchAgent, isLaunchAgentLoaded, readLaunchAgentProgramArguments, + readLaunchAgentRuntime, restartLaunchAgent, stopLaunchAgent, uninstallLaunchAgent, @@ -10,14 +11,17 @@ import { installScheduledTask, isScheduledTaskInstalled, readScheduledTaskCommand, + readScheduledTaskRuntime, restartScheduledTask, stopScheduledTask, uninstallScheduledTask, } from "./schtasks.js"; +import type { GatewayServiceRuntime } from "./service-runtime.js"; import { installSystemdService, isSystemdServiceEnabled, readSystemdServiceExecStart, + readSystemdServiceRuntime, restartSystemdService, stopSystemdService, uninstallSystemdService, @@ -49,6 +53,9 @@ export type GatewayService = { programArguments: string[]; workingDirectory?: string; } | null>; + readRuntime: ( + env: Record, + ) => Promise; }; export function resolveGatewayService(): GatewayService { @@ -71,6 +78,7 @@ export function resolveGatewayService(): GatewayService { }, isLoaded: async () => isLaunchAgentLoaded(), readCommand: readLaunchAgentProgramArguments, + readRuntime: readLaunchAgentRuntime, }; } @@ -93,6 +101,7 @@ export function resolveGatewayService(): GatewayService { }, isLoaded: async () => isSystemdServiceEnabled(), readCommand: readSystemdServiceExecStart, + readRuntime: async () => await readSystemdServiceRuntime(), }; } @@ -115,6 +124,7 @@ export function resolveGatewayService(): GatewayService { }, isLoaded: async () => isScheduledTaskInstalled(), readCommand: readScheduledTaskCommand, + readRuntime: async () => await readScheduledTaskRuntime(), }; } diff --git a/src/daemon/systemd.test.ts b/src/daemon/systemd.test.ts index 754a8f585..b6fe145a1 100644 --- a/src/daemon/systemd.test.ts +++ b/src/daemon/systemd.test.ts @@ -1,43 +1,21 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { runExec } from "../process/exec.js"; -import { readSystemdUserLingerStatus } from "./systemd.js"; +import { describe, expect, it } from "vitest"; -vi.mock("../process/exec.js", () => ({ - runExec: vi.fn(), - runCommandWithTimeout: vi.fn(), -})); +import { parseSystemdShow } from "./systemd.js"; -const runExecMock = vi.mocked(runExec); - -describe("readSystemdUserLingerStatus", () => { - beforeEach(() => { - runExecMock.mockReset(); - }); - - it("returns yes when loginctl reports Linger=yes", async () => { - runExecMock.mockResolvedValue({ - stdout: "Linger=yes\n", - stderr: "", +describe("systemd runtime parsing", () => { + it("parses active state details", () => { + const output = [ + "ActiveState=inactive", + "SubState=dead", + "MainPID=0", + "ExecMainStatus=2", + "ExecMainCode=exited", + ].join("\n"); + expect(parseSystemdShow(output)).toEqual({ + activeState: "inactive", + subState: "dead", + execMainStatus: 2, + execMainCode: "exited", }); - const result = await readSystemdUserLingerStatus({ USER: "tobi" }); - expect(result).toEqual({ user: "tobi", linger: "yes" }); - }); - - it("returns no when loginctl reports Linger=no", async () => { - runExecMock.mockResolvedValue({ - stdout: "Linger=no\n", - stderr: "", - }); - const result = await readSystemdUserLingerStatus({ USER: "tobi" }); - expect(result).toEqual({ user: "tobi", linger: "no" }); - }); - - it("returns null when Linger is missing", async () => { - runExecMock.mockResolvedValue({ - stdout: "UID=1000\n", - stderr: "", - }); - const result = await readSystemdUserLingerStatus({ USER: "tobi" }); - expect(result).toBeNull(); }); }); diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index 5d8875c5f..753ddd44c 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -8,6 +8,7 @@ import { GATEWAY_SYSTEMD_SERVICE_NAME, LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES, } from "./constants.js"; +import type { GatewayServiceRuntime } from "./service-runtime.js"; const execFileAsync = promisify(execFile); @@ -215,6 +216,39 @@ export async function readSystemdServiceExecStart( } } +export type SystemdServiceInfo = { + activeState?: string; + subState?: string; + mainPid?: number; + execMainStatus?: number; + execMainCode?: string; +}; + +export function parseSystemdShow(output: string): SystemdServiceInfo { + const info: SystemdServiceInfo = {}; + for (const rawLine of output.split("\n")) { + const line = rawLine.trim(); + if (!line || !line.includes("=")) continue; + const [key, ...rest] = line.split("="); + const value = rest.join("=").trim(); + if (!key) continue; + if (key === "ActiveState") { + info.activeState = value; + } else if (key === "SubState") { + info.subState = value; + } else if (key === "MainPID") { + const pid = Number.parseInt(value, 10); + if (Number.isFinite(pid) && pid > 0) info.mainPid = pid; + } else if (key === "ExecMainStatus") { + const status = Number.parseInt(value, 10); + if (Number.isFinite(status)) info.execMainStatus = status; + } else if (key === "ExecMainCode") { + info.execMainCode = value; + } + } + return info; +} + async function execSystemctl( args: string[], ): Promise<{ stdout: string; stderr: string; code: number }> { @@ -369,6 +403,47 @@ export async function isSystemdServiceEnabled(): Promise { const res = await execSystemctl(["--user", "is-enabled", unitName]); return res.code === 0; } + +export async function readSystemdServiceRuntime(): Promise { + try { + await assertSystemdAvailable(); + } catch (err) { + return { + status: "unknown", + detail: String(err), + }; + } + const unitName = `${GATEWAY_SYSTEMD_SERVICE_NAME}.service`; + const res = await execSystemctl([ + "--user", + "show", + unitName, + "--no-page", + "--property", + "ActiveState,SubState,MainPID,ExecMainStatus,ExecMainCode", + ]); + if (res.code !== 0) { + const detail = (res.stderr || res.stdout).trim(); + const missing = detail.toLowerCase().includes("not found"); + return { + status: missing ? "stopped" : "unknown", + detail: detail || undefined, + missingUnit: missing, + }; + } + const parsed = parseSystemdShow(res.stdout || ""); + const activeState = parsed.activeState?.toLowerCase(); + const status = + activeState === "active" ? "running" : activeState ? "stopped" : "unknown"; + return { + status, + state: parsed.activeState, + subState: parsed.subState, + pid: parsed.mainPid, + lastExitStatus: parsed.execMainStatus, + lastExitReason: parsed.execMainCode, + }; +} export type LegacySystemdUnit = { name: string; unitPath: string; diff --git a/src/infra/ports.test.ts b/src/infra/ports.test.ts index 9f72739c8..410f61dcd 100644 --- a/src/infra/ports.test.ts +++ b/src/infra/ports.test.ts @@ -2,7 +2,10 @@ import net from "node:net"; import { describe, expect, it, vi } from "vitest"; import { + buildPortHints, + classifyPortListener, ensurePortAvailable, + formatPortDiagnostics, handlePortError, PortInUseError, } from "./ports.js"; @@ -33,4 +36,36 @@ describe("ports helpers", () => { expect(runtime.error).toHaveBeenCalled(); expect(runtime.exit).toHaveBeenCalledWith(1); }); + + it("classifies ssh and gateway listeners", () => { + expect( + classifyPortListener( + { commandLine: "ssh -N -L 18789:127.0.0.1:18789 user@host" }, + 18789, + ), + ).toBe("ssh"); + expect( + classifyPortListener( + { + commandLine: "node /Users/me/Projects/clawdbot/dist/entry.js gateway", + }, + 18789, + ), + ).toBe("gateway"); + }); + + it("formats port diagnostics with hints", () => { + const diagnostics = { + port: 18789, + status: "busy" as const, + listeners: [{ pid: 123, commandLine: "ssh -N -L 18789:127.0.0.1:18789" }], + hints: buildPortHints( + [{ pid: 123, commandLine: "ssh -N -L 18789:127.0.0.1:18789" }], + 18789, + ), + }; + const lines = formatPortDiagnostics(diagnostics); + expect(lines[0]).toContain("Port 18789 is already in use"); + expect(lines.some((line) => line.includes("SSH tunnel"))).toBe(true); + }); }); diff --git a/src/infra/ports.ts b/src/infra/ports.ts index e4e83f74c..eaa799dc0 100644 --- a/src/infra/ports.ts +++ b/src/infra/ports.ts @@ -1,13 +1,7 @@ import net from "node:net"; -import { - danger, - info, - logVerbose, - shouldLogVerbose, - warn, -} from "../globals.js"; +import { danger, info, shouldLogVerbose, warn } from "../globals.js"; import { logDebug } from "../logger.js"; -import { runExec } from "../process/exec.js"; +import { runCommandWithTimeout } from "../process/exec.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; class PortInUseError extends Error { @@ -29,20 +23,9 @@ function isErrno(err: unknown): err is NodeJS.ErrnoException { export async function describePortOwner( port: number, ): Promise { - // Best-effort process info for a listening port (macOS/Linux). - try { - const { stdout } = await runExec("lsof", [ - "-i", - `tcp:${port}`, - "-sTCP:LISTEN", - "-nP", - ]); - const trimmed = stdout.trim(); - if (trimmed) return trimmed; - } catch (err) { - logVerbose(`lsof unavailable: ${String(err)}`); - } - return undefined; + const diagnostics = await inspectPortUsage(port); + if (diagnostics.listeners.length === 0) return undefined; + return formatPortDiagnostics(diagnostics).join("\n"); } export async function ensurePortAvailable(port: number): Promise { @@ -111,3 +94,332 @@ export async function handlePortError( } export { PortInUseError }; + +export type PortListener = { + pid?: number; + command?: string; + commandLine?: string; + user?: string; + address?: string; +}; + +export type PortUsageStatus = "free" | "busy" | "unknown"; + +export type PortUsage = { + port: number; + status: PortUsageStatus; + listeners: PortListener[]; + hints: string[]; + detail?: string; + errors?: string[]; +}; + +type CommandResult = { + stdout: string; + stderr: string; + code: number; + error?: string; +}; + +async function runCommandSafe( + argv: string[], + timeoutMs = 5_000, +): Promise { + try { + const res = await runCommandWithTimeout(argv, { timeoutMs }); + return { + stdout: res.stdout, + stderr: res.stderr, + code: res.code ?? 1, + }; + } catch (err) { + return { + stdout: "", + stderr: "", + code: 1, + error: String(err), + }; + } +} + +function parseLsofFieldOutput(output: string): PortListener[] { + const lines = output.split(/\r?\n/).filter(Boolean); + const listeners: PortListener[] = []; + let current: PortListener = {}; + for (const line of lines) { + if (line.startsWith("p")) { + if (current.pid || current.command) listeners.push(current); + const pid = Number.parseInt(line.slice(1), 10); + current = Number.isFinite(pid) ? { pid } : {}; + } else if (line.startsWith("c")) { + current.command = line.slice(1); + } + } + if (current.pid || current.command) listeners.push(current); + return listeners; +} + +async function resolveUnixCommandLine( + pid: number, +): Promise { + const res = await runCommandSafe(["ps", "-p", String(pid), "-o", "command="]); + if (res.code !== 0) return undefined; + const line = res.stdout.trim(); + return line || undefined; +} + +async function resolveUnixUser(pid: number): Promise { + const res = await runCommandSafe(["ps", "-p", String(pid), "-o", "user="]); + if (res.code !== 0) return undefined; + const line = res.stdout.trim(); + return line || undefined; +} + +async function readUnixListeners( + port: number, +): Promise<{ listeners: PortListener[]; detail?: string; errors: string[] }> { + const errors: string[] = []; + const res = await runCommandSafe([ + "lsof", + "-nP", + `-iTCP:${port}`, + "-sTCP:LISTEN", + "-FpFc", + ]); + if (res.code === 0) { + const listeners = parseLsofFieldOutput(res.stdout); + await Promise.all( + listeners.map(async (listener) => { + if (!listener.pid) return; + const [commandLine, user] = await Promise.all([ + resolveUnixCommandLine(listener.pid), + resolveUnixUser(listener.pid), + ]); + if (commandLine) listener.commandLine = commandLine; + if (user) listener.user = user; + }), + ); + return { listeners, detail: res.stdout.trim() || undefined, errors }; + } + if (res.code === 1) { + return { listeners: [], detail: undefined, errors }; + } + if (res.error) errors.push(res.error); + const detail = [res.stderr.trim(), res.stdout.trim()] + .filter(Boolean) + .join("\n"); + if (detail) errors.push(detail); + return { listeners: [], detail: undefined, errors }; +} + +function parseNetstatListeners(output: string, port: number): PortListener[] { + const listeners: PortListener[] = []; + const portToken = `:${port}`; + for (const rawLine of output.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) continue; + if (!line.toLowerCase().includes("listen")) continue; + if (!line.includes(portToken)) continue; + const parts = line.split(/\s+/); + if (parts.length < 4) continue; + const pidRaw = parts.at(-1); + const pid = pidRaw ? Number.parseInt(pidRaw, 10) : NaN; + const localAddr = parts[1]; + const listener: PortListener = {}; + if (Number.isFinite(pid)) listener.pid = pid; + if (localAddr?.includes(portToken)) listener.address = localAddr; + listeners.push(listener); + } + return listeners; +} + +async function resolveWindowsImageName( + pid: number, +): Promise { + const res = await runCommandSafe([ + "tasklist", + "/FI", + `PID eq ${pid}`, + "/FO", + "LIST", + ]); + if (res.code !== 0) return undefined; + for (const rawLine of res.stdout.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line.toLowerCase().startsWith("image name:")) continue; + const value = line.slice("image name:".length).trim(); + return value || undefined; + } + return undefined; +} + +async function resolveWindowsCommandLine( + pid: number, +): Promise { + const res = await runCommandSafe([ + "wmic", + "process", + "where", + `ProcessId=${pid}`, + "get", + "CommandLine", + "/value", + ]); + if (res.code !== 0) return undefined; + for (const rawLine of res.stdout.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line.toLowerCase().startsWith("commandline=")) continue; + const value = line.slice("commandline=".length).trim(); + return value || undefined; + } + return undefined; +} + +async function readWindowsListeners( + port: number, +): Promise<{ listeners: PortListener[]; detail?: string; errors: string[] }> { + const errors: string[] = []; + const res = await runCommandSafe(["netstat", "-ano", "-p", "tcp"]); + if (res.code !== 0) { + if (res.error) errors.push(res.error); + const detail = [res.stderr.trim(), res.stdout.trim()] + .filter(Boolean) + .join("\n"); + if (detail) errors.push(detail); + return { listeners: [], errors }; + } + const listeners = parseNetstatListeners(res.stdout, port); + await Promise.all( + listeners.map(async (listener) => { + if (!listener.pid) return; + const [imageName, commandLine] = await Promise.all([ + resolveWindowsImageName(listener.pid), + resolveWindowsCommandLine(listener.pid), + ]); + if (imageName) listener.command = imageName; + if (commandLine) listener.commandLine = commandLine; + }), + ); + return { listeners, detail: res.stdout.trim() || undefined, errors }; +} + +async function checkPortInUse(port: number): Promise { + try { + await new Promise((resolve, reject) => { + const tester = net + .createServer() + .once("error", (err) => reject(err)) + .once("listening", () => { + tester.close(() => resolve()); + }) + .listen(port); + }); + return "free"; + } catch (err) { + if (err instanceof PortInUseError) return "busy"; + if (isErrno(err) && err.code === "EADDRINUSE") return "busy"; + return "unknown"; + } +} + +export type PortListenerKind = "gateway" | "ssh" | "unknown"; + +export function classifyPortListener( + listener: PortListener, + port: number, +): PortListenerKind { + const raw = `${listener.commandLine ?? ""} ${listener.command ?? ""}` + .trim() + .toLowerCase(); + if (raw.includes("clawdbot") || raw.includes("clawdis")) return "gateway"; + if (raw.includes("ssh")) { + const portToken = String(port); + const tunnelPattern = new RegExp( + `-(l|r)\\s*${portToken}\\b|-(l|r)${portToken}\\b|:${portToken}\\b`, + ); + if (!raw || tunnelPattern.test(raw)) return "ssh"; + return "ssh"; + } + return "unknown"; +} + +export function buildPortHints( + listeners: PortListener[], + port: number, +): string[] { + if (listeners.length === 0) return []; + const kinds = new Set( + listeners.map((listener) => classifyPortListener(listener, port)), + ); + const hints: string[] = []; + if (kinds.has("gateway")) { + hints.push( + "Gateway already running locally. Stop it (clawdbot gateway stop) or use a different port.", + ); + } + if (kinds.has("ssh")) { + hints.push( + "SSH tunnel already bound to this port. Close the tunnel or use a different local port in -L.", + ); + } + if (kinds.has("unknown")) { + hints.push("Another process is listening on this port."); + } + if (listeners.length > 1) { + hints.push("Multiple listeners detected; ensure only one gateway/tunnel."); + } + return hints; +} + +export function formatPortListener(listener: PortListener): string { + const pid = listener.pid ? `pid ${listener.pid}` : "pid ?"; + const user = listener.user ? ` ${listener.user}` : ""; + const command = listener.commandLine || listener.command || "unknown"; + const address = listener.address ? ` (${listener.address})` : ""; + return `${pid}${user}: ${command}${address}`; +} + +export function formatPortDiagnostics(diagnostics: PortUsage): string[] { + if (diagnostics.status !== "busy") { + return [`Port ${diagnostics.port} is free.`]; + } + const lines = [`Port ${diagnostics.port} is already in use.`]; + for (const listener of diagnostics.listeners) { + lines.push(`- ${formatPortListener(listener)}`); + } + for (const hint of diagnostics.hints) { + lines.push(`- ${hint}`); + } + return lines; +} + +export async function inspectPortUsage(port: number): Promise { + const errors: string[] = []; + const result = + process.platform === "win32" + ? await readWindowsListeners(port) + : await readUnixListeners(port); + errors.push(...result.errors); + let listeners = result.listeners; + let status: PortUsageStatus = listeners.length > 0 ? "busy" : "unknown"; + if (listeners.length === 0) { + status = await checkPortInUse(port); + } + if (status !== "busy") { + listeners = []; + } + const hints = buildPortHints(listeners, port); + if (status === "busy" && listeners.length === 0) { + hints.push( + "Port is in use but process details are unavailable (install lsof or run as an admin user).", + ); + } + return { + port, + status, + listeners, + hints, + detail: result.detail, + errors: errors.length > 0 ? errors : undefined, + }; +}