// Node status/list/describe commands and paired-node display formatting. import { formatByteSize } from "@openclaw/normalization-core"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, normalizeOptionalString, } from "@openclaw/normalization-core/string-coerce"; import type { Command } from "commander"; import { sanitizeTerminalText } from "../../../packages/terminal-core/src/safe-text.js"; import { getTerminalTableWidth, renderTable } from "../../../packages/terminal-core/src/table.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { formatTimeAgo } from "../../infra/format-time/format-relative.ts"; import { defaultRuntime } from "../../runtime.js"; import { isNodeHostStats } from "../../shared/node-host-stats.js"; import { shortenHomeInString } from "../../utils.js"; import { formatPairingApproveCommand } from "../pairing-command-format.js"; import { parseDurationMs } from "../parse-duration.js"; import { formatConnectionFlagReminder, getNodesTheme, runNodesCommand } from "./cli-utils.js"; import { formatPermissions, parseNodeList, parsePairingList } from "./format.js"; import { renderPendingPairingRequestsTable } from "./pairing-render.js"; import { callNodesGatewayCli, callNodeDiagnosticsGatewayCli, nodesCallOpts, resolveNodeDiagnosticsId, } from "./rpc.js"; import type { NodeListNode, NodesRpcOpts, PairedNode } from "./types.js"; type PairedNodeListRow = PairedNode & Partial; type NodeApprovalState = NonNullable; function formatNodeStatsBytes(bytes: number): string { return formatByteSize(bytes, { style: "legacy-binary", maxUnit: "tera", separator: " ", fractionDigits: (value, unit) => (value < 10 && unit !== "byte" ? 1 : 0), }); } function formatNodeHostStats(stats: unknown, connected: boolean, now: number): string | null { if (!isNodeHostStats(stats)) { return null; } const totalMemory = formatNodeStatsBytes(stats.memoryTotalBytes); const usedMemory = formatNodeStatsBytes(stats.memoryTotalBytes - stats.memoryFreeBytes); const memoryUnit = totalMemory.slice(totalMemory.lastIndexOf(" ")); const usedLabel = usedMemory.endsWith(memoryUnit) ? usedMemory.slice(0, -memoryUnit.length) : usedMemory; const summary = [ stats.loadAverage ? `load ${stats.loadAverage[0].toFixed(1)}/${stats.cpuCount}` : null, `mem ${usedLabel}/${totalMemory}`, stats.diskAvailableBytes !== undefined ? `disk ${formatNodeStatsBytes(stats.diskAvailableBytes)} free` : null, ] .filter(Boolean) .join(" · "); return connected ? summary : `${summary} (last known ${formatTimeAgo(Math.max(0, now - stats.updatedAtMs))})`; } function formatVersionLabel(raw: string) { const trimmed = raw.trim(); if (!trimmed) { return raw; } if (normalizeLowercaseStringOrEmpty(trimmed).startsWith("v")) { return trimmed; } return /^\d/.test(trimmed) ? `v${trimmed}` : trimmed; } function resolveNodeVersions(node: { platform?: string; version?: string; coreVersion?: string; uiVersion?: string; }) { const core = normalizeOptionalString(node.coreVersion); const ui = normalizeOptionalString(node.uiVersion); if (core || ui) { return { core, ui }; } const legacy = node.version?.trim(); if (!legacy) { return { core: undefined, ui: undefined }; } const platform = normalizeOptionalLowercaseString(node.platform) ?? ""; // Legacy nodes reported one version field; headless hosts use it as core, mobile nodes as UI. const headless = platform === "darwin" || platform === "linux" || platform === "win32" || platform === "windows"; return headless ? { core: legacy, ui: undefined } : { core: undefined, ui: legacy }; } function formatNodeVersions(node: { platform?: string; version?: string; coreVersion?: string; uiVersion?: string; }) { const { core, ui } = resolveNodeVersions(node); const parts: string[] = []; if (core) { parts.push(`core ${formatVersionLabel(core)}`); } if (ui) { parts.push(`ui ${formatVersionLabel(ui)}`); } return parts.length > 0 ? parts.join(" · ") : null; } function isWindowsNodePlatform(platform?: string): boolean { const normalized = normalizeOptionalLowercaseString(platform) ?? ""; return normalized === "win32" || normalized === "windows"; } function formatPathEnv(raw?: string, platform?: string): string | null { if (typeof raw !== "string") { return null; } const trimmed = raw.trim(); if (!trimmed) { return null; } const delimiter = isWindowsNodePlatform(platform) ? ";" : ":"; const parts = trimmed.split(delimiter).filter(Boolean); const display = parts.length <= 3 ? trimmed : `${parts.slice(0, 2).join(delimiter)}${delimiter}…${delimiter}${parts.slice(-1)[0]}`; return shortenHomeInString(display); } function formatClientLabel(node: { clientId?: string; clientMode?: string }): string | null { const clientId = node.clientId?.trim(); const clientMode = node.clientMode?.trim(); if (clientId && clientMode) { return `${clientId}/${clientMode}`; } return clientId || clientMode || null; } function formatNodeTerminalLabel(node: { nodeId: string; displayName?: string }): string { const label = node.displayName?.trim() ? node.displayName.trim() : node.nodeId; return sanitizeTerminalText(label); } function formatNodeTimeAgo(now: number, timestamp: unknown): string | null { return typeof timestamp === "number" && Number.isFinite(timestamp) ? formatTimeAgo(Math.max(0, now - timestamp)) : null; } function formatNodeApprovalState(raw: unknown): NodeApprovalState | null { return raw === "approved" || raw === "pending-approval" || raw === "pending-reapproval" || raw === "unapproved" ? raw : null; } function formatApprovalStateLabel(state: NodeApprovalState): string { if (state === "pending-approval") { return "approval pending"; } if (state === "pending-reapproval") { return "reapproval pending"; } return state; } function isPendingApprovalState( state: NodeApprovalState | null, ): state is "pending-approval" | "pending-reapproval" { return state === "pending-approval" || state === "pending-reapproval"; } function parseSinceMs(raw: string | undefined, label: string): number | undefined { if (raw === undefined) { return undefined; } try { return parseDurationMs(raw); } catch (err) { throw new Error(`${label}: ${formatErrorMessage(err)}`, { cause: err }); } } function matchesNodeConnectionFilter( node: NodeListNode, connectedOnly: boolean, sinceMs: number | undefined, now: number, ): boolean { if (connectedOnly && !node.connected) { return false; } // Older gateways lack the recorded lastConnectedAtMs field. const lastConnectedAtMs = node.lastConnectedAtMs ?? node.connectedAtMs; return ( sinceMs === undefined || (typeof lastConnectedAtMs === "number" && now - lastConnectedAtMs <= sinceMs) ); } function mergePairedNodeWithEffectiveNode( paired: PairedNode | undefined, effective: NodeListNode, ): PairedNodeListRow { return { ...paired, ...effective, createdAtMs: paired?.createdAtMs, // node.list can record a connection newer than the separate pairing snapshot. lastConnectedAtMs: effective.lastConnectedAtMs ?? paired?.lastConnectedAtMs ?? effective.connectedAtMs, displayName: effective.displayName ?? paired?.displayName, platform: effective.platform ?? paired?.platform, version: effective.version ?? paired?.version, coreVersion: effective.coreVersion ?? paired?.coreVersion, uiVersion: effective.uiVersion ?? paired?.uiVersion, remoteIp: effective.remoteIp ?? paired?.remoteIp, permissions: effective.permissions ?? paired?.permissions, approvedAtMs: effective.approvedAtMs ?? paired?.approvedAtMs, }; } function mergePairedNodesWithEffectiveNodes( paired: PairedNode[], effectiveNodes: NodeListNode[] | null, ): PairedNodeListRow[] { if (effectiveNodes === null) { return paired; } const pairedById = new Map(paired.map((node) => [node.nodeId, node])); const seen = new Set(); const rows: PairedNodeListRow[] = []; for (const effective of effectiveNodes) { const pairedNode = pairedById.get(effective.nodeId); if (!pairedNode && effective.paired !== true) { continue; } seen.add(effective.nodeId); rows.push(mergePairedNodeWithEffectiveNode(pairedNode, effective)); } for (const node of paired) { if (!seen.has(node.nodeId)) { rows.push(node); } } return rows; } async function tryReadNodeList(opts: NodesRpcOpts): Promise { try { return parseNodeList(await callNodeDiagnosticsGatewayCli("node.list", opts, {})); } catch (error) { // Best-effort enrichment may degrade to pairing-only rows, but never // silently: without this notice the table looks authoritative while // omitting connected/commands state. Stderr keeps --json output clean. defaultRuntime.error( getNodesTheme().muted( `live node view unavailable (${formatErrorMessage(error)}); showing paired-only data`, ), ); return null; } } /** Register node status, describe, and paired-node list commands. */ export function registerNodesStatusCommands(nodes: Command) { nodesCallOpts( nodes .command("status") .description("List known nodes with connection status and capabilities") .option("--connected", "Only show connected nodes") .option("--last-connected ", "Only show nodes connected within duration (e.g. 24h)") .action(async (opts: NodesRpcOpts) => { await runNodesCommand("status", async () => { const connectedOnly = Boolean(opts.connected); const sinceMs = parseSinceMs(opts.lastConnected, "Invalid --last-connected"); const result = await callNodeDiagnosticsGatewayCli("node.list", opts, {}); const obj: Record = typeof result === "object" && result !== null ? result : {}; const { ok, warn, muted } = getNodesTheme(); const tableWidth = getTerminalTableWidth(); const now = Date.now(); const nodesLocal = parseNodeList(result); const filtered = nodesLocal.filter((node) => matchesNodeConnectionFilter(node, connectedOnly, sinceMs, now), ); if (opts.json) { const ts = typeof obj.ts === "number" ? obj.ts : Date.now(); defaultRuntime.writeJson({ ...obj, ts, nodes: filtered }); return; } const pairedCount = filtered.filter((n) => Boolean(n.paired)).length; const connectedCount = filtered.filter((n) => Boolean(n.connected)).length; const filteredLabel = filtered.length !== nodesLocal.length ? ` (of ${nodesLocal.length})` : ""; defaultRuntime.log( `Known: ${filtered.length}${filteredLabel} · Paired: ${pairedCount} · Connected: ${connectedCount}`, ); if (filtered.length === 0) { return; } const rows = filtered.map((n) => { const perms = formatPermissions(n.permissions); const versions = formatNodeVersions(n); const pathEnv = formatPathEnv(n.pathEnv, n.platform); const client = formatClientLabel(n); const lastActive = formatNodeTimeAgo(now, n.lastActiveAtMs); const detailParts = [ client ? `client: ${client}` : null, n.deviceFamily ? `device: ${n.deviceFamily}` : null, n.modelIdentifier ? `hw: ${n.modelIdentifier}` : null, perms ? `perms: ${perms}` : null, versions, formatNodeHostStats(n.hostStats, Boolean(n.connected), now), pathEnv ? `path: ${pathEnv}` : null, lastActive ? `input: ${lastActive}${n.active ? " (active)" : ""}` : null, ] .filter(Boolean) .map((part) => sanitizeTerminalText(String(part))); const caps = Array.isArray(n.caps) ? sanitizeTerminalText(n.caps.map(String).filter(Boolean).toSorted().join(", ")) : "?"; const paired = n.paired ? ok("paired") : warn("unpaired"); const connected = n.connected ? ok("connected") : muted("disconnected"); const approvalState = formatNodeApprovalState(n.approvalState); const approval = approvalState === "approved" ? ok("approved") : isPendingApprovalState(approvalState) ? warn(formatApprovalStateLabel(approvalState)) : approvalState === "unapproved" ? warn("unapproved") : null; const since = typeof n.connectedAtMs === "number" ? ` (${formatTimeAgo(Math.max(0, now - n.connectedAtMs))})` : ""; return { Node: formatNodeTerminalLabel(n), ID: sanitizeTerminalText(n.nodeId), IP: sanitizeTerminalText(n.remoteIp ?? ""), Detail: detailParts.join(" · "), Status: `${paired} · ${connected}${since}${approval ? ` · ${approval}` : ""}`, Caps: caps, }; }); defaultRuntime.log( renderTable({ width: tableWidth, columns: [ { key: "Node", header: "Node", minWidth: 14, flex: true }, { key: "ID", header: "ID", minWidth: 10 }, { key: "IP", header: "IP", minWidth: 10 }, { key: "Detail", header: "Detail", minWidth: 18, flex: true }, { key: "Status", header: "Status", minWidth: 18 }, { key: "Caps", header: "Caps", minWidth: 12, flex: true }, ], rows, }).trimEnd(), ); for (const node of filtered) { const approvalState = formatNodeApprovalState(node.approvalState); const requestId = normalizeOptionalString(node.pendingRequestId); if (isPendingApprovalState(approvalState) && requestId) { const approveCommand = formatPairingApproveCommand("nodes", requestId, { timeout: opts.timeout, }); const action = approvalState === "pending-reapproval" ? "Reapproval" : "Approval"; defaultRuntime.log( warn( `${action} pending for ${formatNodeTerminalLabel(node)}. Run ${sanitizeTerminalText(approveCommand)}`, ), ); const connectionReminder = formatConnectionFlagReminder(opts); if (connectionReminder) { defaultRuntime.log(warn(connectionReminder)); } } } }); }), ); nodesCallOpts( nodes .command("describe") .description("Describe a node (capabilities + supported invoke commands)") .requiredOption("--node ", "Node id, name, or IP") .action(async (opts: NodesRpcOpts) => { await runNodesCommand("describe", async () => { const nodeId = await resolveNodeDiagnosticsId(opts, opts.node ?? ""); const result = await callNodeDiagnosticsGatewayCli("node.describe", opts, { nodeId, }); if (opts.json) { defaultRuntime.writeJson(result); return; } const obj: Record = typeof result === "object" && result !== null ? result : {}; const displayName = typeof obj.displayName === "string" ? obj.displayName : nodeId; const connected = Boolean(obj.connected); const paired = Boolean(obj.paired); const caps = Array.isArray(obj.caps) ? obj.caps.map(String).filter(Boolean).toSorted() : null; const commands = Array.isArray(obj.commands) ? obj.commands.map(String).filter(Boolean).toSorted() : []; const perms = formatPermissions(obj.permissions); const approvalState = formatNodeApprovalState(obj.approvalState); const pendingRequestId = normalizeOptionalString(obj.pendingRequestId); const pendingCaps = Array.isArray(obj.pendingDeclaredCaps) ? obj.pendingDeclaredCaps.map(String).filter(Boolean).toSorted() : null; const pendingCommands = Array.isArray(obj.pendingDeclaredCommands) ? obj.pendingDeclaredCommands.map(String).filter(Boolean).toSorted() : []; const pendingPerms = formatPermissions(obj.pendingDeclaredPermissions); const approveCommand = isPendingApprovalState(approvalState) && pendingRequestId ? formatPairingApproveCommand("nodes", pendingRequestId, { timeout: opts.timeout }) : null; const connectionReminder = approveCommand ? formatConnectionFlagReminder(opts) : null; const family = typeof obj.deviceFamily === "string" ? obj.deviceFamily : null; const model = typeof obj.modelIdentifier === "string" ? obj.modelIdentifier : null; const client = formatClientLabel(obj as { clientId?: string; clientMode?: string }); const ip = typeof obj.remoteIp === "string" ? obj.remoteIp : null; const pathEnv = typeof obj.pathEnv === "string" ? obj.pathEnv : null; const versions = formatNodeVersions( obj as { platform?: string; version?: string; coreVersion?: string; uiVersion?: string; }, ); const lastActive = formatNodeTimeAgo(Date.now(), obj.lastActiveAtMs); const stats = formatNodeHostStats(obj.hostStats, connected, Date.now()); const { heading, ok, warn, muted } = getNodesTheme(); const status = `${paired ? ok("paired") : warn("unpaired")} · ${ connected ? ok("connected") : muted("disconnected") }`; const tableWidth = getTerminalTableWidth(); const rows = [ { Field: "ID", Value: sanitizeTerminalText(nodeId) }, displayName ? { Field: "Name", Value: sanitizeTerminalText(displayName) } : null, client ? { Field: "Client", Value: sanitizeTerminalText(client) } : null, ip ? { Field: "IP", Value: sanitizeTerminalText(ip) } : null, family ? { Field: "Device", Value: sanitizeTerminalText(family) } : null, model ? { Field: "Model", Value: sanitizeTerminalText(model) } : null, perms ? { Field: "Perms", Value: sanitizeTerminalText(perms) } : null, versions ? { Field: "Version", Value: sanitizeTerminalText(versions) } : null, stats ? { Field: "Stats", Value: stats } : null, pathEnv ? { Field: "PATH", Value: sanitizeTerminalText(pathEnv) } : null, lastActive ? { Field: "Last input", Value: `${lastActive}${obj.active === true ? " (active node)" : ""}`, } : null, { Field: "Status", Value: status }, approvalState ? { Field: "Approval", Value: formatApprovalStateLabel(approvalState) } : null, pendingRequestId ? { Field: "Pending request", Value: sanitizeTerminalText(pendingRequestId) } : null, pendingCaps ? { Field: "Pending caps", Value: sanitizeTerminalText(pendingCaps.join(", ")) } : null, pendingPerms ? { Field: "Pending perms", Value: sanitizeTerminalText(pendingPerms) } : null, approveCommand ? { Field: approvalState === "pending-reapproval" ? "Reapprove" : "Approve", Value: sanitizeTerminalText(approveCommand), } : null, approveCommand && connectionReminder ? { Field: "Connection reminder", Value: connectionReminder } : null, { Field: "Caps", Value: caps ? sanitizeTerminalText(caps.join(", ")) : "?" }, ].filter(Boolean) as Array<{ Field: string; Value: string }>; defaultRuntime.log(heading("Node")); defaultRuntime.log( renderTable({ width: tableWidth, columns: [ { key: "Field", header: "Field", minWidth: 8 }, { key: "Value", header: "Value", minWidth: 24, flex: true }, ], rows, }).trimEnd(), ); defaultRuntime.log(""); defaultRuntime.log(heading("Commands")); if (commands.length === 0) { defaultRuntime.log(muted("- (none effective)")); } else { for (const c of commands) { defaultRuntime.log(`- ${sanitizeTerminalText(c)}`); } } if (pendingCommands.length > 0) { defaultRuntime.log(""); defaultRuntime.log(heading("Pending commands")); for (const command of pendingCommands) { defaultRuntime.log(`- ${sanitizeTerminalText(command)}`); } } }); }), ); nodesCallOpts( nodes .command("list") .description("List pending and paired nodes") .option("--connected", "Only show connected nodes") .option("--last-connected ", "Only show nodes connected within duration (e.g. 24h)") .action(async (opts: NodesRpcOpts) => { await runNodesCommand("list", async () => { const connectedOnly = Boolean(opts.connected); const sinceMs = parseSinceMs(opts.lastConnected, "Invalid --last-connected"); const result = await callNodesGatewayCli("node.pair.list", opts, {}); const { pending, paired } = parsePairingList(result); const { heading, muted, warn } = getNodesTheme(); const tableWidth = getTerminalTableWidth(); const now = Date.now(); const hasFilters = connectedOnly || sinceMs !== undefined; // Pending requests carry no connection state to filter on; hiding // them under --connected printed "Pending: 0" while requests waited. const pendingRows = pending; const effectiveNodes = hasFilters ? parseNodeList(await callNodeDiagnosticsGatewayCli("node.list", opts, {})) : await tryReadNodeList(opts); const effectivePairedRows = mergePairedNodesWithEffectiveNodes(paired, effectiveNodes); const filteredPaired = effectivePairedRows.filter((node) => matchesNodeConnectionFilter(node, connectedOnly, sinceMs, now), ); const filteredLabel = hasFilters && filteredPaired.length !== effectivePairedRows.length ? ` (of ${effectivePairedRows.length})` : ""; if (opts.json) { defaultRuntime.writeJson({ pending: pendingRows, // Current gateways emit no token, but the permissive parser keeps // unknown fields; strip so an older gateway's legacy node token // never reaches JSON output. paired: filteredPaired.map((row) => { const { token: _token, ...rest } = row as { token?: unknown }; return rest; }), }); return; } defaultRuntime.log( `Pending: ${pendingRows.length} · Paired: ${filteredPaired.length}${filteredLabel}`, ); if (pendingRows.length > 0) { const rendered = renderPendingPairingRequestsTable({ pending: pendingRows, now, tableWidth, theme: { heading, warn, muted }, }); defaultRuntime.log(""); defaultRuntime.log(rendered.heading); defaultRuntime.log(rendered.table); } if (filteredPaired.length > 0) { const pairedTableRows = filteredPaired.map((n) => ({ Node: formatNodeTerminalLabel(n), Id: sanitizeTerminalText(n.nodeId), IP: sanitizeTerminalText(n.remoteIp ?? ""), LastConnect: formatNodeTimeAgo(now, n.lastConnectedAtMs ?? n.connectedAtMs) ?? muted("unknown"), })); defaultRuntime.log(""); defaultRuntime.log(heading("Paired")); defaultRuntime.log( renderTable({ width: tableWidth, columns: [ { key: "Node", header: "Node", minWidth: 14, flex: true }, { key: "Id", header: "ID", minWidth: 10 }, { key: "IP", header: "IP", minWidth: 10 }, { key: "LastConnect", header: "Last Connect", minWidth: 14 }, ], rows: pairedTableRows, }).trimEnd(), ); } }); }), ); }