941 lines
30 KiB
TypeScript
941 lines
30 KiB
TypeScript
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,
|
|
DEFAULT_MODEL,
|
|
DEFAULT_PROVIDER,
|
|
} from "../agents/defaults.js";
|
|
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
|
import { withProgress } from "../cli/progress.js";
|
|
import { loadConfig, resolveGatewayPort } from "../config/config.js";
|
|
import {
|
|
loadSessionStore,
|
|
resolveMainSessionKey,
|
|
resolveStorePath,
|
|
type SessionEntry,
|
|
} from "../config/sessions.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 { buildProviderSummary } from "../infra/provider-summary.js";
|
|
import {
|
|
formatUsageReportLines,
|
|
loadProviderUsageSummary,
|
|
} from "../infra/provider-usage.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 { renderTable } from "../terminal/table.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 { getWebAuthAgeMs, webAuthExists } from "../web/session.js";
|
|
import type { HealthSummary } from "./health.js";
|
|
import { resolveControlUiLinks } from "./onboard-helpers.js";
|
|
import { formatGatewayAuthUsed } from "./status-all/format.js";
|
|
import { buildProvidersTable } from "./status-all/providers.js";
|
|
import { statusAllCommand } from "./status-all.js";
|
|
|
|
export type SessionStatus = {
|
|
key: string;
|
|
kind: "direct" | "group" | "global" | "unknown";
|
|
sessionId?: string;
|
|
updatedAt: number | null;
|
|
age: number | null;
|
|
thinkingLevel?: string;
|
|
verboseLevel?: string;
|
|
reasoningLevel?: string;
|
|
elevatedLevel?: string;
|
|
systemSent?: boolean;
|
|
abortedLastRun?: boolean;
|
|
inputTokens?: number;
|
|
outputTokens?: number;
|
|
totalTokens: number | null;
|
|
remainingTokens: number | null;
|
|
percentUsed: number | null;
|
|
model: string | null;
|
|
contextTokens: number | null;
|
|
flags: string[];
|
|
};
|
|
|
|
export type StatusSummary = {
|
|
web: { linked: boolean; authAgeMs: number | null };
|
|
heartbeatSeconds: number;
|
|
providerSummary: string[];
|
|
queuedSystemEvents: string[];
|
|
sessions: {
|
|
path: string;
|
|
count: number;
|
|
defaults: { model: string | null; contextTokens: number | null };
|
|
recent: SessionStatus[];
|
|
};
|
|
};
|
|
|
|
export async function getStatusSummary(): Promise<StatusSummary> {
|
|
const cfg = loadConfig();
|
|
const account = resolveWhatsAppAccount({ cfg });
|
|
const linked = await webAuthExists(account.authDir);
|
|
const authAgeMs = getWebAuthAgeMs(account.authDir);
|
|
const heartbeatSeconds = resolveHeartbeatSeconds(cfg, undefined);
|
|
const providerSummary = await buildProviderSummary(cfg, {
|
|
colorize: true,
|
|
includeAllowFrom: true,
|
|
});
|
|
const mainSessionKey = resolveMainSessionKey(cfg);
|
|
const queuedSystemEvents = peekSystemEvents(mainSessionKey);
|
|
|
|
const resolved = resolveConfiguredModelRef({
|
|
cfg,
|
|
defaultProvider: DEFAULT_PROVIDER,
|
|
defaultModel: DEFAULT_MODEL,
|
|
});
|
|
const configModel = resolved.model ?? DEFAULT_MODEL;
|
|
const configContextTokens =
|
|
cfg.agents?.defaults?.contextTokens ??
|
|
lookupContextTokens(configModel) ??
|
|
DEFAULT_CONTEXT_TOKENS;
|
|
|
|
const storePath = resolveStorePath(cfg.session?.store);
|
|
const store = loadSessionStore(storePath);
|
|
const now = Date.now();
|
|
const sessions = Object.entries(store)
|
|
.filter(([key]) => key !== "global" && key !== "unknown")
|
|
.map(([key, entry]) => {
|
|
const updatedAt = entry?.updatedAt ?? null;
|
|
const age = updatedAt ? now - updatedAt : null;
|
|
const model = entry?.model ?? configModel ?? null;
|
|
const contextTokens =
|
|
entry?.contextTokens ??
|
|
lookupContextTokens(model) ??
|
|
configContextTokens ??
|
|
null;
|
|
const input = entry?.inputTokens ?? 0;
|
|
const output = entry?.outputTokens ?? 0;
|
|
const total = entry?.totalTokens ?? input + output;
|
|
const remaining =
|
|
contextTokens != null ? Math.max(0, contextTokens - total) : null;
|
|
const pct =
|
|
contextTokens && contextTokens > 0
|
|
? Math.min(999, Math.round((total / contextTokens) * 100))
|
|
: null;
|
|
|
|
return {
|
|
key,
|
|
kind: classifyKey(key, entry),
|
|
sessionId: entry?.sessionId,
|
|
updatedAt,
|
|
age,
|
|
thinkingLevel: entry?.thinkingLevel,
|
|
verboseLevel: entry?.verboseLevel,
|
|
reasoningLevel: entry?.reasoningLevel,
|
|
elevatedLevel: entry?.elevatedLevel,
|
|
systemSent: entry?.systemSent,
|
|
abortedLastRun: entry?.abortedLastRun,
|
|
inputTokens: entry?.inputTokens,
|
|
outputTokens: entry?.outputTokens,
|
|
totalTokens: total ?? null,
|
|
remainingTokens: remaining,
|
|
percentUsed: pct,
|
|
model,
|
|
contextTokens,
|
|
flags: buildFlags(entry),
|
|
} satisfies SessionStatus;
|
|
})
|
|
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
|
const recent = sessions.slice(0, 5);
|
|
|
|
return {
|
|
web: { linked, authAgeMs },
|
|
heartbeatSeconds,
|
|
providerSummary,
|
|
queuedSystemEvents,
|
|
sessions: {
|
|
path: storePath,
|
|
count: sessions.length,
|
|
defaults: {
|
|
model: configModel ?? null,
|
|
contextTokens: configContextTokens ?? null,
|
|
},
|
|
recent,
|
|
},
|
|
};
|
|
}
|
|
|
|
const formatKTokens = (value: number) =>
|
|
`${(value / 1000).toFixed(value >= 10_000 ? 0 : 1)}k`;
|
|
|
|
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`;
|
|
};
|
|
|
|
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 shortenText = (value: string, maxLen: number) => {
|
|
const chars = Array.from(value);
|
|
if (chars.length <= maxLen) return value;
|
|
return `${chars.slice(0, Math.max(0, maxLen - 1)).join("")}…`;
|
|
};
|
|
|
|
const formatTokensCompact = (
|
|
sess: Pick<SessionStatus, "totalTokens" | "contextTokens" | "percentUsed">,
|
|
) => {
|
|
const used = sess.totalTokens ?? 0;
|
|
const ctx = sess.contextTokens;
|
|
if (!ctx) return `${formatKTokens(used)} used`;
|
|
const pctLabel = sess.percentUsed != null ? `${sess.percentUsed}%` : "?%";
|
|
return `${formatKTokens(used)}/${formatKTokens(ctx)} (${pctLabel})`;
|
|
};
|
|
|
|
const classifyKey = (
|
|
key: string,
|
|
entry?: SessionEntry,
|
|
): SessionStatus["kind"] => {
|
|
if (key === "global") return "global";
|
|
if (key === "unknown") return "unknown";
|
|
if (entry?.chatType === "group" || entry?.chatType === "room") return "group";
|
|
if (
|
|
key.startsWith("group:") ||
|
|
key.includes(":group:") ||
|
|
key.includes(":channel:")
|
|
) {
|
|
return "group";
|
|
}
|
|
return "direct";
|
|
};
|
|
|
|
const formatDaemonRuntimeShort = (runtime?: {
|
|
status?: string;
|
|
pid?: number;
|
|
state?: string;
|
|
detail?: string;
|
|
missingUnit?: boolean;
|
|
}) => {
|
|
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}`);
|
|
}
|
|
const detail = runtime.detail?.replace(/\s+/g, " ").trim() || "";
|
|
const noisyLaunchctlDetail =
|
|
runtime.missingUnit === true &&
|
|
detail.toLowerCase().includes("could not find service");
|
|
if (detail && !noisyLaunchctlDetail) details.push(detail);
|
|
return details.length > 0 ? `${status} (${details.join(", ")})` : status;
|
|
};
|
|
|
|
async function getDaemonStatusSummary(): Promise<{
|
|
label: string;
|
|
installed: boolean | null;
|
|
loadedText: string;
|
|
runtimeShort: string | null;
|
|
}> {
|
|
try {
|
|
const service = resolveGatewayService();
|
|
const [loaded, runtime, command] = await Promise.all([
|
|
service.isLoaded({ env: process.env }).catch(() => false),
|
|
service.readRuntime(process.env).catch(() => undefined),
|
|
service.readCommand(process.env).catch(() => null),
|
|
]);
|
|
const installed = command != null;
|
|
const loadedText = loaded ? service.loadedText : service.notLoadedText;
|
|
const runtimeShort = formatDaemonRuntimeShort(runtime);
|
|
return { label: service.label, installed, loadedText, runtimeShort };
|
|
} catch {
|
|
return {
|
|
label: "Daemon",
|
|
installed: null,
|
|
loadedText: "unknown",
|
|
runtimeShort: 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");
|
|
|
|
if (update.registry?.latestVersion) {
|
|
const cmp = compareSemverStrings(VERSION, update.registry.latestVersion);
|
|
if (cmp === 0) parts.push(`npm latest ${update.registry.latestVersion}`);
|
|
else if (cmp != null && cmp < 0)
|
|
parts.push(`npm update ${update.registry.latestVersion}`);
|
|
else
|
|
parts.push(`npm latest ${update.registry.latestVersion} (local newer)`);
|
|
} else if (update.registry?.error) {
|
|
parts.push("npm latest unknown");
|
|
}
|
|
} 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(`npm latest ${update.registry.latestVersion}`);
|
|
else if (cmp != null && cmp < 0) {
|
|
parts.push(`npm update ${update.registry.latestVersion}`);
|
|
} else {
|
|
parts.push(`npm latest ${update.registry.latestVersion} (local newer)`);
|
|
}
|
|
} else if (update.registry?.error) {
|
|
parts.push("npm 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(" · ")}`;
|
|
}
|
|
|
|
const buildFlags = (entry: SessionEntry): string[] => {
|
|
const flags: string[] = [];
|
|
const think = entry?.thinkingLevel;
|
|
if (typeof think === "string" && think.length > 0)
|
|
flags.push(`think:${think}`);
|
|
const verbose = entry?.verboseLevel;
|
|
if (typeof verbose === "string" && verbose.length > 0)
|
|
flags.push(`verbose:${verbose}`);
|
|
const reasoning = entry?.reasoningLevel;
|
|
if (typeof reasoning === "string" && reasoning.length > 0)
|
|
flags.push(`reasoning:${reasoning}`);
|
|
const elevated = entry?.elevatedLevel;
|
|
if (typeof elevated === "string" && elevated.length > 0)
|
|
flags.push(`elevated:${elevated}`);
|
|
if (entry?.systemSent) flags.push("system");
|
|
if (entry?.abortedLastRun) flags.push("aborted");
|
|
const sessionId = entry?.sessionId as unknown;
|
|
if (typeof sessionId === "string" && sessionId.length > 0)
|
|
flags.push(`id:${sessionId}`);
|
|
return flags;
|
|
};
|
|
|
|
export async function statusCommand(
|
|
opts: {
|
|
json?: boolean;
|
|
deep?: boolean;
|
|
usage?: boolean;
|
|
timeoutMs?: number;
|
|
verbose?: boolean;
|
|
all?: boolean;
|
|
},
|
|
runtime: RuntimeEnv,
|
|
) {
|
|
if (opts.all && !opts.json) {
|
|
await statusAllCommand(runtime, { timeoutMs: opts.timeoutMs });
|
|
return;
|
|
}
|
|
|
|
const scan = await withProgress(
|
|
{
|
|
label: "Scanning status…",
|
|
total: 7,
|
|
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("Summarizing providers…");
|
|
const providers = await buildProvidersTable(cfg, {
|
|
// Show token previews in regular status; keep `status --all` redacted.
|
|
// Set `CLAWDBOT_SHOW_SECRETS=0` to force redaction.
|
|
showSecrets: process.env.CLAWDBOT_SHOW_SECRETS?.trim() !== "0",
|
|
});
|
|
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,
|
|
providers,
|
|
summary,
|
|
};
|
|
},
|
|
);
|
|
|
|
const {
|
|
cfg,
|
|
osSummary,
|
|
update,
|
|
gatewayConnection,
|
|
remoteUrlMissing,
|
|
gatewayMode,
|
|
gatewayProbe,
|
|
gatewayReachable,
|
|
gatewaySelf,
|
|
agentStatus,
|
|
providers,
|
|
summary,
|
|
} = scan;
|
|
const usage = opts.usage
|
|
? await withProgress(
|
|
{
|
|
label: "Fetching usage snapshot…",
|
|
indeterminate: true,
|
|
enabled: opts.json !== true,
|
|
},
|
|
async () =>
|
|
await loadProviderUsageSummary({ timeoutMs: opts.timeoutMs }),
|
|
)
|
|
: undefined;
|
|
const health: HealthSummary | undefined = opts.deep
|
|
? await withProgress(
|
|
{
|
|
label: "Checking gateway health…",
|
|
indeterminate: true,
|
|
enabled: opts.json !== true,
|
|
},
|
|
async () =>
|
|
await callGateway<HealthSummary>({
|
|
method: "health",
|
|
timeoutMs: opts.timeoutMs,
|
|
}),
|
|
)
|
|
: undefined;
|
|
|
|
if (opts.json) {
|
|
runtime.log(
|
|
JSON.stringify(
|
|
{
|
|
...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,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const rich = true;
|
|
const muted = (value: string) => (rich ? theme.muted(value) : value);
|
|
const ok = (value: string) => (rich ? theme.success(value) : value);
|
|
const warn = (value: string) => (rich ? theme.warn(value) : value);
|
|
|
|
if (opts.verbose) {
|
|
const details = buildGatewayConnectionDetails();
|
|
runtime.log(info("Gateway connection:"));
|
|
for (const line of details.message.split("\n")) runtime.log(` ${line}`);
|
|
runtime.log("");
|
|
}
|
|
|
|
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
|
|
|
const dashboard = (() => {
|
|
const controlUiEnabled = cfg.gateway?.controlUi?.enabled ?? true;
|
|
if (!controlUiEnabled) return "disabled";
|
|
const links = resolveControlUiLinks({
|
|
port: resolveGatewayPort(cfg),
|
|
bind: cfg.gateway?.bind,
|
|
basePath: cfg.gateway?.controlUi?.basePath,
|
|
});
|
|
return links.httpUrl;
|
|
})();
|
|
|
|
const gatewayValue = (() => {
|
|
const target = remoteUrlMissing
|
|
? `fallback ${gatewayConnection.url}`
|
|
: `${gatewayConnection.url}${gatewayConnection.urlSource ? ` (${gatewayConnection.urlSource})` : ""}`;
|
|
const reach = remoteUrlMissing
|
|
? warn("misconfigured (remote.url missing)")
|
|
: gatewayReachable
|
|
? ok(`reachable ${formatDuration(gatewayProbe?.connectLatencyMs)}`)
|
|
: warn(
|
|
gatewayProbe?.error
|
|
? `unreachable (${gatewayProbe.error})`
|
|
: "unreachable",
|
|
);
|
|
const auth =
|
|
gatewayReachable && !remoteUrlMissing
|
|
? ` · auth ${formatGatewayAuthUsed(resolveGatewayProbeAuth(cfg))}`
|
|
: "";
|
|
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 `${gatewayMode} · ${target} · ${reach}${auth}${suffix}`;
|
|
})();
|
|
|
|
const agentsValue = (() => {
|
|
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 `${agentStatus.agents.length} · ${pending} · sessions ${agentStatus.totalSessions}${defSuffix}`;
|
|
})();
|
|
|
|
const daemon = await getDaemonStatusSummary();
|
|
const daemonValue = (() => {
|
|
if (daemon.installed === false) return `${daemon.label} not installed`;
|
|
const installedPrefix = daemon.installed === true ? "installed · " : "";
|
|
return `${daemon.label} ${installedPrefix}${daemon.loadedText}${daemon.runtimeShort ? ` · ${daemon.runtimeShort}` : ""}`;
|
|
})();
|
|
|
|
const defaults = summary.sessions.defaults;
|
|
const defaultCtx = defaults.contextTokens
|
|
? ` (${formatKTokens(defaults.contextTokens)} ctx)`
|
|
: "";
|
|
const eventsValue =
|
|
summary.queuedSystemEvents.length > 0
|
|
? `${summary.queuedSystemEvents.length} queued`
|
|
: "none";
|
|
|
|
const probesValue = health ? ok("enabled") : muted("skipped (use --deep)");
|
|
|
|
const overviewRows = [
|
|
{ Item: "Dashboard", Value: dashboard },
|
|
{ Item: "OS", Value: `${osSummary.label} · node ${process.versions.node}` },
|
|
{
|
|
Item: "Update",
|
|
Value: formatUpdateOneLiner(update).replace(/^Update:\s*/i, ""),
|
|
},
|
|
{ Item: "Gateway", Value: gatewayValue },
|
|
{ Item: "Daemon", Value: daemonValue },
|
|
{ Item: "Agents", Value: agentsValue },
|
|
{ Item: "Probes", Value: probesValue },
|
|
{ Item: "Events", Value: eventsValue },
|
|
{ Item: "Heartbeat", Value: `${summary.heartbeatSeconds}s` },
|
|
{
|
|
Item: "Sessions",
|
|
Value: `${summary.sessions.count} active · default ${defaults.model ?? "unknown"}${defaultCtx} · store ${summary.sessions.path}`,
|
|
},
|
|
];
|
|
|
|
runtime.log(theme.heading("Clawdbot status"));
|
|
runtime.log("");
|
|
runtime.log(theme.heading("Overview"));
|
|
runtime.log(
|
|
renderTable({
|
|
width: tableWidth,
|
|
columns: [
|
|
{ key: "Item", header: "Item", minWidth: 12 },
|
|
{ key: "Value", header: "Value", flex: true, minWidth: 32 },
|
|
],
|
|
rows: overviewRows,
|
|
}).trimEnd(),
|
|
);
|
|
|
|
runtime.log("");
|
|
runtime.log(theme.heading("Providers"));
|
|
runtime.log(
|
|
renderTable({
|
|
width: tableWidth,
|
|
columns: [
|
|
{ key: "Provider", header: "Provider", minWidth: 10 },
|
|
{ key: "Enabled", header: "Enabled", minWidth: 7 },
|
|
{ key: "State", header: "State", minWidth: 8 },
|
|
{ key: "Detail", header: "Detail", flex: true, minWidth: 24 },
|
|
],
|
|
rows: providers.rows.map((row) => ({
|
|
Provider: row.provider,
|
|
Enabled: row.enabled ? ok("ON") : muted("OFF"),
|
|
State:
|
|
row.state === "ok"
|
|
? ok("OK")
|
|
: row.state === "warn"
|
|
? warn("WARN")
|
|
: row.state === "off"
|
|
? muted("OFF")
|
|
: theme.accentDim("SETUP"),
|
|
Detail: row.detail,
|
|
})),
|
|
}).trimEnd(),
|
|
);
|
|
|
|
runtime.log("");
|
|
runtime.log(theme.heading("Sessions"));
|
|
runtime.log(
|
|
renderTable({
|
|
width: tableWidth,
|
|
columns: [
|
|
{ key: "Key", header: "Key", minWidth: 20, flex: true },
|
|
{ key: "Kind", header: "Kind", minWidth: 6 },
|
|
{ key: "Age", header: "Age", minWidth: 9 },
|
|
{ key: "Model", header: "Model", minWidth: 14 },
|
|
{ key: "Tokens", header: "Tokens", minWidth: 16 },
|
|
],
|
|
rows:
|
|
summary.sessions.recent.length > 0
|
|
? summary.sessions.recent.map((sess) => ({
|
|
Key: shortenText(sess.key, 32),
|
|
Kind: sess.kind,
|
|
Age: sess.updatedAt ? formatAge(sess.age) : "no activity",
|
|
Model: sess.model ?? "unknown",
|
|
Tokens: formatTokensCompact(sess),
|
|
}))
|
|
: [
|
|
{
|
|
Key: muted("no sessions yet"),
|
|
Kind: "",
|
|
Age: "",
|
|
Model: "",
|
|
Tokens: "",
|
|
},
|
|
],
|
|
}).trimEnd(),
|
|
);
|
|
|
|
if (summary.queuedSystemEvents.length > 0) {
|
|
runtime.log("");
|
|
runtime.log(theme.heading("System events"));
|
|
runtime.log(
|
|
renderTable({
|
|
width: tableWidth,
|
|
columns: [{ key: "Event", header: "Event", flex: true, minWidth: 24 }],
|
|
rows: summary.queuedSystemEvents.slice(0, 5).map((event) => ({
|
|
Event: event,
|
|
})),
|
|
}).trimEnd(),
|
|
);
|
|
if (summary.queuedSystemEvents.length > 5) {
|
|
runtime.log(muted(`… +${summary.queuedSystemEvents.length - 5} more`));
|
|
}
|
|
}
|
|
|
|
if (health) {
|
|
runtime.log("");
|
|
runtime.log(theme.heading("Health"));
|
|
const rows: Array<Record<string, string>> = [];
|
|
rows.push({
|
|
Provider: "Gateway",
|
|
Status: ok("reachable"),
|
|
Detail: `${health.durationMs}ms`,
|
|
});
|
|
rows.push({
|
|
Provider: "Telegram",
|
|
Status: health.telegram.configured
|
|
? health.telegram.probe?.ok
|
|
? ok("OK")
|
|
: warn("WARN")
|
|
: muted("OFF"),
|
|
Detail: health.telegram.configured
|
|
? health.telegram.probe?.ok
|
|
? `@${health.telegram.probe.bot?.username ?? "unknown"} · ${health.telegram.probe.elapsedMs}ms`
|
|
: (health.telegram.probe?.error ?? "probe failed")
|
|
: "not configured",
|
|
});
|
|
rows.push({
|
|
Provider: "Discord",
|
|
Status: health.discord.configured
|
|
? health.discord.probe?.ok
|
|
? ok("OK")
|
|
: warn("WARN")
|
|
: muted("OFF"),
|
|
Detail: health.discord.configured
|
|
? health.discord.probe?.ok
|
|
? `@${health.discord.probe.bot?.username ?? "unknown"} · ${health.discord.probe.elapsedMs}ms`
|
|
: (health.discord.probe?.error ?? "probe failed")
|
|
: "not configured",
|
|
});
|
|
|
|
runtime.log(
|
|
renderTable({
|
|
width: tableWidth,
|
|
columns: [
|
|
{ key: "Provider", header: "Provider", minWidth: 10 },
|
|
{ key: "Status", header: "Status", minWidth: 8 },
|
|
{ key: "Detail", header: "Detail", flex: true, minWidth: 28 },
|
|
],
|
|
rows,
|
|
}).trimEnd(),
|
|
);
|
|
}
|
|
|
|
if (usage) {
|
|
runtime.log("");
|
|
runtime.log(theme.heading("Usage"));
|
|
for (const line of formatUsageReportLines(usage)) {
|
|
runtime.log(line);
|
|
}
|
|
}
|
|
|
|
runtime.log("");
|
|
runtime.log("FAQ: https://docs.clawd.bot/faq");
|
|
runtime.log("Troubleshooting: https://docs.clawd.bot/troubleshooting");
|
|
runtime.log(
|
|
"More: clawdbot status --all · clawdbot status --deep · clawdbot gateway status · clawdbot providers status --probe · clawdbot daemon status · clawdbot logs --follow",
|
|
);
|
|
}
|