refactor: split port diagnostics helpers

This commit is contained in:
Peter Steinberger
2026-01-08 02:42:25 +01:00
parent 2fe3b483b1
commit 3d0156890c
12 changed files with 516 additions and 445 deletions

View File

@@ -37,6 +37,19 @@ file creation:
{ agent: { skipBootstrap: true } }
```
## Legacy workspace folders
Older installs may have created `~/clawdis` or `~/clawdbot`. Keeping multiple
workspace directories around can cause confusing auth or state drift, because
only one workspace is active at a time.
**Recommendation:** keep a single active workspace. If you no longer use the
legacy folders, archive or move them to Trash (for example `trash ~/clawdis`).
If you intentionally keep multiple workspaces, make sure
`agent.workspace` points to the active one.
`clawdbot doctor` warns when it detects legacy workspace directories.
## Workspace file map (what each file means)
These are the standard files Clawdbot expects inside the workspace:

View File

@@ -39,6 +39,15 @@ 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.
### Legacy Workspace Folders Detected
If you upgraded from older installs, you might still have `~/clawdis` or
`~/clawdbot` on disk. Multiple workspace directories can cause confusing auth
or state drift because only one workspace is active.
**Fix:** keep a single active workspace and archive/remove the rest. See
[Agent workspace](/concepts/agent-workspace#legacy-workspace-folders).
### "Agent was aborted"
The agent was interrupted mid-response.

View File

@@ -0,0 +1,67 @@
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import { resolveGatewayLogPaths } from "../daemon/launchd.js";
import type { GatewayServiceRuntime } from "../daemon/service-runtime.js";
type RuntimeHintOptions = {
platform?: NodeJS.Platform;
env?: Record<string, string | undefined>;
};
export function formatGatewayRuntimeSummary(
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;
}
export function buildGatewayRuntimeHints(
runtime: GatewayServiceRuntime | undefined,
options: RuntimeHintOptions = {},
): string[] {
const hints: string[] = [];
if (!runtime) return hints;
const platform = options.platform ?? process.platform;
const env = options.env ?? process.env;
if (runtime.cachedLabel && 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 (platform === "darwin") {
const logs = resolveGatewayLogPaths(env);
hints.push(`Logs: ${logs.stdoutPath}`);
hints.push(`Errors: ${logs.stderrPath}`);
} else if (platform === "linux") {
hints.push(
"Logs: journalctl --user -u clawdbot-gateway.service -n 200 --no-pager",
);
} else if (platform === "win32") {
hints.push('Logs: schtasks /Query /TN "Clawdbot Gateway" /V /FO LIST');
}
}
return hints;
}

View File

@@ -9,14 +9,16 @@ import {
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";
import { maybeRepairAnthropicOAuthProfileId } from "./doctor-auth.js";
import {
buildGatewayRuntimeHints,
formatGatewayRuntimeSummary,
} from "./doctor-format.js";
import {
maybeMigrateLegacyGatewayService,
maybeScanExtraGatewayServices,
@@ -57,62 +59,6 @@ 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 = {},
@@ -279,8 +225,11 @@ export async function doctorCommand(
const serviceRuntime = await service
.readRuntime(process.env)
.catch(() => undefined);
const summary = formatRuntimeSummary(serviceRuntime);
const hints = buildGatewayRuntimeHints(serviceRuntime);
const summary = formatGatewayRuntimeSummary(serviceRuntime);
const hints = buildGatewayRuntimeHints(serviceRuntime, {
platform: process.platform,
env: process.env,
});
if (summary || hints.length > 0) {
const lines = [];
if (summary) lines.push(`Runtime: ${summary}`);

View File

@@ -7,6 +7,7 @@ import {
GATEWAY_LAUNCH_AGENT_LABEL,
LEGACY_GATEWAY_LAUNCH_AGENT_LABELS,
} from "./constants.js";
import { parseKeyValueOutput } from "./runtime-parse.js";
import type { GatewayServiceRuntime } from "./service-runtime.js";
const execFileAsync = promisify(execFile);
@@ -205,27 +206,22 @@ export type LaunchctlPrintInfo = {
};
export function parseLaunchctlPrint(output: string): LaunchctlPrintInfo {
const entries = parseKeyValueOutput(output, "=");
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;
}
const state = entries.state;
if (state) info.state = state;
const pidValue = entries.pid;
if (pidValue) {
const pid = Number.parseInt(pidValue, 10);
if (Number.isFinite(pid)) info.pid = pid;
}
const exitStatusValue = entries["last exit status"];
if (exitStatusValue) {
const status = Number.parseInt(exitStatusValue, 10);
if (Number.isFinite(status)) info.lastExitStatus = status;
}
const exitReason = entries["last exit reason"];
if (exitReason) info.lastExitReason = exitReason;
return info;
}

View File

@@ -0,0 +1,17 @@
export function parseKeyValueOutput(
output: string,
separator: string,
): Record<string, string> {
const entries: Record<string, string> = {};
for (const rawLine of output.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
const idx = line.indexOf(separator);
if (idx <= 0) continue;
const key = line.slice(0, idx).trim().toLowerCase();
if (!key) continue;
const value = line.slice(idx + separator.length).trim();
entries[key] = value;
}
return entries;
}

View File

@@ -7,6 +7,7 @@ import {
GATEWAY_WINDOWS_TASK_NAME,
LEGACY_GATEWAY_WINDOWS_TASK_NAMES,
} from "./constants.js";
import { parseKeyValueOutput } from "./runtime-parse.js";
import type { GatewayServiceRuntime } from "./service-runtime.js";
const execFileAsync = promisify(execFile);
@@ -110,23 +111,14 @@ export type ScheduledTaskInfo = {
};
export function parseSchtasksQuery(output: string): ScheduledTaskInfo {
const entries = parseKeyValueOutput(output, ":");
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;
}
}
const status = entries.status;
if (status) info.status = status;
const lastRunTime = entries["last run time"];
if (lastRunTime) info.lastRunTime = lastRunTime;
const lastRunResult = entries["last run result"];
if (lastRunResult) info.lastRunResult = lastRunResult;
return info;
}

View File

@@ -8,6 +8,7 @@ import {
GATEWAY_SYSTEMD_SERVICE_NAME,
LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES,
} from "./constants.js";
import { parseKeyValueOutput } from "./runtime-parse.js";
import type { GatewayServiceRuntime } from "./service-runtime.js";
const execFileAsync = promisify(execFile);
@@ -225,27 +226,24 @@ export type SystemdServiceInfo = {
};
export function parseSystemdShow(output: string): SystemdServiceInfo {
const entries = parseKeyValueOutput(output, "=");
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;
}
const activeState = entries.activestate;
if (activeState) info.activeState = activeState;
const subState = entries.substate;
if (subState) info.subState = subState;
const mainPidValue = entries.mainpid;
if (mainPidValue) {
const pid = Number.parseInt(mainPidValue, 10);
if (Number.isFinite(pid) && pid > 0) info.mainPid = pid;
}
const execMainStatusValue = entries.execmainstatus;
if (execMainStatusValue) {
const status = Number.parseInt(execMainStatusValue, 10);
if (Number.isFinite(status)) info.execMainStatus = status;
}
const execMainCode = entries.execmaincode;
if (execMainCode) info.execMainCode = execMainCode;
return info;
}

74
src/infra/ports-format.ts Normal file
View File

@@ -0,0 +1,74 @@
import type {
PortListener,
PortListenerKind,
PortUsage,
} from "./ports-types.js";
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;
}

250
src/infra/ports-inspect.ts Normal file
View File

@@ -0,0 +1,250 @@
import net from "node:net";
import { runCommandWithTimeout } from "../process/exec.js";
import { buildPortHints } from "./ports-format.js";
import type {
PortListener,
PortUsage,
PortUsageStatus,
} from "./ports-types.js";
type CommandResult = {
stdout: string;
stderr: string;
code: number;
error?: string;
};
function isErrno(err: unknown): err is NodeJS.ErrnoException {
return Boolean(err && typeof err === "object" && "code" in err);
}
async function runCommandSafe(
argv: string[],
timeoutMs = 5_000,
): Promise<CommandResult> {
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<string | undefined> {
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<string | undefined> {
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<string | undefined> {
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<string | undefined> {
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<PortUsageStatus> {
try {
await new Promise<void>((resolve, reject) => {
const tester = net
.createServer()
.once("error", (err) => reject(err))
.once("listening", () => {
tester.close(() => resolve());
})
.listen(port);
});
return "free";
} catch (err) {
if (isErrno(err) && err.code === "EADDRINUSE") return "busy";
return "unknown";
}
}
export async function inspectPortUsage(port: number): Promise<PortUsage> {
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,
};
}

20
src/infra/ports-types.ts Normal file
View File

@@ -0,0 +1,20 @@
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[];
};
export type PortListenerKind = "gateway" | "ssh" | "unknown";

View File

@@ -1,8 +1,16 @@
import net from "node:net";
import { danger, info, shouldLogVerbose, warn } from "../globals.js";
import { logDebug } from "../logger.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { formatPortDiagnostics } from "./ports-format.js";
import { inspectPortUsage } from "./ports-inspect.js";
import type {
PortListener,
PortListenerKind,
PortUsage,
PortUsageStatus,
} from "./ports-types.js";
class PortInUseError extends Error {
port: number;
@@ -94,332 +102,10 @@ 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<CommandResult> {
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<string | undefined> {
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<string | undefined> {
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<string | undefined> {
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<string | undefined> {
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<PortUsageStatus> {
try {
await new Promise<void>((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<PortUsage> {
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,
};
}
export type { PortListener, PortListenerKind, PortUsage, PortUsageStatus };
export {
buildPortHints,
classifyPortListener,
formatPortDiagnostics,
} from "./ports-format.js";
export { inspectPortUsage } from "./ports-inspect.js";