Files
openclaw/src/commands/status.command.ts
solstead ab71fdf821 Plugin API: compaction/reset hooks, bootstrap file globs, memory plugin status (#13287)
* feat: add before_compaction and before_reset plugin hooks with session context

- Pass session messages to before_compaction hook
- Add before_reset plugin hook for /new and /reset commands
- Add sessionId to plugin hook agent context

* feat: extraBootstrapFiles config with glob pattern support

Add extraBootstrapFiles to agent defaults config, allowing glob patterns
(e.g. "projects/*/TOOLS.md") to auto-load project-level bootstrap files
into agent context every turn. Missing files silently skipped.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(status): show custom memory plugins as enabled, not unavailable

The status command probes memory availability using the built-in
memory-core manager. Custom memory plugins (e.g. via plugin slot)
can't be probed this way, so they incorrectly showed "unavailable".
Now they show "enabled (plugin X)" without the misleading label.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use async fs.glob and capture pre-compaction messages

- Replace globSync (node:fs) with fs.glob (node:fs/promises) to match
  codebase conventions for async file operations
- Capture session.messages BEFORE replaceMessages(limited) so
  before_compaction hook receives the full conversation history,
  not the already-truncated list

* fix: resolve lint errors from CI (oxlint strict mode)

- Add void to fire-and-forget IIFE (no-floating-promises)
- Use String() for unknown catch params in template literals
- Add curly braces to single-statement if (curly rule)

* fix: resolve remaining CI lint errors in workspace.ts

- Remove `| string` from WorkspaceBootstrapFileName union (made all
  typeof members redundant per no-redundant-type-constituents)
- Use type assertion for extra bootstrap file names
- Drop redundant await on fs.glob() AsyncIterable (await-thenable)

* fix: address Greptile review — path traversal guard + fs/promises import

- workspace.ts: use path.resolve() + traversal check in loadExtraBootstrapFiles()
- commands-core.ts: import fs from node:fs/promises, drop fs.promises prefix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: resolve symlinks before workspace boundary check

Greptile correctly identified that symlinks inside the workspace could
point to files outside it, bypassing the path prefix check. Now uses
fs.realpath() to resolve symlinks before verifying the real path stays
within the workspace boundary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address Greptile review — hook reliability and type safety

1. before_compaction: add compactingCount field so plugins know both
   the full pre-compaction message count and the truncated count being
   fed to the compaction LLM. Clarify semantics in comment.

2. loadExtraBootstrapFiles: use path.basename() for the name field
   so "projects/quaid/TOOLS.md" maps to the known "TOOLS.md" type
   instead of an invalid WorkspaceBootstrapFileName cast.

3. before_reset: fire the hook even when no session file exists.
   Previously, short sessions without a persisted file would silently
   skip the hook. Now fires with empty messages array so plugins
   always know a reset occurred.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: validate bootstrap filenames and add compaction hook timeout

- Only load extra bootstrap files whose basename matches a recognized
  workspace filename (AGENTS.md, TOOLS.md, etc.), preventing arbitrary
  files from being injected into agent context.
- Wrap before_compaction hook in a 30-second Promise.race timeout so
  misbehaving plugins cannot stall the compaction pipeline.
- Clarify hook comments: before_compaction is intentionally awaited
  (plugins need messages before they're discarded) but bounded.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: make before_compaction non-blocking, add sessionFile to after_compaction

- before_compaction is now true fire-and-forget — no await, no timeout.
  Plugins that need full conversation data should persist it themselves
  and return quickly, or use after_compaction for async processing.
- after_compaction now includes sessionFile path so plugins can read
  the full JSONL transcript asynchronously. All pre-compaction messages
  are preserved on disk, eliminating the need to block compaction.
- Removes Promise.race timeout pattern that didn't actually cancel
  slow hooks (just raced past them while they continued running).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add sessionFile to before_compaction for parallel processing

The session JSONL already has all messages on disk before compaction
starts. By providing sessionFile in before_compaction, plugins can
read and extract data in parallel with the compaction LLM call rather
than waiting for after_compaction. This is the optimal path for memory
plugins that need the full conversation history.

sessionFile is also kept on after_compaction for plugins that only
need to act after compaction completes (analytics, cleanup, etc.).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: move bootstrap extras into bundled hook

---------

Co-authored-by: Solomon Steadman <solstead@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Clawdbot <clawdbot@alfie.local>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-14 00:45:45 +01:00

648 lines
21 KiB
TypeScript

import type { HeartbeatEventPayload } from "../infra/heartbeat-events.js";
import type { RuntimeEnv } from "../runtime.js";
import { formatCliCommand } from "../cli/command-format.js";
import { withProgress } from "../cli/progress.js";
import { resolveGatewayPort } from "../config/config.js";
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
import { info } from "../globals.js";
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
import { formatUsageReportLines, loadProviderUsageSummary } from "../infra/provider-usage.js";
import {
formatUpdateChannelLabel,
normalizeUpdateChannel,
resolveEffectiveUpdateChannel,
} from "../infra/update-channels.js";
import {
resolveMemoryCacheSummary,
resolveMemoryFtsState,
resolveMemoryVectorState,
type Tone,
} from "../memory/status-format.js";
import { runSecurityAudit } from "../security/audit.js";
import { renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js";
import { formatHealthChannelLines, type HealthSummary } from "./health.js";
import { resolveControlUiLinks } from "./onboard-helpers.js";
import { statusAllCommand } from "./status-all.js";
import { formatGatewayAuthUsed } from "./status-all/format.js";
import { getDaemonStatusSummary, getNodeDaemonStatusSummary } from "./status.daemon.js";
import {
formatDuration,
formatKTokens,
formatTokensCompact,
shortenText,
} from "./status.format.js";
import { resolveGatewayProbeAuth } from "./status.gateway-probe.js";
import { scanStatus } from "./status.scan.js";
import {
formatUpdateAvailableHint,
formatUpdateOneLiner,
resolveUpdateAvailability,
} from "./status.update.js";
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 scanStatus(
{ json: opts.json, timeoutMs: opts.timeoutMs, all: opts.all },
runtime,
);
const {
cfg,
osSummary,
tailscaleMode,
tailscaleDns,
tailscaleHttpsUrl,
update,
gatewayConnection,
remoteUrlMissing,
gatewayMode,
gatewayProbe,
gatewayReachable,
gatewaySelf,
channelIssues,
agentStatus,
channels,
summary,
memory,
memoryPlugin,
} = scan;
const securityAudit = await withProgress(
{
label: "Running security audit…",
indeterminate: true,
enabled: opts.json !== true,
},
async () =>
await runSecurityAudit({
config: cfg,
deep: false,
includeFilesystem: true,
includeChannelSecurity: true,
}),
);
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",
params: { probe: true },
timeoutMs: opts.timeoutMs,
}),
)
: undefined;
const lastHeartbeat =
opts.deep && gatewayReachable
? await callGateway<HeartbeatEventPayload | null>({
method: "last-heartbeat",
params: {},
timeoutMs: opts.timeoutMs,
}).catch(() => null)
: null;
const configChannel = normalizeUpdateChannel(cfg.update?.channel);
const channelInfo = resolveEffectiveUpdateChannel({
configChannel,
installKind: update.installKind,
git: update.git ? { tag: update.git.tag, branch: update.git.branch } : undefined,
});
if (opts.json) {
const [daemon, nodeDaemon] = await Promise.all([
getDaemonStatusSummary(),
getNodeDaemonStatusSummary(),
]);
runtime.log(
JSON.stringify(
{
...summary,
os: osSummary,
update,
updateChannel: channelInfo.channel,
updateChannelSource: channelInfo.source,
memory,
memoryPlugin,
gateway: {
mode: gatewayMode,
url: gatewayConnection.url,
urlSource: gatewayConnection.urlSource,
misconfigured: remoteUrlMissing,
reachable: gatewayReachable,
connectLatencyMs: gatewayProbe?.connectLatencyMs ?? null,
self: gatewaySelf,
error: gatewayProbe?.error ?? null,
},
gatewayService: daemon,
nodeService: nodeDaemon,
agents: agentStatus,
securityAudit,
...(health || usage || lastHeartbeat ? { health, usage, lastHeartbeat } : {}),
},
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,
customBindHost: cfg.gateway?.customBindHost,
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 ? formatTimeAgo(def.lastActiveAgeMs) : "unknown";
const defSuffix = def ? ` · default ${def.id} active ${defActive}` : "";
return `${agentStatus.agents.length} · ${pending} · sessions ${agentStatus.totalSessions}${defSuffix}`;
})();
const [daemon, nodeDaemon] = await Promise.all([
getDaemonStatusSummary(),
getNodeDaemonStatusSummary(),
]);
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 nodeDaemonValue = (() => {
if (nodeDaemon.installed === false) {
return `${nodeDaemon.label} not installed`;
}
const installedPrefix = nodeDaemon.installed === true ? "installed · " : "";
return `${nodeDaemon.label} ${installedPrefix}${nodeDaemon.loadedText}${nodeDaemon.runtimeShort ? ` · ${nodeDaemon.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 heartbeatValue = (() => {
const parts = summary.heartbeat.agents
.map((agent) => {
if (!agent.enabled || !agent.everyMs) {
return `disabled (${agent.agentId})`;
}
const everyLabel = agent.every;
return `${everyLabel} (${agent.agentId})`;
})
.filter(Boolean);
return parts.length > 0 ? parts.join(", ") : "disabled";
})();
const lastHeartbeatValue = (() => {
if (!opts.deep) {
return null;
}
if (!gatewayReachable) {
return warn("unavailable");
}
if (!lastHeartbeat) {
return muted("none");
}
const age = formatTimeAgo(Date.now() - lastHeartbeat.ts);
const channel = lastHeartbeat.channel ?? "unknown";
const accountLabel = lastHeartbeat.accountId ? `account ${lastHeartbeat.accountId}` : null;
return [lastHeartbeat.status, `${age} ago`, channel, accountLabel].filter(Boolean).join(" · ");
})();
const storeLabel =
summary.sessions.paths.length > 1
? `${summary.sessions.paths.length} stores`
: (summary.sessions.paths[0] ?? "unknown");
const memoryValue = (() => {
if (!memoryPlugin.enabled) {
const suffix = memoryPlugin.reason ? ` (${memoryPlugin.reason})` : "";
return muted(`disabled${suffix}`);
}
if (!memory) {
const slot = memoryPlugin.slot ? `plugin ${memoryPlugin.slot}` : "plugin";
// Custom (non-built-in) memory plugins can't be probed — show enabled, not unavailable
if (memoryPlugin.slot && memoryPlugin.slot !== "memory-core") {
return `enabled (${slot})`;
}
return muted(`enabled (${slot}) · unavailable`);
}
const parts: string[] = [];
const dirtySuffix = memory.dirty ? ` · ${warn("dirty")}` : "";
parts.push(`${memory.files} files · ${memory.chunks} chunks${dirtySuffix}`);
if (memory.sources?.length) {
parts.push(`sources ${memory.sources.join(", ")}`);
}
if (memoryPlugin.slot) {
parts.push(`plugin ${memoryPlugin.slot}`);
}
const colorByTone = (tone: Tone, text: string) =>
tone === "ok" ? ok(text) : tone === "warn" ? warn(text) : muted(text);
const vector = memory.vector;
if (vector) {
const state = resolveMemoryVectorState(vector);
const label = state.state === "disabled" ? "vector off" : `vector ${state.state}`;
parts.push(colorByTone(state.tone, label));
}
const fts = memory.fts;
if (fts) {
const state = resolveMemoryFtsState(fts);
const label = state.state === "disabled" ? "fts off" : `fts ${state.state}`;
parts.push(colorByTone(state.tone, label));
}
const cache = memory.cache;
if (cache) {
const summary = resolveMemoryCacheSummary(cache);
parts.push(colorByTone(summary.tone, summary.text));
}
return parts.join(" · ");
})();
const updateAvailability = resolveUpdateAvailability(update);
const updateLine = formatUpdateOneLiner(update).replace(/^Update:\s*/i, "");
const channelLabel = formatUpdateChannelLabel({
channel: channelInfo.channel,
source: channelInfo.source,
gitTag: update.git?.tag ?? null,
gitBranch: update.git?.branch ?? null,
});
const gitLabel =
update.installKind === "git"
? (() => {
const shortSha = update.git?.sha ? update.git.sha.slice(0, 8) : null;
const branch =
update.git?.branch && update.git.branch !== "HEAD" ? update.git.branch : null;
const tag = update.git?.tag ?? null;
const parts = [
branch ?? (tag ? "detached" : "git"),
tag ? `tag ${tag}` : null,
shortSha ? `@ ${shortSha}` : null,
].filter(Boolean);
return parts.join(" · ");
})()
: null;
const overviewRows = [
{ Item: "Dashboard", Value: dashboard },
{ Item: "OS", Value: `${osSummary.label} · node ${process.versions.node}` },
{
Item: "Tailscale",
Value:
tailscaleMode === "off"
? muted("off")
: tailscaleDns && tailscaleHttpsUrl
? `${tailscaleMode} · ${tailscaleDns} · ${tailscaleHttpsUrl}`
: warn(`${tailscaleMode} · magicdns unknown`),
},
{ Item: "Channel", Value: channelLabel },
...(gitLabel ? [{ Item: "Git", Value: gitLabel }] : []),
{
Item: "Update",
Value: updateAvailability.available ? warn(`available · ${updateLine}`) : updateLine,
},
{ Item: "Gateway", Value: gatewayValue },
{ Item: "Gateway service", Value: daemonValue },
{ Item: "Node service", Value: nodeDaemonValue },
{ Item: "Agents", Value: agentsValue },
{ Item: "Memory", Value: memoryValue },
{ Item: "Probes", Value: probesValue },
{ Item: "Events", Value: eventsValue },
{ Item: "Heartbeat", Value: heartbeatValue },
...(lastHeartbeatValue ? [{ Item: "Last heartbeat", Value: lastHeartbeatValue }] : []),
{
Item: "Sessions",
Value: `${summary.sessions.count} active · default ${defaults.model ?? "unknown"}${defaultCtx} · ${storeLabel}`,
},
];
runtime.log(theme.heading("OpenClaw 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("Security audit"));
const fmtSummary = (value: { critical: number; warn: number; info: number }) => {
const parts = [
theme.error(`${value.critical} critical`),
theme.warn(`${value.warn} warn`),
theme.muted(`${value.info} info`),
];
return parts.join(" · ");
};
runtime.log(theme.muted(`Summary: ${fmtSummary(securityAudit.summary)}`));
const importantFindings = securityAudit.findings.filter(
(f) => f.severity === "critical" || f.severity === "warn",
);
if (importantFindings.length === 0) {
runtime.log(theme.muted("No critical or warn findings detected."));
} else {
const severityLabel = (sev: "critical" | "warn" | "info") => {
if (sev === "critical") {
return theme.error("CRITICAL");
}
if (sev === "warn") {
return theme.warn("WARN");
}
return theme.muted("INFO");
};
const sevRank = (sev: "critical" | "warn" | "info") =>
sev === "critical" ? 0 : sev === "warn" ? 1 : 2;
const sorted = [...importantFindings].toSorted(
(a, b) => sevRank(a.severity) - sevRank(b.severity),
);
const shown = sorted.slice(0, 6);
for (const f of shown) {
runtime.log(` ${severityLabel(f.severity)} ${f.title}`);
runtime.log(` ${shortenText(f.detail.replaceAll("\n", " "), 160)}`);
if (f.remediation?.trim()) {
runtime.log(` ${theme.muted(`Fix: ${f.remediation.trim()}`)}`);
}
}
if (sorted.length > shown.length) {
runtime.log(theme.muted(`… +${sorted.length - shown.length} more`));
}
}
runtime.log(theme.muted(`Full report: ${formatCliCommand("openclaw security audit")}`));
runtime.log(theme.muted(`Deep probe: ${formatCliCommand("openclaw security audit --deep")}`));
runtime.log("");
runtime.log(theme.heading("Channels"));
const channelIssuesByChannel = (() => {
const map = new Map<string, typeof channelIssues>();
for (const issue of channelIssues) {
const key = issue.channel;
const list = map.get(key);
if (list) {
list.push(issue);
} else {
map.set(key, [issue]);
}
}
return map;
})();
runtime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Channel", header: "Channel", minWidth: 10 },
{ key: "Enabled", header: "Enabled", minWidth: 7 },
{ key: "State", header: "State", minWidth: 8 },
{ key: "Detail", header: "Detail", flex: true, minWidth: 24 },
],
rows: channels.rows.map((row) => {
const issues = channelIssuesByChannel.get(row.id) ?? [];
const effectiveState = row.state === "off" ? "off" : issues.length > 0 ? "warn" : row.state;
const issueSuffix =
issues.length > 0
? ` · ${warn(`gateway: ${shortenText(issues[0]?.message ?? "issue", 84)}`)}`
: "";
return {
Channel: row.label,
Enabled: row.enabled ? ok("ON") : muted("OFF"),
State:
effectiveState === "ok"
? ok("OK")
: effectiveState === "warn"
? warn("WARN")
: effectiveState === "off"
? muted("OFF")
: theme.accentDim("SETUP"),
Detail: `${row.detail}${issueSuffix}`,
};
}),
}).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 ? formatTimeAgo(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({
Item: "Gateway",
Status: ok("reachable"),
Detail: `${health.durationMs}ms`,
});
for (const line of formatHealthChannelLines(health, { accountMode: "all" })) {
const colon = line.indexOf(":");
if (colon === -1) {
continue;
}
const item = line.slice(0, colon).trim();
const detail = line.slice(colon + 1).trim();
const normalized = detail.toLowerCase();
const status = (() => {
if (normalized.startsWith("ok")) {
return ok("OK");
}
if (normalized.startsWith("failed")) {
return warn("WARN");
}
if (normalized.startsWith("not configured")) {
return muted("OFF");
}
if (normalized.startsWith("configured")) {
return ok("OK");
}
if (normalized.startsWith("linked")) {
return ok("LINKED");
}
if (normalized.startsWith("not linked")) {
return warn("UNLINKED");
}
return warn("WARN");
})();
rows.push({ Item: item, Status: status, Detail: detail });
}
runtime.log(
renderTable({
width: tableWidth,
columns: [
{ key: "Item", header: "Item", 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.openclaw.ai/faq");
runtime.log("Troubleshooting: https://docs.openclaw.ai/troubleshooting");
runtime.log("");
const updateHint = formatUpdateAvailableHint(update);
if (updateHint) {
runtime.log(theme.warn(updateHint));
runtime.log("");
}
runtime.log("Next steps:");
runtime.log(` Need to share? ${formatCliCommand("openclaw status --all")}`);
runtime.log(` Need to debug live? ${formatCliCommand("openclaw logs --follow")}`);
if (gatewayReachable) {
runtime.log(` Need to test channels? ${formatCliCommand("openclaw status --deep")}`);
} else {
runtime.log(` Fix reachability first: ${formatCliCommand("openclaw gateway probe")}`);
}
}