diff --git a/AGENTS.md b/AGENTS.md index 1112a609e..4f7b2f282 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,6 +70,7 @@ - When answering questions, respond with high-confidence answers only: verify in code; do not guess. - Never update the Carbon dependency. - CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars. +- Status output: keep `clawdbot status` table-based (`src/terminal/table.ts`, flex fills width) + `status --all` log tail summarized/pasteable. - Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.** - macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`. - If shared guardrails are available locally, review them; otherwise follow this repo's guidance. diff --git a/CHANGELOG.md b/CHANGELOG.md index 50a23b371..663bb2e7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 2026.1.10-1 + +### Fixes +- CLI/Status: expand tables to full terminal width; improve update + daemon summary lines; keep `status --all` gateway log tail pasteable. + ## 2026.1.10 ### New Features and Changes diff --git a/package.json b/package.json index f84ef9119..c07de2d29 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawdbot", - "version": "2026.1.10", + "version": "2026.1.10-1", "description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent", "type": "module", "main": "dist/index.js", diff --git a/src/commands/status-all.ts b/src/commands/status-all.ts index a585ffc13..f8d718e5e 100644 --- a/src/commands/status-all.ts +++ b/src/commands/status-all.ts @@ -1,4 +1,5 @@ import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; +import { withProgress } from "../cli/progress.js"; import { loadConfig, readConfigFileSnapshot, @@ -35,6 +36,7 @@ import { import { pickGatewaySelfPresence, readFileTailLines, + summarizeLogTail, } from "./status-all/gateway.js"; import { buildProvidersTable } from "./status-all/providers.js"; @@ -42,526 +44,575 @@ export async function statusAllCommand( runtime: RuntimeEnv, opts?: { timeoutMs?: number }, ): Promise { - const cfg = loadConfig(); - const osSummary = resolveOsSummary(); - const snap = await readConfigFileSnapshot().catch(() => null); + await withProgress( + { label: "Scanning status --all…", indeterminate: true }, + async (progress) => { + progress.setLabel("Loading config…"); + 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, - }); + progress.setLabel("Checking for updates…"); + 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"; + progress.setLabel("Probing gateway…"); + 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, + 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 }; }; - } catch { - return null; - } - })(); - const agentStatus = await getAgentLocalStatuses(cfg); - const providers = await buildProvidersTable(cfg); + const localFallbackAuth = resolveProbeAuth("local"); + const remoteAuth = resolveProbeAuth("remote"); - 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 - ? { + const gatewayProbe = await probeGateway({ url: connection.url, - token: localFallbackAuth.token, - password: localFallbackAuth.password, - } - : {}; - - const health = gatewayReachable - ? await callGateway({ - 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>({ - 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}`, + 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, ); - } - 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; - 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))}`); - } + progress.setLabel("Checking daemon…"); + const daemon = await (async () => { + try { + const service = resolveGatewayService(); + const [loaded, runtimeInfo, 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; + return { + label: service.label, + installed, + loaded, + loadedText: loaded ? service.loadedText : service.notLoadedText, + runtime: runtimeInfo, + }; + } catch { + return null; + } + })(); - lines.push(""); - lines.push(muted("Pasteable debug report. Auth tokens redacted.")); - lines.push(""); + progress.setLabel("Scanning agents…"); + const agentStatus = await getAgentLocalStatuses(cfg); + progress.setLabel("Summarizing providers…"); + const providers = await buildProvidersTable(cfg); - runtime.log(lines.join("\n")); + 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, + } + : {}; + + progress.setLabel("Querying gateway…"); + const health = gatewayReachable + ? await callGateway({ + 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>({ + 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) + : []; + + progress.setLabel("Checking local state…"); + 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"); + + const latest = update.registry?.latestVersion; + if (latest) { + const cmp = compareSemverStrings(VERSION, latest); + if (cmp === 0) parts.push(`npm latest ${latest}`); + else if (cmp != null && cmp < 0) parts.push(`npm update ${latest}`); + else parts.push(`npm latest ${latest} (local newer)`); + } else if (update.registry?.error) { + parts.push("npm latest unknown"); + } + + if (update.deps?.status === "ok") parts.push("deps ok"); + 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(`npm latest ${latest}`); + else if (cmp != null && cmp < 0) parts.push(`npm update ${latest}`); + else parts.push(`npm latest ${latest} (local newer)`); + } else if (update.registry?.error) { + parts.push("npm latest unknown"); + } + if (update.deps?.status === "ok") parts.push("deps ok"); + 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.installed === false + ? `${daemon.label} not installed` + : `${daemon.label} ${daemon.installed ? "installed · " : ""}${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 = Math.max(60, (process.stdout.columns ?? 120) - 1); + + 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 · ${skillStatus.workspaceDir}`, + missing === 0 ? "ok" : "warn", + ); + } + + const logPaths = (() => { + try { + return resolveGatewayLogPaths(process.env); + } catch { + return null; + } + })(); + if (logPaths) { + progress.setLabel("Reading logs…"); + 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, summarized): ${logPaths.logDir}`)}`, + ); + lines.push(` ${muted(`# stderr: ${logPaths.stderrPath}`)}`); + for (const line of summarizeLogTail(stderrTail, { maxLines: 22 }).map( + redactSecrets, + )) { + lines.push(` ${muted(line)}`); + } + lines.push(` ${muted(`# stdout: ${logPaths.stdoutPath}`)}`); + for (const line of summarizeLogTail(stdoutTail, { maxLines: 22 }).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; + 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(""); + + progress.setLabel("Rendering…"); + runtime.log(lines.join("\n")); + }, + ); } diff --git a/src/commands/status-all/gateway.ts b/src/commands/status-all/gateway.ts index eed40597a..add882237 100644 --- a/src/commands/status-all/gateway.ts +++ b/src/commands/status-all/gateway.ts @@ -13,6 +13,177 @@ export async function readFileTailLines( .filter((line) => line.trim().length > 0); } +function countMatches(haystack: string, needle: string): number { + if (!haystack || !needle) return 0; + return haystack.split(needle).length - 1; +} + +function shorten(message: string, maxLen: number): string { + const cleaned = message.replace(/\s+/g, " ").trim(); + if (cleaned.length <= maxLen) return cleaned; + return `${cleaned.slice(0, Math.max(0, maxLen - 1))}…`; +} + +function normalizeGwsLine(line: string): string { + return line + .replace(/\s+runId=[^\s]+/g, "") + .replace(/\s+conn=[^\s]+/g, "") + .replace(/\s+id=[^\s]+/g, "") + .replace(/\s+error=Error:.*$/g, "") + .trim(); +} + +function consumeJsonBlock( + lines: string[], + startIndex: number, +): { json: string; endIndex: number } | null { + const startLine = lines[startIndex] ?? ""; + const braceAt = startLine.indexOf("{"); + if (braceAt < 0) return null; + + const parts: string[] = [startLine.slice(braceAt)]; + let depth = + countMatches(parts[0] ?? "", "{") - countMatches(parts[0] ?? "", "}"); + let i = startIndex; + while (depth > 0 && i + 1 < lines.length) { + i += 1; + const next = lines[i] ?? ""; + parts.push(next); + depth += countMatches(next, "{") - countMatches(next, "}"); + } + return { json: parts.join("\n"), endIndex: i }; +} + +export function summarizeLogTail( + rawLines: string[], + opts?: { maxLines?: number }, +): string[] { + const maxLines = Math.max(6, opts?.maxLines ?? 26); + + const out: string[] = []; + const groups = new Map< + string, + { count: number; index: number; base: string } + >(); + + const addGroup = (key: string, base: string) => { + const existing = groups.get(key); + if (existing) { + existing.count += 1; + return; + } + groups.set(key, { count: 1, index: out.length, base }); + out.push(base); + }; + + const addLine = (line: string) => { + const trimmed = line.trimEnd(); + if (!trimmed) return; + out.push(trimmed); + }; + + const lines = rawLines.map((line) => line.trimEnd()).filter(Boolean); + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i] ?? ""; + const trimmedStart = line.trimStart(); + if ( + (trimmedStart.startsWith('"') || + trimmedStart === "}" || + trimmedStart === "{" || + trimmedStart.startsWith("}") || + trimmedStart.startsWith("{")) && + !trimmedStart.startsWith("[") && + !trimmedStart.startsWith("#") + ) { + // Tail can cut in the middle of a JSON blob; drop orphaned JSON fragments. + continue; + } + + // "[openai-codex] Token refresh failed: 401 { ...json... }" + const tokenRefresh = line.match( + /^\[([^\]]+)\]\s+Token refresh failed:\s*(\d+)\s*(\{)?\s*$/, + ); + if (tokenRefresh) { + const tag = tokenRefresh[1] ?? "unknown"; + const status = tokenRefresh[2] ?? "unknown"; + const block = consumeJsonBlock(lines, i); + if (block) { + i = block.endIndex; + const parsed = (() => { + try { + return JSON.parse(block.json) as { + error?: { code?: string; message?: string }; + }; + } catch { + return null; + } + })(); + const code = parsed?.error?.code?.trim() || null; + const msg = parsed?.error?.message?.trim() || null; + const msgShort = msg + ? msg.toLowerCase().includes("signing in again") + ? "re-auth required" + : shorten(msg, 52) + : null; + const base = `[${tag}] token refresh ${status}${code ? ` ${code}` : ""}${msgShort ? ` · ${msgShort}` : ""}`; + addGroup( + `token:${tag}:${status}:${code ?? ""}:${msgShort ?? ""}`, + base, + ); + continue; + } + } + + // "Embedded agent failed before reply: OAuth token refresh failed for openai-codex: ..." + const embedded = line.match( + /^Embedded agent failed before reply:\s+OAuth token refresh failed for ([^:]+):/, + ); + if (embedded) { + const provider = embedded[1]?.trim() || "unknown"; + addGroup( + `embedded:${provider}`, + `Embedded agent: OAuth token refresh failed (${provider})`, + ); + continue; + } + + // "[gws] ⇄ res ✗ agent ... errorCode=UNAVAILABLE errorMessage=Error: OAuth token refresh failed ... runId=..." + if ( + line.startsWith("[gws]") && + line.includes("errorCode=UNAVAILABLE") && + line.includes("OAuth token refresh failed") + ) { + const normalized = normalizeGwsLine(line); + addGroup(`gws:${normalized}`, normalized); + continue; + } + + addLine(line); + } + + for (const g of groups.values()) { + if (g.count <= 1) continue; + out[g.index] = `${g.base} ×${g.count}`; + } + + const deduped: string[] = []; + for (const line of out) { + if (deduped[deduped.length - 1] === line) continue; + deduped.push(line); + } + + if (deduped.length <= maxLines) return deduped; + + const head = Math.min(6, Math.floor(maxLines / 3)); + const tail = Math.max(1, maxLines - head - 1); + const kept = [ + ...deduped.slice(0, head), + `… ${deduped.length - head - tail} lines omitted …`, + ...deduped.slice(-tail), + ]; + return kept; +} + export function pickGatewaySelfPresence(presence: unknown): { host?: string; ip?: string; diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index e9acd167c..8d21b03e3 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -104,6 +104,10 @@ vi.mock("../daemon/service.js", () => ({ notLoadedText: "not loaded", isLoaded: async () => true, readRuntime: async () => ({ status: "running", pid: 1234 }), + readCommand: async () => ({ + programArguments: ["node", "dist/entry.js", "gateway"], + sourcePath: "/tmp/Library/LaunchAgents/com.clawdbot.gateway.plist", + }), }), })); @@ -133,16 +137,17 @@ describe("statusCommand", () => { (runtime.log as vi.Mock).mockClear(); await statusCommand({}, runtime as never); const logs = (runtime.log as vi.Mock).mock.calls.map((c) => String(c[0])); - expect(logs.some((l) => l.includes("Web session"))).toBe(true); - expect(logs.some((l) => l.includes("Active sessions"))).toBe(true); - expect(logs.some((l) => l.includes("Default model"))).toBe(true); - expect(logs.some((l) => l.includes("tokens:"))).toBe(true); - expect(logs.some((l) => l.includes("Daemon:"))).toBe(true); + expect(logs.some((l) => l.includes("Clawdbot status"))).toBe(true); + expect(logs.some((l) => l.includes("Overview"))).toBe(true); + expect(logs.some((l) => l.includes("Dashboard"))).toBe(true); + expect(logs.some((l) => l.includes("macos 14.0 (arm64)"))).toBe(true); + expect(logs.some((l) => l.includes("Providers"))).toBe(true); + expect(logs.some((l) => l.includes("Telegram"))).toBe(true); + expect(logs.some((l) => l.includes("Sessions"))).toBe(true); + expect(logs.some((l) => l.includes("+1000"))).toBe(true); + expect(logs.some((l) => l.includes("50%"))).toBe(true); + expect(logs.some((l) => l.includes("LaunchAgent"))).toBe(true); expect(logs.some((l) => l.includes("FAQ:"))).toBe(true); expect(logs.some((l) => l.includes("Troubleshooting:"))).toBe(true); - expect( - logs.some((l) => l.includes("flags:") && l.includes("verbose:on")), - ).toBe(true); - expect(mocks.logWebSelfId).toHaveBeenCalled(); }); }); diff --git a/src/commands/status.ts b/src/commands/status.ts index d500b08de..f6e3e14a4 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -9,20 +9,14 @@ 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, - readConfigFileSnapshot, - resolveGatewayPort, -} from "../config/config.js"; +import { loadConfig, 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"; @@ -30,17 +24,11 @@ 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, @@ -48,17 +36,15 @@ import { 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, - logWebSelfId, - webAuthExists, -} from "../web/session.js"; +import { getWebAuthAgeMs, webAuthExists } from "../web/session.js"; import type { HealthSummary } from "./health.js"; import { resolveControlUiLinks } from "./onboard-helpers.js"; +import { buildProvidersTable } from "./status-all/providers.js"; import { statusAllCommand } from "./status-all.js"; export type SessionStatus = { @@ -206,19 +192,20 @@ const formatDuration = (ms: number | null | undefined) => { return `${(ms / 1000).toFixed(1)}s`; }; -const formatContextUsage = ( - total: number | null | undefined, - contextTokens: number | null | undefined, - remaining: number | null | undefined, - pct: number | null | undefined, +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, ) => { - const used = total ?? 0; - if (!contextTokens) { - return `tokens: ${formatKTokens(used)} used (ctx unknown)`; - } - const left = remaining ?? Math.max(0, contextTokens - used); - const pctLabel = pct != null ? `${pct}%` : "?%"; - return `tokens: ${formatKTokens(used)} used, ${formatKTokens(left)} left of ${formatKTokens(contextTokens)} (${pctLabel})`; + 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 = ( @@ -243,6 +230,7 @@ const formatDaemonRuntimeShort = (runtime?: { pid?: number; state?: string; detail?: string; + missingUnit?: boolean; }) => { if (!runtime) return null; const status = runtime.status ?? "unknown"; @@ -251,22 +239,38 @@ const formatDaemonRuntimeShort = (runtime?: { if (runtime.state && runtime.state.toLowerCase() !== status) { details.push(`state ${runtime.state}`); } - if (runtime.detail) details.push(runtime.detail); + 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 getDaemonShortLine(): Promise { +async function getDaemonStatusSummary(): Promise<{ + label: string; + installed: boolean | null; + loadedText: string; + runtimeShort: string | null; +}> { try { const service = resolveGatewayService(); - const [loaded, runtime] = await Promise.all([ + 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 `Daemon: ${service.label} ${loadedText}${runtimeShort ? `, ${runtimeShort}` : ""}. Details: clawdbot daemon status`; + return { label: service.label, installed, loadedText, runtimeShort }; } catch { - return "Daemon: unknown. Details: clawdbot daemon status"; + return { + label: "Daemon", + installed: null, + loadedText: "unknown", + runtimeShort: null, + }; } } @@ -449,20 +453,31 @@ function formatUpdateOneLiner(update: UpdateCheckResult): string { } } 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(`latest ${update.registry.latestVersion}`); + if (cmp === 0) parts.push(`npm latest ${update.registry.latestVersion}`); else if (cmp != null && cmp < 0) { - parts.push(`update available ${update.registry.latestVersion}`); + parts.push(`npm update ${update.registry.latestVersion}`); } else { - parts.push(`latest ${update.registry.latestVersion}`); + parts.push(`npm latest ${update.registry.latestVersion} (local newer)`); } } else if (update.registry?.error) { - parts.push("latest unknown"); + parts.push("npm latest unknown"); } } @@ -474,26 +489,6 @@ function formatUpdateOneLiner(update: UpdateCheckResult): string { 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; @@ -535,7 +530,7 @@ export async function statusCommand( const scan = await withProgress( { label: "Scanning status…", - total: 6, + total: 7, enabled: opts.json !== true, }, async (progress) => { @@ -582,6 +577,10 @@ export async function statusCommand( : null; progress.tick(); + progress.setLabel("Summarizing providers…"); + const providers = await buildProvidersTable(cfg); + progress.tick(); + progress.setLabel("Reading sessions…"); const summary = await getStatusSummary(); progress.tick(); @@ -600,6 +599,7 @@ export async function statusCommand( gatewayReachable, gatewaySelf, agentStatus, + providers, summary, }; }, @@ -616,6 +616,7 @@ export async function statusCommand( gatewayReachable, gatewaySelf, agentStatus, + providers, summary, } = scan; const usage = opts.usage @@ -671,40 +672,44 @@ export async function statusCommand( return; } - if (opts.verbose || opts.all) { + 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}`); - } + for (const line of details.message.split("\n")) runtime.log(` ${line}`); + runtime.log(""); } - const controlUiEnabled = cfg.gateway?.controlUi?.enabled ?? true; - if (!controlUiEnabled) { - runtime.log(info("Dashboard: disabled")); - } else { + 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, }); - runtime.log(info(`Dashboard: ${links.httpUrl}`)); - } + return links.httpUrl; + })(); - runtime.log(info(`OS: ${osSummary.label} · node ${process.versions.node}`)); - runtime.log(info(formatUpdateOneLiner(update))); - - const gatewayLine = (() => { + const gatewayValue = (() => { const target = remoteUrlMissing - ? "(missing gateway.remote.url)" - : gatewayConnection.url; + ? `fallback ${gatewayConnection.url}` + : `${gatewayConnection.url}${gatewayConnection.urlSource ? ` (${gatewayConnection.urlSource})` : ""}`; const reach = remoteUrlMissing - ? "misconfigured (missing gateway.remote.url)" + ? warn("misconfigured (remote.url missing)") : gatewayReachable - ? `reachable (${formatDuration(gatewayProbe?.connectLatencyMs)})` - : gatewayProbe?.error - ? `unreachable (${gatewayProbe.error})` - : "unreachable"; + ? ok(`reachable ${formatDuration(gatewayProbe?.connectLatencyMs)}`) + : warn( + gatewayProbe?.error + ? `unreachable (${gatewayProbe.error})` + : "unreachable", + ); const self = gatewaySelf?.host || gatewaySelf?.version || gatewaySelf?.platform ? [ @@ -717,11 +722,10 @@ export async function statusCommand( .join(" ") : null; const suffix = self ? ` · ${self}` : ""; - return `Gateway: ${gatewayMode} · ${target} · ${reach}${suffix}`; + return `${gatewayMode} · ${target} · ${reach}${suffix}`; })(); - runtime.log(info(gatewayLine)); - const agentLine = (() => { + const agentsValue = (() => { const pending = agentStatus.bootstrapPendingCount > 0 ? `${agentStatus.bootstrapPendingCount} bootstrapping` @@ -730,300 +734,192 @@ export async function statusCommand( 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}`; + return `${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)})` : ""}`, - ); - if (summary.web.linked) { - const account = resolveWhatsAppAccount({ cfg }); - logWebSelfId(account.authDir, runtime, true); - } - runtime.log(""); - runtime.log(info("System:")); - for (const line of summary.providerSummary) { - runtime.log(` ${line}`); - } - const daemonLine = await getDaemonShortLine(); - if (daemonLine) { - runtime.log(info(daemonLine)); - } + 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}` : ""}`; + })(); - 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>({ - 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")); - - const tgLine = health.telegram.configured - ? health.telegram.probe?.ok - ? info( - `Telegram: ok${health.telegram.probe.bot?.username ? ` (@${health.telegram.probe.bot.username})` : ""} (${health.telegram.probe.elapsedMs}ms)` + - (health.telegram.probe.webhook?.url - ? ` - webhook ${health.telegram.probe.webhook.url}` - : ""), - ) - : `Telegram: failed (${health.telegram.probe?.status ?? "unknown"})${health.telegram.probe?.error ? ` - ${health.telegram.probe.error}` : ""}` - : info("Telegram: not configured"); - runtime.log(tgLine); - - const discordLine = health.discord.configured - ? health.discord.probe?.ok - ? info( - `Discord: ok${health.discord.probe.bot?.username ? ` (@${health.discord.probe.bot.username})` : ""} (${health.discord.probe.elapsedMs}ms)`, - ) - : `Discord: failed (${health.discord.probe?.status ?? "unknown"})${health.discord.probe?.error ? ` - ${health.discord.probe.error}` : ""}` - : info("Discord: not configured"); - runtime.log(discordLine); - } else { - runtime.log(info("Provider probes: skipped (use --deep)")); - } - runtime.log(""); - if (summary.queuedSystemEvents.length > 0) { - const preview = summary.queuedSystemEvents.slice(0, 3).join(" | "); - runtime.log( - info( - `Queued system events (${summary.queuedSystemEvents.length}): ${preview}`, - ), - ); - } - runtime.log(info(`Heartbeat: ${summary.heartbeatSeconds}s`)); - runtime.log(info(`Session store: ${summary.sessions.path}`)); const defaults = summary.sessions.defaults; const defaultCtx = defaults.contextTokens ? ` (${formatKTokens(defaults.contextTokens)} ctx)` : ""; - runtime.log( - info(`Default model: ${defaults.model ?? "unknown"}${defaultCtx}`), - ); - runtime.log(info(`Active sessions: ${summary.sessions.count}`)); - if (summary.sessions.recent.length > 0) { - runtime.log("Recent sessions:"); - for (const r of summary.sessions.recent) { - runtime.log( - `- ${r.key} [${r.kind}] | ${r.updatedAt ? formatAge(r.age) : "no activity"} | model ${r.model ?? "unknown"} | ${formatContextUsage(r.totalTokens, r.contextTokens, r.remainingTokens, r.percentUsed)}${r.flags.length ? ` | flags: ${r.flags.join(", ")}` : ""}`, - ); - } - } else { - runtime.log("No session activity yet."); - } + 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: "Configured", header: "Configured", minWidth: 10 }, + { key: "Detail", header: "Detail", flex: true, minWidth: 24 }, + ], + rows: 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, + })), + }).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> = []; + 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"); } diff --git a/src/terminal/table.test.ts b/src/terminal/table.test.ts index 4de60503b..3bda0e9e0 100644 --- a/src/terminal/table.test.ts +++ b/src/terminal/table.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { visibleWidth } from "./ansi.js"; import { renderTable } from "./table.js"; describe("renderTable", () => { @@ -16,4 +17,19 @@ describe("renderTable", () => { expect(out).toContain("Dashboard"); expect(out).toMatch(/│ Dashboard\s+│/); }); + + it("expands flex columns to fill available width", () => { + const width = 60; + const out = renderTable({ + width, + columns: [ + { key: "Item", header: "Item", minWidth: 10 }, + { key: "Value", header: "Value", flex: true, minWidth: 24 }, + ], + rows: [{ Item: "OS", Value: "macos 26.2 (arm64)" }], + }); + + const firstLine = out.trimEnd().split("\n")[0] ?? ""; + expect(visibleWidth(firstLine)).toBe(width); + }); }); diff --git a/src/terminal/table.ts b/src/terminal/table.ts index 6c9907fef..83b9ba0ec 100644 --- a/src/terminal/table.ts +++ b/src/terminal/table.ts @@ -196,6 +196,39 @@ export function renderTable(opts: RenderTableOptions): string { shrink(nonFlexOrder, absoluteMinWidths); } + // If we have room and any flex columns, expand them to fill the available width. + // This keeps tables from looking "clipped" and reduces wrapping in wide terminals. + if (maxWidth) { + const sepCount = columns.length + 1; + const currentTotal = widths.reduce((a, b) => a + b, 0) + sepCount; + let extra = maxWidth - currentTotal; + if (extra > 0) { + const flexCols = columns + .map((c, i) => ({ c, i })) + .filter(({ c }) => Boolean(c.flex)) + .map(({ i }) => i); + if (flexCols.length > 0) { + const caps = columns.map((c) => + typeof c.maxWidth === "number" && c.maxWidth > 0 + ? Math.floor(c.maxWidth) + : Number.POSITIVE_INFINITY, + ); + while (extra > 0) { + let progressed = false; + for (const i of flexCols) { + if ((widths[i] ?? 0) >= (caps[i] ?? Number.POSITIVE_INFINITY)) + continue; + widths[i] = (widths[i] ?? 0) + 1; + extra -= 1; + progressed = true; + if (extra <= 0) break; + } + if (!progressed) break; + } + } + } + } + const box = border === "ascii" ? {