feat(status): improve status output

This commit is contained in:
Peter Steinberger
2026-01-10 23:31:24 +01:00
parent 67b7877bbf
commit 1eb50ffac4
25 changed files with 2382 additions and 40 deletions

View File

@@ -6,7 +6,6 @@ import { isRich, theme } from "../terminal/theme.js";
const SEARCH_TOOL = "https://docs.clawd.bot/mcp.SearchClawdbot";
const SEARCH_TIMEOUT_MS = 30_000;
const RENDER_TIMEOUT_MS = 10_000;
const DEFAULT_SNIPPET_MAX = 220;
type DocResult = {
@@ -154,20 +153,6 @@ function renderRichResults(
}
async function renderMarkdown(markdown: string, runtime: RuntimeEnv) {
const width = process.stdout.columns ?? 0;
const args = width > 0 ? ["--width", String(width)] : [];
try {
const res = await runTool("markdansi", args, {
timeoutMs: RENDER_TIMEOUT_MS,
input: markdown,
});
if (res.code === 0 && res.stdout.trim()) {
runtime.log(res.stdout.trimEnd());
return;
}
} catch {
// Fall back to plain Markdown if renderer fails or cannot be installed.
}
runtime.log(markdown.trimEnd());
}

567
src/commands/status-all.ts Normal file
View File

@@ -0,0 +1,567 @@
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import {
loadConfig,
readConfigFileSnapshot,
resolveGatewayPort,
} from "../config/config.js";
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
import { resolveGatewayLogPaths } from "../daemon/launchd.js";
import { resolveGatewayService } from "../daemon/service.js";
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
import { probeGateway } from "../gateway/probe.js";
import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js";
import { resolveOsSummary } from "../infra/os-summary.js";
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
import { collectProvidersStatusIssues } from "../infra/providers-status-issues.js";
import {
readRestartSentinel,
summarizeRestartSentinel,
} from "../infra/restart-sentinel.js";
import {
checkUpdateStatus,
compareSemverStrings,
} from "../infra/update-check.js";
import type { RuntimeEnv } from "../runtime.js";
import { renderTable } from "../terminal/table.js";
import { isRich, theme } from "../terminal/theme.js";
import { VERSION } from "../version.js";
import { resolveControlUiLinks } from "./onboard-helpers.js";
import { getAgentLocalStatuses } from "./status-all/agents.js";
import {
formatAge,
formatDuration,
redactSecrets,
} from "./status-all/format.js";
import {
pickGatewaySelfPresence,
readFileTailLines,
} from "./status-all/gateway.js";
import { buildProvidersTable } from "./status-all/providers.js";
export async function statusAllCommand(
runtime: RuntimeEnv,
opts?: { timeoutMs?: number },
): Promise<void> {
const cfg = loadConfig();
const osSummary = resolveOsSummary();
const snap = await readConfigFileSnapshot().catch(() => null);
const root = await resolveClawdbotPackageRoot({
moduleUrl: import.meta.url,
argv1: process.argv[1],
cwd: process.cwd(),
});
const update = await checkUpdateStatus({
root,
timeoutMs: 6500,
fetchGit: true,
includeRegistry: true,
});
const connection = buildGatewayConnectionDetails({ config: cfg });
const isRemoteMode = cfg.gateway?.mode === "remote";
const remoteUrlRaw =
typeof cfg.gateway?.remote?.url === "string"
? cfg.gateway.remote.url.trim()
: "";
const remoteUrlMissing = isRemoteMode && !remoteUrlRaw;
const gatewayMode = isRemoteMode ? "remote" : "local";
const resolveProbeAuth = (mode: "local" | "remote") => {
const authToken = cfg.gateway?.auth?.token;
const authPassword = cfg.gateway?.auth?.password;
const remote = cfg.gateway?.remote;
const token =
mode === "remote"
? typeof remote?.token === "string" && remote.token.trim()
? remote.token.trim()
: undefined
: process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() ||
(typeof authToken === "string" && authToken.trim()
? authToken.trim()
: undefined);
const password =
process.env.CLAWDBOT_GATEWAY_PASSWORD?.trim() ||
(mode === "remote"
? typeof remote?.password === "string" && remote.password.trim()
? remote.password.trim()
: undefined
: typeof authPassword === "string" && authPassword.trim()
? authPassword.trim()
: undefined);
return { token, password };
};
const localFallbackAuth = resolveProbeAuth("local");
const remoteAuth = resolveProbeAuth("remote");
const gatewayProbe = await probeGateway({
url: connection.url,
auth: remoteUrlMissing ? localFallbackAuth : remoteAuth,
timeoutMs: Math.min(5000, opts?.timeoutMs ?? 10_000),
}).catch(() => null);
const gatewayReachable = gatewayProbe?.ok === true;
const gatewaySelf = pickGatewaySelfPresence(gatewayProbe?.presence ?? null);
const daemon = await (async () => {
try {
const service = resolveGatewayService();
const [loaded, runtimeInfo] = await Promise.all([
service.isLoaded({ env: process.env }).catch(() => false),
service.readRuntime(process.env).catch(() => undefined),
]);
return {
label: service.label,
loaded,
loadedText: loaded ? service.loadedText : service.notLoadedText,
runtime: runtimeInfo,
};
} catch {
return null;
}
})();
const agentStatus = await getAgentLocalStatuses(cfg);
const providers = await buildProvidersTable(cfg);
const connectionDetailsForReport = (() => {
if (!remoteUrlMissing) return connection.message;
const bindMode = cfg.gateway?.bind ?? "loopback";
const configPath = snap?.path?.trim()
? snap.path.trim()
: "(unknown config path)";
return [
"Gateway mode: remote",
"Gateway target: (missing gateway.remote.url)",
`Config: ${configPath}`,
`Bind: ${bindMode}`,
`Local fallback (used for probes): ${connection.url}`,
"Fix: set gateway.remote.url, or set gateway.mode=local.",
].join("\n");
})();
const callOverrides = remoteUrlMissing
? {
url: connection.url,
token: localFallbackAuth.token,
password: localFallbackAuth.password,
}
: {};
const health = gatewayReachable
? await callGateway<unknown>({
method: "health",
timeoutMs: Math.min(8000, opts?.timeoutMs ?? 10_000),
...callOverrides,
}).catch((err) => ({ error: String(err) }))
: { error: gatewayProbe?.error ?? "gateway unreachable" };
const providersStatus = gatewayReachable
? await callGateway<Record<string, unknown>>({
method: "providers.status",
params: { probe: false, timeoutMs: opts?.timeoutMs ?? 10_000 },
timeoutMs: Math.min(8000, opts?.timeoutMs ?? 10_000),
...callOverrides,
}).catch(() => null)
: null;
const providerIssues = providersStatus
? collectProvidersStatusIssues(providersStatus)
: [];
const sentinel = await readRestartSentinel().catch(() => null);
const lastErr = await readLastGatewayErrorLine(process.env).catch(() => null);
const port = resolveGatewayPort(cfg);
const portUsage = await inspectPortUsage(port).catch(() => null);
const defaultWorkspace =
agentStatus.agents.find((a) => a.id === agentStatus.defaultId)
?.workspaceDir ??
agentStatus.agents[0]?.workspaceDir ??
null;
const skillStatus =
defaultWorkspace != null
? (() => {
try {
return buildWorkspaceSkillStatus(defaultWorkspace, { config: cfg });
} catch {
return null;
}
})()
: null;
const controlUiEnabled = cfg.gateway?.controlUi?.enabled ?? true;
const dashboard = controlUiEnabled
? resolveControlUiLinks({
port,
bind: cfg.gateway?.bind,
basePath: cfg.gateway?.controlUi?.basePath,
}).httpUrl
: null;
const updateLine = (() => {
if (update.installKind === "git" && update.git) {
const parts: string[] = [];
parts.push(update.git.branch ? `git ${update.git.branch}` : "git");
if (update.git.upstream) parts.push(`${update.git.upstream}`);
if (update.git.dirty) parts.push("dirty");
if (update.git.behind != null && update.git.ahead != null) {
if (update.git.behind === 0 && update.git.ahead === 0)
parts.push("up to date");
else if (update.git.behind > 0 && update.git.ahead === 0)
parts.push(`behind ${update.git.behind}`);
else if (update.git.behind === 0 && update.git.ahead > 0)
parts.push(`ahead ${update.git.ahead}`);
else
parts.push(
`diverged (ahead ${update.git.ahead}, behind ${update.git.behind})`,
);
}
if (update.git.fetchOk === false) parts.push("fetch failed");
if (update.deps?.status === "stale") parts.push("deps stale");
if (update.deps?.status === "missing") parts.push("deps missing");
return parts.join(" · ");
}
const parts: string[] = [];
parts.push(
update.packageManager !== "unknown" ? update.packageManager : "pkg",
);
const latest = update.registry?.latestVersion;
if (latest) {
const cmp = compareSemverStrings(VERSION, latest);
if (cmp === 0) parts.push(`latest ${latest}`);
else if (cmp != null && cmp < 0) parts.push(`update available ${latest}`);
else parts.push(`latest ${latest}`);
} else if (update.registry?.error) {
parts.push("latest unknown");
}
if (update.deps?.status === "stale") parts.push("deps stale");
if (update.deps?.status === "missing") parts.push("deps missing");
return parts.join(" · ");
})();
const gatewayTarget = remoteUrlMissing
? `fallback ${connection.url}`
: connection.url;
const gatewayStatus = gatewayReachable
? `reachable ${formatDuration(gatewayProbe?.connectLatencyMs)}`
: gatewayProbe?.error
? `unreachable (${gatewayProbe.error})`
: "unreachable";
const gatewaySelfLine =
gatewaySelf?.host ||
gatewaySelf?.ip ||
gatewaySelf?.version ||
gatewaySelf?.platform
? [
gatewaySelf.host ? gatewaySelf.host : null,
gatewaySelf.ip ? `(${gatewaySelf.ip})` : null,
gatewaySelf.version ? `app ${gatewaySelf.version}` : null,
gatewaySelf.platform ? gatewaySelf.platform : null,
]
.filter(Boolean)
.join(" ")
: null;
const aliveThresholdMs = 10 * 60_000;
const aliveAgents = agentStatus.agents.filter(
(a) => a.lastActiveAgeMs != null && a.lastActiveAgeMs <= aliveThresholdMs,
).length;
const overviewRows = [
{ Item: "Version", Value: VERSION },
{ Item: "OS", Value: osSummary.label },
{ Item: "Node", Value: process.versions.node },
{
Item: "Config",
Value: snap?.path?.trim() ? snap.path.trim() : "(unknown config path)",
},
dashboard
? { Item: "Dashboard", Value: dashboard }
: { Item: "Dashboard", Value: "disabled" },
{ Item: "Update", Value: updateLine },
{
Item: "Gateway",
Value: `${gatewayMode}${remoteUrlMissing ? " (remote.url missing)" : ""} · ${gatewayTarget} (${connection.urlSource}) · ${gatewayStatus}`,
},
gatewaySelfLine
? { Item: "Gateway self", Value: gatewaySelfLine }
: { Item: "Gateway self", Value: "unknown" },
daemon
? {
Item: "Daemon",
Value: `${daemon.label} ${daemon.loadedText}${daemon.runtime?.status ? ` · ${daemon.runtime.status}` : ""}${daemon.runtime?.pid ? ` (pid ${daemon.runtime.pid})` : ""}`,
}
: { Item: "Daemon", Value: "unknown" },
{
Item: "Agents",
Value: `${agentStatus.agents.length} total · ${agentStatus.bootstrapPendingCount} bootstrapping · ${aliveAgents} active · ${agentStatus.totalSessions} sessions`,
},
];
const rich = isRich();
const heading = (text: string) => (rich ? theme.heading(text) : text);
const ok = (text: string) => (rich ? theme.success(text) : text);
const warn = (text: string) => (rich ? theme.warn(text) : text);
const fail = (text: string) => (rich ? theme.error(text) : text);
const muted = (text: string) => (rich ? theme.muted(text) : text);
const tableWidth = process.stdout.columns ?? 120;
const overview = renderTable({
width: tableWidth,
columns: [
{ key: "Item", header: "Item", minWidth: 10 },
{ key: "Value", header: "Value", flex: true, minWidth: 24 },
],
rows: overviewRows,
});
const providerRows = providers.rows.map((row) => ({
Provider: row.provider,
Enabled: row.enabled ? ok("ON") : muted("OFF"),
Configured: row.configured
? ok("OK")
: row.enabled
? warn("WARN")
: muted("OFF"),
Detail: row.detail,
}));
const providersTable = renderTable({
width: tableWidth,
columns: [
{ key: "Provider", header: "Provider", minWidth: 10 },
{ key: "Enabled", header: "Enabled", minWidth: 7 },
{ key: "Configured", header: "Configured", minWidth: 10 },
{ key: "Detail", header: "Detail", flex: true, minWidth: 28 },
],
rows: providerRows,
});
const agentRows = agentStatus.agents.map((a) => ({
Agent: a.name?.trim() ? `${a.id} (${a.name.trim()})` : a.id,
Bootstrap:
a.bootstrapPending === true
? warn("PENDING")
: a.bootstrapPending === false
? ok("OK")
: "unknown",
Sessions: String(a.sessionsCount),
Active:
a.lastActiveAgeMs != null ? formatAge(a.lastActiveAgeMs) : "unknown",
Store: a.sessionsPath,
}));
const agentsTable = renderTable({
width: tableWidth,
columns: [
{ key: "Agent", header: "Agent", minWidth: 12 },
{ key: "Bootstrap", header: "Bootstrap", minWidth: 10 },
{ key: "Sessions", header: "Sessions", align: "right", minWidth: 8 },
{ key: "Active", header: "Active", minWidth: 10 },
{ key: "Store", header: "Store", flex: true, minWidth: 34 },
],
rows: agentRows,
});
const lines: string[] = [];
lines.push(heading("Clawdbot status --all"));
lines.push("");
lines.push(heading("Overview"));
lines.push(overview.trimEnd());
lines.push("");
lines.push(heading("Providers"));
lines.push(providersTable.trimEnd());
for (const detail of providers.details) {
lines.push("");
lines.push(heading(detail.title));
lines.push(
renderTable({
width: tableWidth,
columns: detail.columns.map((c) => ({
key: c,
header: c,
flex: c === "Notes",
minWidth: c === "Notes" ? 28 : 10,
})),
rows: detail.rows.map((r) => ({
...r,
...(r.Status === "OK"
? { Status: ok("OK") }
: r.Status === "WARN"
? { Status: warn("WARN") }
: {}),
})),
}).trimEnd(),
);
}
lines.push("");
lines.push(heading("Agents"));
lines.push(agentsTable.trimEnd());
lines.push("");
lines.push(heading("Diagnosis (read-only)"));
const emitCheck = (label: string, status: "ok" | "warn" | "fail") => {
const icon =
status === "ok" ? ok("✓") : status === "warn" ? warn("!") : fail("✗");
const colored =
status === "ok"
? ok(label)
: status === "warn"
? warn(label)
: fail(label);
lines.push(`${icon} ${colored}`);
};
lines.push("");
lines.push(`${muted("Gateway connection details:")}`);
for (const line of redactSecrets(connectionDetailsForReport)
.split("\n")
.map((l) => l.trimEnd())) {
lines.push(` ${muted(line)}`);
}
lines.push("");
if (snap) {
const status = !snap.exists ? "fail" : snap.valid ? "ok" : "warn";
emitCheck(`Config: ${snap.path ?? "(unknown)"}`, status);
const issues = [...(snap.legacyIssues ?? []), ...(snap.issues ?? [])];
const uniqueIssues = issues.filter(
(issue, index) =>
issues.findIndex(
(x) => x.path === issue.path && x.message === issue.message,
) === index,
);
for (const issue of uniqueIssues.slice(0, 12)) {
lines.push(` - ${issue.path}: ${issue.message}`);
}
if (uniqueIssues.length > 12) {
lines.push(` ${muted(`… +${uniqueIssues.length - 12} more`)}`);
}
} else {
emitCheck("Config: read failed", "warn");
}
if (remoteUrlMissing) {
lines.push("");
emitCheck(
"Gateway remote mode misconfigured (gateway.remote.url missing)",
"warn",
);
lines.push(
` ${muted("Fix: set gateway.remote.url, or set gateway.mode=local.")}`,
);
}
if (sentinel?.payload) {
emitCheck("Restart sentinel present", "warn");
lines.push(
` ${muted(`${summarizeRestartSentinel(sentinel.payload)} · ${formatAge(Date.now() - sentinel.payload.ts)}`)}`,
);
} else {
emitCheck("Restart sentinel: none", "ok");
}
const lastErrClean = lastErr?.trim() ?? "";
const isTrivialLastErr =
lastErrClean.length < 8 || lastErrClean === "}" || lastErrClean === "{";
if (lastErrClean && !isTrivialLastErr) {
lines.push("");
lines.push(`${muted("Gateway last log line:")}`);
lines.push(` ${muted(redactSecrets(lastErrClean))}`);
}
if (portUsage) {
const portOk = portUsage.listeners.length === 0;
emitCheck(`Port ${port}`, portOk ? "ok" : "warn");
if (!portOk) {
for (const line of formatPortDiagnostics(portUsage)) {
lines.push(` ${muted(line)}`);
}
}
}
if (skillStatus) {
const eligible = skillStatus.skills.filter((s) => s.eligible).length;
const missing = skillStatus.skills.filter(
(s) => s.eligible && Object.values(s.missing).some((arr) => arr.length),
).length;
emitCheck(
`Skills (${eligible} eligible · ${missing} missing requirements)`,
missing === 0 ? "ok" : "warn",
);
lines.push(` ${muted(skillStatus.workspaceDir)}`);
}
const logPaths = (() => {
try {
return resolveGatewayLogPaths(process.env);
} catch {
return null;
}
})();
if (logPaths) {
const [stderrTail, stdoutTail] = await Promise.all([
readFileTailLines(logPaths.stderrPath, 40).catch(() => []),
readFileTailLines(logPaths.stdoutPath, 40).catch(() => []),
]);
if (stderrTail.length > 0 || stdoutTail.length > 0) {
lines.push("");
lines.push(`${muted(`Gateway logs (tail): ${logPaths.logDir}`)}`);
lines.push(` ${muted(`# stderr: ${logPaths.stderrPath}`)}`);
for (const line of stderrTail.map(redactSecrets)) {
lines.push(` ${muted(line)}`);
}
lines.push(` ${muted(`# stdout: ${logPaths.stdoutPath}`)}`);
for (const line of stdoutTail.map(redactSecrets)) {
lines.push(` ${muted(line)}`);
}
}
}
if (providersStatus) {
emitCheck(
`Provider issues (${providerIssues.length || "none"})`,
providerIssues.length === 0 ? "ok" : "warn",
);
for (const issue of providerIssues.slice(0, 12)) {
const fixText = issue.fix ? ` · fix: ${issue.fix}` : "";
lines.push(
` - ${issue.provider}[${issue.accountId}] ${issue.kind}: ${issue.message}${fixText}`,
);
}
if (providerIssues.length > 12) {
lines.push(` ${muted(`… +${providerIssues.length - 12} more`)}`);
}
} else {
emitCheck(
`Provider issues skipped (gateway ${gatewayReachable ? "query failed" : "unreachable"})`,
"warn",
);
}
const healthErr = (() => {
if (!health || typeof health !== "object") return "";
const record = health as Record<string, unknown>;
if (!("error" in record)) return "";
const value = record.error;
if (!value) return "";
if (typeof value === "string") return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return "[unserializable error]";
}
})();
if (healthErr) {
lines.push("");
lines.push(`${muted("Gateway health:")}`);
lines.push(` ${muted(redactSecrets(healthErr))}`);
}
lines.push("");
lines.push(muted("Pasteable debug report. Auth tokens redacted."));
lines.push("");
runtime.log(lines.join("\n"));
}

View File

@@ -0,0 +1,77 @@
import fs from "node:fs/promises";
import path from "node:path";
import { resolveAgentWorkspaceDir } from "../../agents/agent-scope.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
import { listAgentsForGateway } from "../../gateway/session-utils.js";
async function fileExists(p: string): Promise<boolean> {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
export async function getAgentLocalStatuses(cfg: ClawdbotConfig) {
const agentList = listAgentsForGateway(cfg);
const now = Date.now();
const agents = await Promise.all(
agentList.agents.map(async (agent) => {
const workspaceDir = (() => {
try {
return resolveAgentWorkspaceDir(cfg, agent.id);
} catch {
return null;
}
})();
const bootstrapPending =
workspaceDir != null
? await fileExists(path.join(workspaceDir, "BOOTSTRAP.md"))
: null;
const sessionsPath = resolveStorePath(cfg.session?.store, {
agentId: agent.id,
});
const store = (() => {
try {
return loadSessionStore(sessionsPath);
} catch {
return {};
}
})();
const updatedAt = Object.values(store).reduce(
(max, entry) => Math.max(max, entry?.updatedAt ?? 0),
0,
);
const lastUpdatedAt = updatedAt > 0 ? updatedAt : null;
const lastActiveAgeMs = lastUpdatedAt ? now - lastUpdatedAt : null;
const sessionsCount = Object.keys(store).filter(
(k) => k !== "global" && k !== "unknown",
).length;
return {
id: agent.id,
name: agent.name,
workspaceDir,
bootstrapPending,
sessionsPath,
sessionsCount,
lastUpdatedAt,
lastActiveAgeMs,
};
}),
);
const totalSessions = agents.reduce((sum, a) => sum + a.sessionsCount, 0);
const bootstrapPendingCount = agents.reduce(
(sum, a) => sum + (a.bootstrapPending ? 1 : 0),
0,
);
return {
defaultId: agentList.defaultId,
agents,
totalSessions,
bootstrapPendingCount,
};
}

View File

@@ -0,0 +1,28 @@
export const formatAge = (ms: number | null | undefined) => {
if (!ms || ms < 0) return "unknown";
const minutes = Math.round(ms / 60_000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
if (hours < 48) return `${hours}h ago`;
const days = Math.round(hours / 24);
return `${days}d ago`;
};
export const formatDuration = (ms: number | null | undefined) => {
if (ms == null || !Number.isFinite(ms)) return "unknown";
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(1)}s`;
};
export function redactSecrets(text: string): string {
if (!text) return text;
let out = text;
out = out.replace(
/(\b(?:access[_-]?token|refresh[_-]?token|token|password|secret|api[_-]?key)\b\s*[:=]\s*)("?)([^"\\s]+)("?)/gi,
"$1$2***$4",
);
out = out.replace(/\bBearer\s+[A-Za-z0-9._-]+\b/g, "Bearer ***");
out = out.replace(/\bsk-[A-Za-z0-9]{10,}\b/g, "sk-***");
return out;
}

View File

@@ -0,0 +1,33 @@
import fs from "node:fs/promises";
export async function readFileTailLines(
filePath: string,
maxLines: number,
): Promise<string[]> {
const raw = await fs.readFile(filePath, "utf8").catch(() => "");
if (!raw.trim()) return [];
const lines = raw.replace(/\r/g, "").split("\n");
const out = lines.slice(Math.max(0, lines.length - maxLines));
return out
.map((line) => line.trimEnd())
.filter((line) => line.trim().length > 0);
}
export function pickGatewaySelfPresence(presence: unknown): {
host?: string;
ip?: string;
version?: string;
platform?: string;
} | null {
if (!Array.isArray(presence)) return null;
const entries = presence as Array<Record<string, unknown>>;
const self =
entries.find((e) => e.mode === "gateway" && e.reason === "self") ?? null;
if (!self) return null;
return {
host: typeof self.host === "string" ? self.host : undefined,
ip: typeof self.ip === "string" ? self.ip : undefined,
version: typeof self.version === "string" ? self.version : undefined,
platform: typeof self.platform === "string" ? self.platform : undefined,
};
}

View File

@@ -0,0 +1,211 @@
import type { ClawdbotConfig } from "../../config/config.js";
import {
listDiscordAccountIds,
resolveDiscordAccount,
} from "../../discord/accounts.js";
import {
listIMessageAccountIds,
resolveIMessageAccount,
} from "../../imessage/accounts.js";
import { resolveMSTeamsCredentials } from "../../msteams/token.js";
import {
listSignalAccountIds,
resolveSignalAccount,
} from "../../signal/accounts.js";
import {
listSlackAccountIds,
resolveSlackAccount,
} from "../../slack/accounts.js";
import {
listTelegramAccountIds,
resolveTelegramAccount,
} from "../../telegram/accounts.js";
import { normalizeE164 } from "../../utils.js";
import {
listWhatsAppAccountIds,
resolveWhatsAppAccount,
} from "../../web/accounts.js";
import {
getWebAuthAgeMs,
readWebSelfId,
webAuthExists,
} from "../../web/session.js";
import { formatAge } from "./format.js";
export type ProviderRow = {
provider: string;
enabled: boolean;
configured: boolean;
detail: string;
};
export async function buildProvidersTable(cfg: ClawdbotConfig): Promise<{
rows: ProviderRow[];
details: Array<{
title: string;
columns: string[];
rows: Array<Record<string, string>>;
}>;
}> {
const rows: ProviderRow[] = [];
const details: Array<{
title: string;
columns: string[];
rows: Array<Record<string, string>>;
}> = [];
// WhatsApp
const waEnabled = cfg.web?.enabled !== false;
const waLinked = waEnabled ? await webAuthExists().catch(() => false) : false;
const waAuthAgeMs = waLinked ? getWebAuthAgeMs() : null;
const waSelf = waLinked ? readWebSelfId().e164 : undefined;
const waAccounts = waLinked
? listWhatsAppAccountIds(cfg).map((accountId) =>
resolveWhatsAppAccount({ cfg, accountId }),
)
: [];
rows.push({
provider: "WhatsApp",
enabled: waEnabled,
configured: waLinked,
detail: waEnabled
? waLinked
? `linked${waSelf ? ` ${waSelf}` : ""}${waAuthAgeMs ? ` · auth ${formatAge(waAuthAgeMs)}` : ""} · accounts ${waAccounts.length || 1}`
: "not linked"
: "disabled",
});
if (waLinked) {
const waRows =
waAccounts.length > 0 ? waAccounts : [resolveWhatsAppAccount({ cfg })];
details.push({
title: "WhatsApp accounts",
columns: ["Account", "Status", "Notes"],
rows: waRows.map((account) => {
const allowFrom = (account.allowFrom ?? cfg.whatsapp?.allowFrom ?? [])
.map(normalizeE164)
.filter(Boolean)
.slice(0, 3);
const dmPolicy =
account.dmPolicy ?? cfg.whatsapp?.dmPolicy ?? "pairing";
const notes: string[] = [];
if (!account.enabled) notes.push("disabled");
if (account.selfChatMode) notes.push("self-chat");
notes.push(`dm:${dmPolicy}`);
if (allowFrom.length) notes.push(`allow:${allowFrom.join(",")}`);
return {
Account: account.name?.trim()
? `${account.accountId} (${account.name.trim()})`
: account.accountId,
Status: account.enabled ? "OK" : "WARN",
Notes: notes.join(" · "),
};
}),
});
}
// Telegram
const tgEnabled = cfg.telegram?.enabled !== false;
const tgAccounts = listTelegramAccountIds(cfg).map((accountId) =>
resolveTelegramAccount({ cfg, accountId }),
);
const tgConfigured = tgAccounts.some((a) => Boolean(a.token?.trim()));
rows.push({
provider: "Telegram",
enabled: tgEnabled,
configured: tgEnabled && tgConfigured,
detail: tgEnabled
? tgConfigured
? `accounts ${tgAccounts.filter((a) => a.token?.trim()).length}`
: "not configured"
: "disabled",
});
// Discord
const dcEnabled = cfg.discord?.enabled !== false;
const dcAccounts = listDiscordAccountIds(cfg).map((accountId) =>
resolveDiscordAccount({ cfg, accountId }),
);
const dcConfigured = dcAccounts.some((a) => Boolean(a.token?.trim()));
rows.push({
provider: "Discord",
enabled: dcEnabled,
configured: dcEnabled && dcConfigured,
detail: dcEnabled
? dcConfigured
? `accounts ${dcAccounts.filter((a) => a.token?.trim()).length}`
: "not configured"
: "disabled",
});
// Slack
const slEnabled = cfg.slack?.enabled !== false;
const slAccounts = listSlackAccountIds(cfg).map((accountId) =>
resolveSlackAccount({ cfg, accountId }),
);
const slConfigured = slAccounts.some(
(a) => Boolean(a.botToken?.trim()) && Boolean(a.appToken?.trim()),
);
rows.push({
provider: "Slack",
enabled: slEnabled,
configured: slEnabled && slConfigured,
detail: slEnabled
? slConfigured
? `accounts ${slAccounts.filter((a) => a.botToken?.trim() && a.appToken?.trim()).length}`
: "not configured"
: "disabled",
});
// Signal
const siEnabled = cfg.signal?.enabled !== false;
const siAccounts = listSignalAccountIds(cfg).map((accountId) =>
resolveSignalAccount({ cfg, accountId }),
);
const siConfigured = siAccounts.some((a) => a.configured);
rows.push({
provider: "Signal",
enabled: siEnabled,
configured: siEnabled && siConfigured,
detail: siEnabled
? siConfigured
? `accounts ${siAccounts.filter((a) => a.configured).length}`
: "not configured"
: "disabled",
});
// iMessage
const imEnabled = cfg.imessage?.enabled !== false;
const imAccounts = listIMessageAccountIds(cfg).map((accountId) =>
resolveIMessageAccount({ cfg, accountId }),
);
const imConfigured = imAccounts.some((a) => a.configured);
rows.push({
provider: "iMessage",
enabled: imEnabled,
configured: imEnabled && imConfigured,
detail: imEnabled
? imConfigured
? `accounts ${imAccounts.length}`
: "not configured"
: "disabled",
});
// MS Teams
const msEnabled = cfg.msteams?.enabled !== false;
const msConfigured = Boolean(resolveMSTeamsCredentials(cfg.msteams));
rows.push({
provider: "MS Teams",
enabled: msEnabled,
configured: msEnabled && msConfigured,
detail: msEnabled
? msConfigured
? "credentials present"
: "not configured"
: "disabled",
});
return {
rows,
details,
};
}

View File

@@ -20,6 +20,17 @@ const mocks = vi.hoisted(() => ({
getWebAuthAgeMs: vi.fn().mockReturnValue(5000),
readWebSelfId: vi.fn().mockReturnValue({ e164: "+1999" }),
logWebSelfId: vi.fn(),
probeGateway: vi.fn().mockResolvedValue({
ok: false,
url: "ws://127.0.0.1:18789",
connectLatencyMs: null,
error: "timeout",
close: null,
health: null,
status: null,
presence: null,
configSnapshot: null,
}),
}));
vi.mock("../config/sessions.js", () => ({
@@ -33,6 +44,52 @@ vi.mock("../web/session.js", () => ({
readWebSelfId: mocks.readWebSelfId,
logWebSelfId: mocks.logWebSelfId,
}));
vi.mock("../gateway/probe.js", () => ({
probeGateway: mocks.probeGateway,
}));
vi.mock("../gateway/session-utils.js", () => ({
listAgentsForGateway: () => ({
defaultId: "main",
mainKey: "agent:main:main",
scope: "per-sender",
agents: [{ id: "main", name: "Main" }],
}),
}));
vi.mock("../infra/clawdbot-root.js", () => ({
resolveClawdbotPackageRoot: vi.fn().mockResolvedValue("/tmp/clawdbot"),
}));
vi.mock("../infra/os-summary.js", () => ({
resolveOsSummary: () => ({
platform: "darwin",
arch: "arm64",
release: "23.0.0",
label: "macos 14.0 (arm64)",
}),
}));
vi.mock("../infra/update-check.js", () => ({
checkUpdateStatus: vi.fn().mockResolvedValue({
root: "/tmp/clawdbot",
installKind: "git",
packageManager: "pnpm",
git: {
root: "/tmp/clawdbot",
branch: "main",
upstream: "origin/main",
dirty: false,
ahead: 0,
behind: 0,
fetchOk: true,
},
deps: {
manager: "pnpm",
status: "ok",
lockfilePath: "/tmp/clawdbot/pnpm-lock.yaml",
markerPath: "/tmp/clawdbot/node_modules/.modules.yaml",
},
registry: { latestVersion: "0.0.0" },
}),
compareSemverStrings: vi.fn(() => 0),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {

View File

@@ -1,3 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import { lookupContextTokens } from "../agents/context.js";
import {
DEFAULT_CONTEXT_TOKENS,
@@ -5,24 +9,47 @@ import {
DEFAULT_PROVIDER,
} from "../agents/defaults.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import { withProgress } from "../cli/progress.js";
import { loadConfig, resolveGatewayPort } from "../config/config.js";
import {
loadConfig,
readConfigFileSnapshot,
resolveGatewayPort,
} from "../config/config.js";
import {
loadSessionStore,
resolveMainSessionKey,
resolveStorePath,
type SessionEntry,
} from "../config/sessions.js";
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
import { resolveGatewayService } from "../daemon/service.js";
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
import { probeGateway } from "../gateway/probe.js";
import { listAgentsForGateway } from "../gateway/session-utils.js";
import { info } from "../globals.js";
import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js";
import { resolveOsSummary } from "../infra/os-summary.js";
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
import { buildProviderSummary } from "../infra/provider-summary.js";
import {
formatUsageReportLines,
loadProviderUsageSummary,
} from "../infra/provider-usage.js";
import { collectProvidersStatusIssues } from "../infra/providers-status-issues.js";
import {
readRestartSentinel,
summarizeRestartSentinel,
} from "../infra/restart-sentinel.js";
import { peekSystemEvents } from "../infra/system-events.js";
import {
checkUpdateStatus,
compareSemverStrings,
type UpdateCheckResult,
} from "../infra/update-check.js";
import type { RuntimeEnv } from "../runtime.js";
import { theme } from "../terminal/theme.js";
import { VERSION } from "../version.js";
import { resolveWhatsAppAccount } from "../web/accounts.js";
import { resolveHeartbeatSeconds } from "../web/reconnect.js";
import {
@@ -32,6 +59,7 @@ import {
} from "../web/session.js";
import type { HealthSummary } from "./health.js";
import { resolveControlUiLinks } from "./onboard-helpers.js";
import { statusAllCommand } from "./status-all.js";
export type SessionStatus = {
key: string;
@@ -172,6 +200,12 @@ const formatAge = (ms: number | null | undefined) => {
return `${days}d ago`;
};
const formatDuration = (ms: number | null | undefined) => {
if (ms == null || !Number.isFinite(ms)) return "unknown";
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(1)}s`;
};
const formatContextUsage = (
total: number | null | undefined,
contextTokens: number | null | undefined,
@@ -236,6 +270,230 @@ async function getDaemonShortLine(): Promise<string | null> {
}
}
type AgentLocalStatus = {
id: string;
name?: string;
workspaceDir: string | null;
bootstrapPending: boolean | null;
sessionsPath: string;
sessionsCount: number;
lastUpdatedAt: number | null;
lastActiveAgeMs: number | null;
};
async function fileExists(p: string): Promise<boolean> {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
async function getAgentLocalStatuses(): Promise<{
defaultId: string;
agents: AgentLocalStatus[];
totalSessions: number;
bootstrapPendingCount: number;
}> {
const cfg = loadConfig();
const agentList = listAgentsForGateway(cfg);
const now = Date.now();
const statuses: AgentLocalStatus[] = [];
for (const agent of agentList.agents) {
const agentId = agent.id;
const workspaceDir = (() => {
try {
return resolveAgentWorkspaceDir(cfg, agentId);
} catch {
return null;
}
})();
const bootstrapPath =
workspaceDir != null ? path.join(workspaceDir, "BOOTSTRAP.md") : null;
const bootstrapPending =
bootstrapPath != null ? await fileExists(bootstrapPath) : null;
const sessionsPath = resolveStorePath(cfg.session?.store, { agentId });
const store = (() => {
try {
return loadSessionStore(sessionsPath);
} catch {
return {};
}
})();
const sessions = Object.entries(store)
.filter(([key]) => key !== "global" && key !== "unknown")
.map(([, entry]) => entry);
const sessionsCount = sessions.length;
const lastUpdatedAt = sessions.reduce(
(max, e) => Math.max(max, e?.updatedAt ?? 0),
0,
);
const resolvedLastUpdatedAt = lastUpdatedAt > 0 ? lastUpdatedAt : null;
const lastActiveAgeMs = resolvedLastUpdatedAt
? now - resolvedLastUpdatedAt
: null;
statuses.push({
id: agentId,
name: agent.name,
workspaceDir,
bootstrapPending,
sessionsPath,
sessionsCount,
lastUpdatedAt: resolvedLastUpdatedAt,
lastActiveAgeMs,
});
}
const totalSessions = statuses.reduce((sum, s) => sum + s.sessionsCount, 0);
const bootstrapPendingCount = statuses.reduce(
(sum, s) => sum + (s.bootstrapPending ? 1 : 0),
0,
);
return {
defaultId: agentList.defaultId,
agents: statuses,
totalSessions,
bootstrapPendingCount,
};
}
function resolveGatewayProbeAuth(cfg: ReturnType<typeof loadConfig>): {
token?: string;
password?: string;
} {
const isRemoteMode = cfg.gateway?.mode === "remote";
const remote = isRemoteMode ? cfg.gateway?.remote : undefined;
const authToken = cfg.gateway?.auth?.token;
const authPassword = cfg.gateway?.auth?.password;
const token = isRemoteMode
? typeof remote?.token === "string" && remote.token.trim().length > 0
? remote.token.trim()
: undefined
: process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() ||
(typeof authToken === "string" && authToken.trim().length > 0
? authToken.trim()
: undefined);
const password =
process.env.CLAWDBOT_GATEWAY_PASSWORD?.trim() ||
(isRemoteMode
? typeof remote?.password === "string" &&
remote.password.trim().length > 0
? remote.password.trim()
: undefined
: typeof authPassword === "string" && authPassword.trim().length > 0
? authPassword.trim()
: undefined);
return { token, password };
}
function pickGatewaySelfPresence(presence: unknown): {
host?: string;
ip?: string;
version?: string;
platform?: string;
} | null {
if (!Array.isArray(presence)) return null;
const entries = presence as Array<Record<string, unknown>>;
const self =
entries.find((e) => e.mode === "gateway" && e.reason === "self") ?? null;
if (!self) return null;
return {
host: typeof self.host === "string" ? self.host : undefined,
ip: typeof self.ip === "string" ? self.ip : undefined,
version: typeof self.version === "string" ? self.version : undefined,
platform: typeof self.platform === "string" ? self.platform : undefined,
};
}
async function getUpdateCheckResult(params: {
timeoutMs: number;
fetchGit: boolean;
includeRegistry: boolean;
}): Promise<UpdateCheckResult> {
const root = await resolveClawdbotPackageRoot({
moduleUrl: import.meta.url,
argv1: process.argv[1],
cwd: process.cwd(),
});
return await checkUpdateStatus({
root,
timeoutMs: params.timeoutMs,
fetchGit: params.fetchGit,
includeRegistry: params.includeRegistry,
});
}
function formatUpdateOneLiner(update: UpdateCheckResult): string {
const parts: string[] = [];
if (update.installKind === "git" && update.git) {
const branch = update.git.branch ? `git ${update.git.branch}` : "git";
parts.push(branch);
if (update.git.upstream) parts.push(`${update.git.upstream}`);
if (update.git.dirty === true) parts.push("dirty");
if (update.git.behind != null && update.git.ahead != null) {
if (update.git.behind === 0 && update.git.ahead === 0) {
parts.push("up to date");
} else if (update.git.behind > 0 && update.git.ahead === 0) {
parts.push(`behind ${update.git.behind}`);
} else if (update.git.behind === 0 && update.git.ahead > 0) {
parts.push(`ahead ${update.git.ahead}`);
} else if (update.git.behind > 0 && update.git.ahead > 0) {
parts.push(
`diverged (ahead ${update.git.ahead}, behind ${update.git.behind})`,
);
}
}
if (update.git.fetchOk === false) parts.push("fetch failed");
} else {
parts.push(
update.packageManager !== "unknown" ? update.packageManager : "pkg",
);
if (update.registry?.latestVersion) {
const cmp = compareSemverStrings(VERSION, update.registry.latestVersion);
if (cmp === 0) parts.push(`latest ${update.registry.latestVersion}`);
else if (cmp != null && cmp < 0) {
parts.push(`update available ${update.registry.latestVersion}`);
} else {
parts.push(`latest ${update.registry.latestVersion}`);
}
} else if (update.registry?.error) {
parts.push("latest unknown");
}
}
if (update.deps) {
if (update.deps.status === "ok") parts.push("deps ok");
if (update.deps.status === "missing") parts.push("deps missing");
if (update.deps.status === "stale") parts.push("deps stale");
}
return `Update: ${parts.join(" · ")}`;
}
function formatCheckLine(params: {
ok: boolean;
label: string;
detail?: string | null;
warn?: boolean;
}) {
const symbol = params.ok
? theme.success("\u2713")
: params.warn
? theme.warn("!")
: theme.error("\u2717");
const label = params.ok
? theme.success(params.label)
: params.warn
? theme.warn(params.label)
: theme.error(params.label);
const detail = params.detail?.trim() ? ` ${theme.muted(params.detail)}` : "";
return `${symbol} ${label}${detail}`;
}
const buildFlags = (entry: SessionEntry): string[] => {
const flags: string[] = [];
const think = entry?.thinkingLevel;
@@ -265,11 +523,101 @@ export async function statusCommand(
usage?: boolean;
timeoutMs?: number;
verbose?: boolean;
all?: boolean;
},
runtime: RuntimeEnv,
) {
const cfg = loadConfig();
const summary = await getStatusSummary();
if (opts.all && !opts.json) {
await statusAllCommand(runtime, { timeoutMs: opts.timeoutMs });
return;
}
const scan = await withProgress(
{
label: "Scanning status…",
total: 6,
enabled: opts.json !== true,
},
async (progress) => {
progress.setLabel("Loading config…");
const cfg = loadConfig();
const osSummary = resolveOsSummary();
progress.tick();
progress.setLabel("Checking for updates…");
const updateTimeoutMs = opts.all ? 6500 : 2500;
const update = await getUpdateCheckResult({
timeoutMs: updateTimeoutMs,
fetchGit: true,
includeRegistry: true,
});
progress.tick();
progress.setLabel("Resolving agents…");
const agentStatus = await getAgentLocalStatuses();
progress.tick();
progress.setLabel("Probing gateway…");
const gatewayConnection = buildGatewayConnectionDetails();
const isRemoteMode = cfg.gateway?.mode === "remote";
const remoteUrlRaw =
typeof cfg.gateway?.remote?.url === "string"
? cfg.gateway.remote.url
: "";
const remoteUrlMissing = isRemoteMode && !remoteUrlRaw.trim();
const gatewayMode = isRemoteMode ? "remote" : "local";
const gatewayProbe = remoteUrlMissing
? null
: await probeGateway({
url: gatewayConnection.url,
auth: resolveGatewayProbeAuth(cfg),
timeoutMs: Math.min(
opts.all ? 5000 : 2500,
opts.timeoutMs ?? 10_000,
),
}).catch(() => null);
const gatewayReachable = gatewayProbe?.ok === true;
const gatewaySelf = gatewayProbe?.presence
? pickGatewaySelfPresence(gatewayProbe.presence)
: null;
progress.tick();
progress.setLabel("Reading sessions…");
const summary = await getStatusSummary();
progress.tick();
progress.setLabel("Rendering…");
progress.tick();
return {
cfg,
osSummary,
update,
gatewayConnection,
remoteUrlMissing,
gatewayMode,
gatewayProbe,
gatewayReachable,
gatewaySelf,
agentStatus,
summary,
};
},
);
const {
cfg,
osSummary,
update,
gatewayConnection,
remoteUrlMissing,
gatewayMode,
gatewayProbe,
gatewayReachable,
gatewaySelf,
agentStatus,
summary,
} = scan;
const usage = opts.usage
? await withProgress(
{
@@ -299,7 +647,23 @@ export async function statusCommand(
if (opts.json) {
runtime.log(
JSON.stringify(
health || usage ? { ...summary, health, usage } : summary,
{
...summary,
os: osSummary,
update,
gateway: {
mode: gatewayMode,
url: gatewayConnection.url,
urlSource: gatewayConnection.urlSource,
misconfigured: remoteUrlMissing,
reachable: gatewayReachable,
connectLatencyMs: gatewayProbe?.connectLatencyMs ?? null,
self: gatewaySelf,
error: gatewayProbe?.error ?? null,
},
agents: agentStatus,
...(health || usage ? { health, usage } : {}),
},
null,
2,
),
@@ -307,7 +671,7 @@ export async function statusCommand(
return;
}
if (opts.verbose) {
if (opts.verbose || opts.all) {
const details = buildGatewayConnectionDetails();
runtime.log(info("Gateway connection:"));
for (const line of details.message.split("\n")) {
@@ -326,6 +690,50 @@ export async function statusCommand(
});
runtime.log(info(`Dashboard: ${links.httpUrl}`));
}
runtime.log(info(`OS: ${osSummary.label} · node ${process.versions.node}`));
runtime.log(info(formatUpdateOneLiner(update)));
const gatewayLine = (() => {
const target = remoteUrlMissing
? "(missing gateway.remote.url)"
: gatewayConnection.url;
const reach = remoteUrlMissing
? "misconfigured (missing gateway.remote.url)"
: gatewayReachable
? `reachable (${formatDuration(gatewayProbe?.connectLatencyMs)})`
: gatewayProbe?.error
? `unreachable (${gatewayProbe.error})`
: "unreachable";
const self =
gatewaySelf?.host || gatewaySelf?.version || gatewaySelf?.platform
? [
gatewaySelf?.host ? gatewaySelf.host : null,
gatewaySelf?.ip ? `(${gatewaySelf.ip})` : null,
gatewaySelf?.version ? `app ${gatewaySelf.version}` : null,
gatewaySelf?.platform ? gatewaySelf.platform : null,
]
.filter(Boolean)
.join(" ")
: null;
const suffix = self ? ` · ${self}` : "";
return `Gateway: ${gatewayMode} · ${target} · ${reach}${suffix}`;
})();
runtime.log(info(gatewayLine));
const agentLine = (() => {
const pending =
agentStatus.bootstrapPendingCount > 0
? `${agentStatus.bootstrapPendingCount} bootstrapping`
: "no bootstraps";
const def = agentStatus.agents.find((a) => a.id === agentStatus.defaultId);
const defActive =
def?.lastActiveAgeMs != null ? formatAge(def.lastActiveAgeMs) : "unknown";
const defSuffix = def ? ` · default ${def.id} active ${defActive}` : "";
return `Agents: ${agentStatus.agents.length} · ${pending} · sessions ${agentStatus.totalSessions}${defSuffix}`;
})();
runtime.log(info(agentLine));
runtime.log(
`Web session: ${summary.web.linked ? "linked" : "not linked"}${summary.web.linked ? ` (last refreshed ${formatAge(summary.web.authAgeMs)})` : ""}`,
);
@@ -342,6 +750,217 @@ export async function statusCommand(
if (daemonLine) {
runtime.log(info(daemonLine));
}
if (opts.all) {
runtime.log("");
runtime.log(theme.heading("Diagnosis (read-only):"));
const snap = await readConfigFileSnapshot().catch(() => null);
if (snap) {
runtime.log(
formatCheckLine({
ok: Boolean(snap.exists && snap.valid),
warn: Boolean(snap.exists && !snap.valid),
label: `Config: ${snap.path ?? "(unknown)"}`,
detail: snap.exists
? snap.valid
? "valid"
: `invalid (${snap.issues.length} issues)`
: "missing",
}),
);
const issues = [...(snap.legacyIssues ?? []), ...(snap.issues ?? [])];
const uniqueIssues = issues.filter(
(issue, index) =>
issues.findIndex(
(x) => x.path === issue.path && x.message === issue.message,
) === index,
);
for (const issue of uniqueIssues.slice(0, 12)) {
runtime.log(` - ${issue.path}: ${issue.message}`);
}
if (uniqueIssues.length > 12) {
runtime.log(theme.muted(` … +${uniqueIssues.length - 12} more`));
}
} else {
runtime.log(
formatCheckLine({
ok: false,
label: "Config: unknown",
detail: "read failed",
}),
);
}
const sentinel = await readRestartSentinel().catch(() => null);
if (sentinel?.payload) {
runtime.log(
formatCheckLine({
ok: true,
label: "Restart sentinel",
detail: `${summarizeRestartSentinel(sentinel.payload)} · ${formatAge(Date.now() - sentinel.payload.ts)}`,
warn: true,
}),
);
} else {
runtime.log(
formatCheckLine({
ok: true,
label: "Restart sentinel",
detail: "none",
}),
);
}
const lastErr = await readLastGatewayErrorLine(process.env).catch(
() => null,
);
if (lastErr) {
runtime.log(
formatCheckLine({
ok: true,
warn: true,
label: "Gateway last log line",
detail: lastErr,
}),
);
} else {
runtime.log(
formatCheckLine({
ok: true,
label: "Gateway last log line",
detail: "none",
}),
);
}
const port = resolveGatewayPort(cfg);
const portUsage = await inspectPortUsage(port).catch(() => null);
if (portUsage) {
const ok = portUsage.listeners.length === 0;
runtime.log(
formatCheckLine({
ok,
warn: !ok,
label: `Port ${port}`,
detail: ok ? "free" : "in use",
}),
);
if (!ok) {
for (const line of formatPortDiagnostics(portUsage)) {
runtime.log(` ${line}`);
}
}
}
const defaultWorkspace =
agentStatus.agents.find((a) => a.id === agentStatus.defaultId)
?.workspaceDir ??
agentStatus.agents[0]?.workspaceDir ??
null;
const skillStatus =
defaultWorkspace != null
? (() => {
try {
return buildWorkspaceSkillStatus(defaultWorkspace, {
config: cfg,
});
} catch {
return null;
}
})()
: null;
if (skillStatus) {
const eligible = skillStatus.skills.filter((s) => s.eligible).length;
const missing = skillStatus.skills.filter(
(s) => s.eligible && Object.values(s.missing).some((arr) => arr.length),
).length;
runtime.log(
formatCheckLine({
ok: missing === 0,
warn: missing > 0,
label: "Skills",
detail: `${eligible} eligible · ${missing} missing requirements · ${skillStatus.workspaceDir}`,
}),
);
}
runtime.log("");
runtime.log(theme.heading("Agents:"));
for (const agent of agentStatus.agents) {
const name = agent.name ? ` (${agent.name})` : "";
const bootstrap =
agent.bootstrapPending === true
? theme.warn("BOOTSTRAP.md pending")
: agent.bootstrapPending === false
? theme.success("bootstrapped")
: theme.muted("bootstrap unknown");
const active =
agent.lastActiveAgeMs != null
? formatAge(agent.lastActiveAgeMs)
: "unknown";
runtime.log(
`- ${theme.info(agent.id)}${name} · ${bootstrap} · sessions ${agent.sessionsCount} · active ${active}`,
);
if (agent.workspaceDir)
runtime.log(theme.muted(` workspace: ${agent.workspaceDir}`));
runtime.log(theme.muted(` sessions: ${agent.sessionsPath}`));
}
if (gatewayReachable) {
const providersStatus = await callGateway<Record<string, unknown>>({
method: "providers.status",
params: { probe: false, timeoutMs: opts.timeoutMs ?? 10_000 },
timeoutMs: Math.min(8000, opts.timeoutMs ?? 10_000),
}).catch(() => null);
if (providersStatus) {
const issues = collectProvidersStatusIssues(providersStatus);
runtime.log(
formatCheckLine({
ok: issues.length === 0,
warn: issues.length > 0,
label: "Provider config/runtime issues",
detail: issues.length ? String(issues.length) : "none",
}),
);
for (const issue of issues.slice(0, 8)) {
runtime.log(
` - ${issue.provider}[${issue.accountId}] ${issue.kind}: ${issue.message}`,
);
if (issue.fix) runtime.log(theme.muted(` fix: ${issue.fix}`));
}
if (issues.length > 8) {
runtime.log(theme.muted(` … +${issues.length - 8} more`));
}
} else {
runtime.log(
formatCheckLine({
ok: false,
warn: true,
label: "Provider config/runtime issues",
detail: "skipped (gateway query failed)",
}),
);
}
} else {
runtime.log(
formatCheckLine({
ok: false,
warn: true,
label: "Provider config/runtime issues",
detail: "skipped (gateway unreachable)",
}),
);
}
runtime.log("");
runtime.log(
theme.muted(
"Tip: This output is safe to paste for debugging (no tokens).",
),
);
}
runtime.log("");
if (health) {
runtime.log(info("Gateway health: reachable"));