diff --git a/AGENTS.md b/AGENTS.md index 4f7b2f2824..7893dca4f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +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. +- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes. - 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 9369e859c7..d2fa19799a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 2026.1.11-5 + +### Fixes +- CLI/Status: surface gateway provider runtime errors (Signal/iMessage/Slack) in the Providers table. +- CLI/Status: improve Tailscale reporting in `status --all` and harden parsing of noisy `tailscale status --json` output. +- CLI/Status: make `status --all` scan progress determinate (OSC progress + spinner). +- Terminal/Table: ANSI-safe wrapping to prevent table clipping/color loss; add regression coverage. + ## 2026.1.11-4 ### Fixes diff --git a/docs/cli/index.md b/docs/cli/index.md index 2d25cb1da9..9b0fb9201a 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -226,7 +226,7 @@ Manage chat provider accounts (WhatsApp/Telegram/Discord/Slack/Signal/iMessage). Subcommands: - `providers list`: show configured chat providers and auth profiles (Claude Code + Codex CLI OAuth sync included). -- `providers status`: check gateway reachability and provider health (`--probe` to verify credentials and run small provider audits; use `status --deep` for local-only probes). +- `providers status`: check gateway reachability and provider health (`--probe` runs extra checks; use `clawdbot health` or `clawdbot status --deep` for gateway health probes). - Tip: `providers status` prints warnings with suggested fixes when it can detect common misconfigurations (then points you to `clawdbot doctor`). - `providers add`: wizard-style setup when no flags are passed; flags switch to non-interactive mode. - `providers remove`: disable by default; pass `--delete` to remove config entries without prompts. diff --git a/docs/gateway/health.md b/docs/gateway/health.md index 068be36157..a979fad305 100644 --- a/docs/gateway/health.md +++ b/docs/gateway/health.md @@ -10,7 +10,7 @@ Short guide to verify the WhatsApp Web / Baileys stack without guessing. ## Quick checks - `clawdbot status` — local summary: gateway reachability/mode, update hint, creds/auth age, sessions + recent activity. - `clawdbot status --all` — full local diagnosis (read-only, color, safe to paste for debugging). -- `clawdbot status --deep` — also probes the running Gateway (WhatsApp connect + Telegram + Discord APIs). +- `clawdbot status --deep` — adds gateway health probes to status output (Telegram + Discord APIs; requires reachable gateway). - `clawdbot health --json` — asks the running Gateway for a full health snapshot (WS-only; no direct Baileys socket). - Send `/status` as a standalone message in WhatsApp/WebChat to get a status reply without invoking the agent. - Logs: tail `/tmp/clawdbot/clawdbot-*.log` and filter for `web-heartbeat`, `web-reconnect`, `web-auto-reply`, `web-inbound`. diff --git a/docs/start/clawd.md b/docs/start/clawd.md index 13e17cd5d3..837ef5c6c4 100644 --- a/docs/start/clawd.md +++ b/docs/start/clawd.md @@ -207,7 +207,7 @@ Clawdbot extracts these and sends them as media alongside the text. ```bash clawdbot status # local status (creds, sessions, queued events) clawdbot status --all # full diagnosis (read-only, pasteable) -clawdbot status --deep # also probes the running Gateway (WA connect + Telegram) +clawdbot status --deep # adds gateway health probes (Telegram + Discord) clawdbot health --json # gateway health snapshot (WS) ``` diff --git a/docs/start/getting-started.md b/docs/start/getting-started.md index 7e5039f7ef..ec2f77855d 100644 --- a/docs/start/getting-started.md +++ b/docs/start/getting-started.md @@ -166,8 +166,8 @@ clawdbot message send --to +15555550123 --message "Hello from Clawdbot" If `health` shows “no auth configured”, go back to the wizard and set OAuth/key auth — the agent won’t be able to respond without it. -Local probe tip: `clawdbot status --deep` runs provider checks without needing a gateway connection. -Gateway snapshot: `clawdbot providers status` shows what the gateway reports (use `status --deep` for local-only probes). +Tip: `clawdbot status --all` is the best pasteable, read-only debug report. +Health probes: `clawdbot health` (or `clawdbot status --deep`) asks the running gateway for a health snapshot. ## Next steps (optional, but great) diff --git a/docs/start/wizard.md b/docs/start/wizard.md index 4e69123c96..97e4af7ed2 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -118,7 +118,7 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` ( 7) **Health check** - Starts the Gateway (if needed) and runs `clawdbot health`. - - Tip: `clawdbot status --deep` runs local provider probes without a gateway. + - Tip: `clawdbot status --deep` adds gateway health probes to status output (requires a reachable gateway). 8) **Skills (recommended)** - Reads the available skills and checks requirements. diff --git a/package.json b/package.json index 105390fd54..241e356be2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawdbot", - "version": "2026.1.11-4", + "version": "2026.1.11-5", "description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent", "type": "module", "main": "dist/index.js", diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 89346148dd..1cd95039ed 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -33,6 +33,9 @@ export function buildAgentSystemPrompt(params: { browserControlUrl?: string; browserNoVncUrl?: string; hostBrowserAllowed?: boolean; + allowedControlUrls?: string[]; + allowedControlHosts?: string[]; + allowedControlPorts?: number[]; elevated?: { allowed: boolean; defaultLevel: "on" | "off"; diff --git a/src/commands/providers.test.ts b/src/commands/providers.test.ts index 0bacb9ce8a..e978e167a5 100644 --- a/src/commands/providers.test.ts +++ b/src/commands/providers.test.ts @@ -431,4 +431,38 @@ describe("providers command", () => { }); expect(disconnected.join("\n")).toMatch(/disconnected/i); }); + + it("surfaces Signal runtime errors in providers status output", () => { + const lines = formatGatewayProvidersStatusLines({ + signalAccounts: [ + { + accountId: "default", + enabled: true, + configured: true, + running: false, + lastError: "signal-cli unreachable", + }, + ], + }); + expect(lines.join("\n")).toMatch(/Warnings:/); + expect(lines.join("\n")).toMatch(/signal/i); + expect(lines.join("\n")).toMatch(/Provider error/i); + }); + + it("surfaces iMessage runtime errors in providers status output", () => { + const lines = formatGatewayProvidersStatusLines({ + imessageAccounts: [ + { + accountId: "default", + enabled: true, + configured: true, + running: false, + lastError: "imsg permission denied", + }, + ], + }); + expect(lines.join("\n")).toMatch(/Warnings:/); + expect(lines.join("\n")).toMatch(/imessage/i); + expect(lines.join("\n")).toMatch(/Provider error/i); + }); }); diff --git a/src/commands/status-all.ts b/src/commands/status-all.ts index c862727433..11b7554c1d 100644 --- a/src/commands/status-all.ts +++ b/src/commands/status-all.ts @@ -9,6 +9,7 @@ import { readLastGatewayErrorLine } from "../daemon/diagnostics.js"; import { resolveGatewayLogPaths } from "../daemon/launchd.js"; import { resolveGatewayService } from "../daemon/service.js"; import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; +import { normalizeControlUiBasePath } from "../gateway/control-ui.js"; import { probeGateway } from "../gateway/probe.js"; import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js"; import { resolveOsSummary } from "../infra/os-summary.js"; @@ -18,10 +19,12 @@ import { readRestartSentinel, summarizeRestartSentinel, } from "../infra/restart-sentinel.js"; +import { readTailscaleStatusJson } from "../infra/tailscale.js"; import { checkUpdateStatus, compareSemverStrings, } from "../infra/update-check.js"; +import { runExec } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; import { renderTable } from "../terminal/table.js"; import { isRich, theme } from "../terminal/theme.js"; @@ -46,12 +49,54 @@ export async function statusAllCommand( opts?: { timeoutMs?: number }, ): Promise { await withProgress( - { label: "Scanning status --all…", indeterminate: true }, + { label: "Scanning status --all…", total: 11 }, async (progress) => { progress.setLabel("Loading config…"); const cfg = loadConfig(); const osSummary = resolveOsSummary(); const snap = await readConfigFileSnapshot().catch(() => null); + progress.tick(); + + progress.setLabel("Checking Tailscale…"); + const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; + const tailscale = await (async () => { + try { + const parsed = await readTailscaleStatusJson(runExec, { + timeoutMs: 1200, + }); + const backendState = + typeof parsed.BackendState === "string" + ? parsed.BackendState + : null; + const self = + typeof parsed.Self === "object" && parsed.Self !== null + ? (parsed.Self as Record) + : null; + const dnsNameRaw = + self && typeof self.DNSName === "string" ? self.DNSName : null; + const dnsName = dnsNameRaw ? dnsNameRaw.replace(/\.$/, "") : null; + const ips = + self && Array.isArray(self.TailscaleIPs) + ? (self.TailscaleIPs as unknown[]) + .filter((v) => typeof v === "string" && v.trim().length > 0) + .map((v) => (v as string).trim()) + : []; + return { ok: true as const, backendState, dnsName, ips, error: null }; + } catch (err) { + return { + ok: false as const, + backendState: null, + dnsName: null, + ips: [] as string[], + error: String(err), + }; + } + })(); + const tailscaleHttpsUrl = + tailscaleMode !== "off" && tailscale.dnsName + ? `https://${tailscale.dnsName}${normalizeControlUiBasePath(cfg.gateway?.controlUi?.basePath)}` + : null; + progress.tick(); progress.setLabel("Checking for updates…"); const root = await resolveClawdbotPackageRoot({ @@ -65,6 +110,7 @@ export async function statusAllCommand( fetchGit: true, includeRegistry: true, }); + progress.tick(); progress.setLabel("Probing gateway…"); const connection = buildGatewayConnectionDetails({ config: cfg }); @@ -113,6 +159,7 @@ export async function statusAllCommand( const gatewaySelf = pickGatewaySelfPresence( gatewayProbe?.presence ?? null, ); + progress.tick(); progress.setLabel("Checking daemon…"); const daemon = await (async () => { @@ -137,11 +184,14 @@ export async function statusAllCommand( return null; } })(); + progress.tick(); progress.setLabel("Scanning agents…"); const agentStatus = await getAgentLocalStatuses(cfg); + progress.tick(); progress.setLabel("Summarizing providers…"); const providers = await buildProvidersTable(cfg, { showSecrets: false }); + progress.tick(); const connectionDetailsForReport = (() => { if (!remoteUrlMissing) return connection.message; @@ -187,6 +237,7 @@ export async function statusAllCommand( const providerIssues = providersStatus ? collectProvidersStatusIssues(providersStatus) : []; + progress.tick(); progress.setLabel("Checking local state…"); const sentinel = await readRestartSentinel().catch(() => null); @@ -195,6 +246,7 @@ export async function statusAllCommand( ); const port = resolveGatewayPort(cfg); const portUsage = await inspectPortUsage(port).catch(() => null); + progress.tick(); const defaultWorkspace = agentStatus.agents.find((a) => a.id === agentStatus.defaultId) @@ -322,6 +374,15 @@ export async function statusAllCommand( dashboard ? { Item: "Dashboard", Value: dashboard } : { Item: "Dashboard", Value: "disabled" }, + { + Item: "Tailscale", + Value: + tailscaleMode === "off" + ? `off${tailscale.backendState ? ` · ${tailscale.backendState}` : ""}${tailscale.dnsName ? ` · ${tailscale.dnsName}` : ""}` + : tailscale.dnsName && tailscaleHttpsUrl + ? `${tailscaleMode} · ${tailscale.backendState ?? "unknown"} · ${tailscale.dnsName} · ${tailscaleHttpsUrl}` + : `${tailscaleMode} · ${tailscale.backendState ?? "unknown"} · magicdns unknown`, + }, { Item: "Update", Value: updateLine }, { Item: "Gateway", @@ -376,6 +437,46 @@ export async function statusAllCommand( : theme.accentDim("SETUP"), Detail: row.detail, })); + const providerIssuesByProvider = (() => { + const map = new Map(); + for (const issue of providerIssues) { + const key = issue.provider; + const list = map.get(key); + if (list) list.push(issue); + else map.set(key, [issue]); + } + return map; + })(); + const providerKeyForLabel = (label: string) => { + switch (label) { + case "WhatsApp": + return "whatsapp"; + case "Telegram": + return "telegram"; + case "Discord": + return "discord"; + case "Slack": + return "slack"; + case "Signal": + return "signal"; + case "iMessage": + return "imessage"; + default: + return label.toLowerCase(); + } + }; + const providerRowsWithIssues = providerRows.map((row) => { + const providerKey = providerKeyForLabel(row.Provider); + const issues = providerIssuesByProvider.get(providerKey) ?? []; + if (issues.length === 0) return row; + const issue = issues[0]; + const suffix = ` · ${warn(`gateway: ${String(issue.message).slice(0, 90)}`)}`; + return { + ...row, + State: warn("WARN"), + Detail: `${row.Detail}${suffix}`, + }; + }); const providersTable = renderTable({ width: tableWidth, @@ -385,7 +486,7 @@ export async function statusAllCommand( { key: "State", header: "State", minWidth: 8 }, { key: "Detail", header: "Detail", flex: true, minWidth: 28 }, ], - rows: providerRows, + rows: providerRowsWithIssues, }); const agentRows = agentStatus.agents.map((a) => ({ @@ -531,6 +632,31 @@ export async function statusAllCommand( } } + { + const backend = tailscale.backendState ?? "unknown"; + const okBackend = backend === "Running"; + const hasDns = Boolean(tailscale.dnsName); + const label = + tailscaleMode === "off" + ? `Tailscale: off · ${backend}${tailscale.dnsName ? ` · ${tailscale.dnsName}` : ""}` + : `Tailscale: ${tailscaleMode} · ${backend}${tailscale.dnsName ? ` · ${tailscale.dnsName}` : ""}`; + emitCheck( + label, + okBackend && (tailscaleMode === "off" || hasDns) ? "ok" : "warn", + ); + if (tailscale.error) { + lines.push(` ${muted(`error: ${tailscale.error}`)}`); + } + if (tailscale.ips.length > 0) { + lines.push( + ` ${muted(`ips: ${tailscale.ips.slice(0, 3).join(", ")}${tailscale.ips.length > 3 ? "…" : ""}`)}`, + ); + } + if (tailscaleHttpsUrl) { + lines.push(` ${muted(`https: ${tailscaleHttpsUrl}`)}`); + } + } + if (skillStatus) { const eligible = skillStatus.skills.filter((s) => s.eligible).length; const missing = skillStatus.skills.filter( @@ -543,6 +669,7 @@ export async function statusAllCommand( ); } + progress.setLabel("Reading logs…"); const logPaths = (() => { try { return resolveGatewayLogPaths(process.env); @@ -575,6 +702,7 @@ export async function statusAllCommand( } } } + progress.tick(); if (providersStatus) { emitCheck( @@ -623,6 +751,7 @@ export async function statusAllCommand( progress.setLabel("Rendering…"); runtime.log(lines.join("\n")); + progress.tick(); }, ); } diff --git a/src/commands/status-all/providers.ts b/src/commands/status-all/providers.ts index d305d15949..f32fb80a93 100644 --- a/src/commands/status-all/providers.ts +++ b/src/commands/status-all/providers.ts @@ -285,6 +285,8 @@ export async function buildProvidersTable( ); const siEnabledAccounts = siAccounts.filter((a) => a.enabled); const siConfiguredAccounts = siEnabledAccounts.filter((a) => a.configured); + const siSample = siConfiguredAccounts[0] ?? siEnabledAccounts[0] ?? null; + const siBaseUrl = siSample?.baseUrl?.trim() ? siSample.baseUrl.trim() : ""; rows.push({ provider: "Signal", enabled: siEnabled, @@ -295,7 +297,7 @@ export async function buildProvidersTable( : "setup", detail: siEnabled ? siConfiguredAccounts.length > 0 - ? `configured · accounts ${siConfiguredAccounts.length}/${siEnabledAccounts.length || 1}` + ? `configured${siBaseUrl ? ` · baseUrl ${siBaseUrl}` : ""} · accounts ${siConfiguredAccounts.length}/${siEnabledAccounts.length || 1}` : "default config (no overrides)" : "disabled", }); @@ -307,6 +309,9 @@ export async function buildProvidersTable( ); const imEnabledAccounts = imAccounts.filter((a) => a.enabled); const imConfiguredAccounts = imEnabledAccounts.filter((a) => a.configured); + const imSample = imEnabledAccounts[0] ?? null; + const imCliPath = imSample?.config?.cliPath?.trim() || ""; + const imDbPath = imSample?.config?.dbPath?.trim() || ""; rows.push({ provider: "iMessage", enabled: imEnabled, @@ -317,7 +322,7 @@ export async function buildProvidersTable( : "setup", detail: imEnabled ? imConfiguredAccounts.length > 0 - ? `configured · accounts ${imConfiguredAccounts.length}/${imEnabledAccounts.length || 1}` + ? `configured${imCliPath ? ` · cliPath ${imCliPath}` : ""}${imDbPath ? ` · dbPath ${imDbPath}` : ""} · accounts ${imConfiguredAccounts.length}/${imEnabledAccounts.length || 1}` : "default config (no overrides)" : "disabled", }); diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 2412786023..6ffc724416 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -31,6 +31,7 @@ const mocks = vi.hoisted(() => ({ presence: null, configSnapshot: null, }), + callGateway: vi.fn().mockResolvedValue({}), })); vi.mock("../config/sessions.js", () => ({ @@ -47,6 +48,10 @@ vi.mock("../web/session.js", () => ({ vi.mock("../gateway/probe.js", () => ({ probeGateway: mocks.probeGateway, })); +vi.mock("../gateway/call.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, callGateway: mocks.callGateway }; +}); vi.mock("../gateway/session-utils.js", () => ({ listAgentsForGateway: () => ({ defaultId: "main", @@ -175,4 +180,46 @@ describe("statusCommand", () => { else process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken; } }); + + it("surfaces provider runtime errors from the gateway", async () => { + mocks.probeGateway.mockResolvedValueOnce({ + ok: true, + url: "ws://127.0.0.1:18789", + connectLatencyMs: 10, + error: null, + close: null, + health: {}, + status: {}, + presence: [], + configSnapshot: null, + }); + mocks.callGateway.mockResolvedValueOnce({ + signalAccounts: [ + { + accountId: "default", + enabled: true, + configured: true, + running: false, + lastError: "signal-cli unreachable", + }, + ], + imessageAccounts: [ + { + accountId: "default", + enabled: true, + configured: true, + running: false, + lastError: "imessage permission denied", + }, + ], + }); + + (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.join("\n")).toMatch(/Signal/i); + expect(logs.join("\n")).toMatch(/iMessage/i); + expect(logs.join("\n")).toMatch(/gateway:/i); + expect(logs.join("\n")).toMatch(/WARN/); + }); }); diff --git a/src/commands/status.ts b/src/commands/status.ts index 79a0a046e1..59dbe11e3a 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -19,6 +19,7 @@ import { } from "../config/sessions.js"; import { resolveGatewayService } from "../daemon/service.js"; import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; +import { normalizeControlUiBasePath } from "../gateway/control-ui.js"; import { probeGateway } from "../gateway/probe.js"; import { listAgentsForGateway } from "../gateway/session-utils.js"; import { info } from "../globals.js"; @@ -29,12 +30,15 @@ import { formatUsageReportLines, loadProviderUsageSummary, } from "../infra/provider-usage.js"; +import { collectProvidersStatusIssues } from "../infra/providers-status-issues.js"; import { peekSystemEvents } from "../infra/system-events.js"; +import { getTailnetHostname } from "../infra/tailscale.js"; import { checkUpdateStatus, compareSemverStrings, type UpdateCheckResult, } from "../infra/update-check.js"; +import { runExec } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js"; import { renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; @@ -533,7 +537,7 @@ export async function statusCommand( const scan = await withProgress( { label: "Scanning status…", - total: 7, + total: 9, enabled: opts.json !== true, }, async (progress) => { @@ -542,6 +546,20 @@ export async function statusCommand( const osSummary = resolveOsSummary(); progress.tick(); + progress.setLabel("Checking Tailscale…"); + const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; + const tailscaleDns = + tailscaleMode === "off" + ? null + : await getTailnetHostname((cmd, args) => + runExec(cmd, args, { timeoutMs: 1200, maxBuffer: 200_000 }), + ).catch(() => null); + const tailscaleHttpsUrl = + tailscaleMode !== "off" && tailscaleDns + ? `https://${tailscaleDns}${normalizeControlUiBasePath(cfg.gateway?.controlUi?.basePath)}` + : null; + progress.tick(); + progress.setLabel("Checking for updates…"); const updateTimeoutMs = opts.all ? 6500 : 2500; const update = await getUpdateCheckResult({ @@ -580,6 +598,25 @@ export async function statusCommand( : null; progress.tick(); + progress.setLabel("Querying provider status…"); + const providersStatus = gatewayReachable + ? await callGateway>({ + method: "providers.status", + params: { + probe: false, + timeoutMs: Math.min(8000, opts.timeoutMs ?? 10_000), + }, + timeoutMs: Math.min( + opts.all ? 5000 : 2500, + opts.timeoutMs ?? 10_000, + ), + }).catch(() => null) + : null; + const providerIssues = providersStatus + ? collectProvidersStatusIssues(providersStatus) + : []; + progress.tick(); + progress.setLabel("Summarizing providers…"); const providers = await buildProvidersTable(cfg, { // Show token previews in regular status; keep `status --all` redacted. @@ -598,6 +635,9 @@ export async function statusCommand( return { cfg, osSummary, + tailscaleMode, + tailscaleDns, + tailscaleHttpsUrl, update, gatewayConnection, remoteUrlMissing, @@ -605,6 +645,7 @@ export async function statusCommand( gatewayProbe, gatewayReachable, gatewaySelf, + providerIssues, agentStatus, providers, summary, @@ -615,6 +656,9 @@ export async function statusCommand( const { cfg, osSummary, + tailscaleMode, + tailscaleDns, + tailscaleHttpsUrl, update, gatewayConnection, remoteUrlMissing, @@ -622,6 +666,7 @@ export async function statusCommand( gatewayProbe, gatewayReachable, gatewaySelf, + providerIssues, agentStatus, providers, summary, @@ -769,6 +814,15 @@ export async function statusCommand( 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: "Update", Value: formatUpdateOneLiner(update).replace(/^Update:\s*/i, ""), @@ -801,6 +855,34 @@ export async function statusCommand( runtime.log(""); runtime.log(theme.heading("Providers")); + const providerIssuesByProvider = (() => { + const map = new Map(); + for (const issue of providerIssues) { + const key = issue.provider; + const list = map.get(key); + if (list) list.push(issue); + else map.set(key, [issue]); + } + return map; + })(); + const providerKeyForLabel = (label: string) => { + switch (label) { + case "WhatsApp": + return "whatsapp"; + case "Telegram": + return "telegram"; + case "Discord": + return "discord"; + case "Slack": + return "slack"; + case "Signal": + return "signal"; + case "iMessage": + return "imessage"; + default: + return label.toLowerCase(); + } + }; runtime.log( renderTable({ width: tableWidth, @@ -810,19 +892,29 @@ export async function statusCommand( { key: "State", header: "State", minWidth: 8 }, { key: "Detail", header: "Detail", flex: true, minWidth: 24 }, ], - rows: providers.rows.map((row) => ({ - Provider: row.provider, - Enabled: row.enabled ? ok("ON") : muted("OFF"), - State: - row.state === "ok" - ? ok("OK") - : row.state === "warn" - ? warn("WARN") - : row.state === "off" - ? muted("OFF") - : theme.accentDim("SETUP"), - Detail: row.detail, - })), + rows: providers.rows.map((row) => { + const providerKey = providerKeyForLabel(row.provider); + const issues = providerIssuesByProvider.get(providerKey) ?? []; + 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 { + Provider: row.provider, + 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(), ); diff --git a/src/infra/providers-status-issues.ts b/src/infra/providers-status-issues.ts index c1a060aaba..fdaa331c73 100644 --- a/src/infra/providers-status-issues.ts +++ b/src/infra/providers-status-issues.ts @@ -1,5 +1,11 @@ export type ProviderStatusIssue = { - provider: "discord" | "telegram" | "whatsapp"; + provider: + | "discord" + | "telegram" + | "whatsapp" + | "slack" + | "signal" + | "imessage"; accountId: string; kind: "intent" | "permissions" | "config" | "auth" | "runtime"; message: string; @@ -40,12 +46,37 @@ type WhatsAppAccountStatus = { lastError?: unknown; }; +type RuntimeAccountStatus = { + accountId?: unknown; + enabled?: unknown; + configured?: unknown; + running?: unknown; + lastError?: unknown; +}; + function asString(value: unknown): string | undefined { return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; } +function formatValue(value: unknown): string | undefined { + const s = asString(value); + if (s) return s; + if (value == null) return undefined; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function shorten(message: string, maxLen = 140): string { + const cleaned = message.replace(/\s+/g, " ").trim(); + if (cleaned.length <= maxLen) return cleaned; + return `${cleaned.slice(0, Math.max(0, maxLen - 1))}…`; +} + function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } @@ -191,6 +222,17 @@ function readWhatsAppAccountStatus( }; } +function readRuntimeAccountStatus(value: unknown): RuntimeAccountStatus | null { + if (!isRecord(value)) return null; + return { + accountId: value.accountId, + enabled: value.enabled, + configured: value.configured, + running: value.running, + lastError: value.lastError, + }; +} + export function collectProvidersStatusIssues( payload: Record, ): ProviderStatusIssue[] { @@ -342,5 +384,68 @@ export function collectProvidersStatusIssues( } } + const slackAccountsRaw = payload.slackAccounts; + if (Array.isArray(slackAccountsRaw)) { + for (const entry of slackAccountsRaw) { + const account = readRuntimeAccountStatus(entry); + if (!account) continue; + const accountId = asString(account.accountId) ?? "default"; + const enabled = account.enabled !== false; + const configured = account.configured === true; + if (!enabled || !configured) continue; + const lastError = formatValue(account.lastError); + if (!lastError) continue; + issues.push({ + provider: "slack", + accountId, + kind: "runtime", + message: `Provider error: ${shorten(lastError)}`, + fix: "Check gateway logs (`clawdbot logs --follow`) and re-auth/restart if needed.", + }); + } + } + + const signalAccountsRaw = payload.signalAccounts; + if (Array.isArray(signalAccountsRaw)) { + for (const entry of signalAccountsRaw) { + const account = readRuntimeAccountStatus(entry); + if (!account) continue; + const accountId = asString(account.accountId) ?? "default"; + const enabled = account.enabled !== false; + const configured = account.configured === true; + if (!enabled || !configured) continue; + const lastError = formatValue(account.lastError); + if (!lastError) continue; + issues.push({ + provider: "signal", + accountId, + kind: "runtime", + message: `Provider error: ${shorten(lastError)}`, + fix: "Check gateway logs (`clawdbot logs --follow`) and verify signal CLI/service setup.", + }); + } + } + + const imessageAccountsRaw = payload.imessageAccounts; + if (Array.isArray(imessageAccountsRaw)) { + for (const entry of imessageAccountsRaw) { + const account = readRuntimeAccountStatus(entry); + if (!account) continue; + const accountId = asString(account.accountId) ?? "default"; + const enabled = account.enabled !== false; + const configured = account.configured === true; + if (!enabled || !configured) continue; + const lastError = formatValue(account.lastError); + if (!lastError) continue; + issues.push({ + provider: "imessage", + accountId, + kind: "runtime", + message: `Provider error: ${shorten(lastError)}`, + fix: "Check macOS permissions/TCC and gateway logs (`clawdbot logs --follow`).", + }); + } + } + return issues; } diff --git a/src/infra/tailscale.ts b/src/infra/tailscale.ts index 07e34b82ee..43ebb0e6f8 100644 --- a/src/infra/tailscale.ts +++ b/src/infra/tailscale.ts @@ -12,6 +12,16 @@ import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { colorize, isRich, theme } from "../terminal/theme.js"; import { ensureBinary } from "./binaries.js"; +function parsePossiblyNoisyJsonObject(stdout: string): Record { + const trimmed = stdout.trim(); + const start = trimmed.indexOf("{"); + const end = trimmed.lastIndexOf("}"); + if (start >= 0 && end > start) { + return JSON.parse(trimmed.slice(start, end + 1)) as Record; + } + return JSON.parse(trimmed) as Record; +} + export async function getTailnetHostname(exec: typeof runExec = runExec) { // Derive tailnet hostname (or IP fallback) from tailscale status JSON. const candidates = [ @@ -24,9 +34,7 @@ export async function getTailnetHostname(exec: typeof runExec = runExec) { if (candidate.startsWith("/") && !existsSync(candidate)) continue; try { const { stdout } = await exec(candidate, ["status", "--json"]); - const parsed = stdout - ? (JSON.parse(stdout) as Record) - : {}; + const parsed = stdout ? parsePossiblyNoisyJsonObject(stdout) : {}; const self = typeof parsed.Self === "object" && parsed.Self !== null ? (parsed.Self as Record) @@ -49,6 +57,17 @@ export async function getTailnetHostname(exec: typeof runExec = runExec) { throw lastError ?? new Error("Could not determine Tailscale DNS or IP"); } +export async function readTailscaleStatusJson( + exec: typeof runExec = runExec, + opts?: { timeoutMs?: number }, +): Promise> { + const { stdout } = await exec("tailscale", ["status", "--json"], { + timeoutMs: opts?.timeoutMs ?? 5000, + maxBuffer: 400_000, + }); + return stdout ? parsePossiblyNoisyJsonObject(stdout) : {}; +} + export async function ensureGoInstalled( exec: typeof runExec = runExec, prompt: typeof promptYesNo = promptYesNo, diff --git a/src/terminal/table.test.ts b/src/terminal/table.test.ts index 3bda0e9e00..ee7a9fcc97 100644 --- a/src/terminal/table.test.ts +++ b/src/terminal/table.test.ts @@ -32,4 +32,56 @@ describe("renderTable", () => { const firstLine = out.trimEnd().split("\n")[0] ?? ""; expect(visibleWidth(firstLine)).toBe(width); }); + + it("wraps ANSI-colored cells without corrupting escape sequences", () => { + const out = renderTable({ + width: 36, + columns: [ + { key: "K", header: "K", minWidth: 3 }, + { key: "V", header: "V", flex: true, minWidth: 10 }, + ], + rows: [ + { + K: "X", + V: `\x1b[33m${"a".repeat(120)}\x1b[0m`, + }, + ], + }); + + const ESC = "\u001b"; + for (let i = 0; i < out.length; i += 1) { + if (out[i] !== ESC) continue; + + // SGR: ESC [ ... m + if (out[i + 1] === "[") { + let j = i + 2; + while (j < out.length) { + const ch = out[j]; + if (ch === "m") break; + if (ch && ch >= "0" && ch <= "9") { + j += 1; + continue; + } + if (ch === ";") { + j += 1; + continue; + } + break; + } + expect(out[j]).toBe("m"); + i = j; + continue; + } + + // OSC-8: ESC ] 8 ; ; ... ST (ST = ESC \) + if (out[i + 1] === "]" && out.slice(i + 2, i + 5) === "8;;") { + const st = out.indexOf(`${ESC}\\`, i + 5); + expect(st).toBeGreaterThanOrEqual(0); + i = st + 1; + continue; + } + + throw new Error(`Unexpected escape sequence at index ${i}`); + } + }); }); diff --git a/src/terminal/table.ts b/src/terminal/table.ts index 83b9ba0ec9..16072667b5 100644 --- a/src/terminal/table.ts +++ b/src/terminal/table.ts @@ -39,74 +39,130 @@ function padCell(text: string, width: number, align: Align): string { function wrapLine(text: string, width: number): string[] { if (width <= 0) return [text]; - const words = text.split(/(\s+)/).filter(Boolean); - const lines: string[] = []; - let current = ""; - let currentWidth = 0; - const push = (value: string) => lines.push(value.replace(/\s+$/, "")); - const flush = () => { - if (current.trim().length === 0) return; - push(current); - current = ""; - currentWidth = 0; - }; + // ANSI-aware wrapping: never split inside ANSI SGR/OSC-8 sequences. + // We don't attempt to re-open styling per line; terminals keep SGR state + // across newlines, so as long as we don't corrupt escape sequences we're safe. + const ESC = "\u001b"; - const breakLong = (word: string) => { - const parts: string[] = []; - let buf = ""; - let lastBreakAt = 0; - const isBreakChar = (ch: string) => - ch === "/" || ch === "-" || ch === "_" || ch === "."; - for (const ch of Array.from(word)) { - const next = buf + ch; - if (visibleWidth(next) > width && buf) { - if (lastBreakAt > 0) { - parts.push(buf.slice(0, lastBreakAt)); - buf = `${buf.slice(lastBreakAt)}${ch}`; - lastBreakAt = 0; - for (let i = 0; i < buf.length; i += 1) { - const c = buf[i]; - if (c && isBreakChar(c)) lastBreakAt = i + 1; + type Token = { kind: "ansi" | "char"; value: string }; + const tokens: Token[] = []; + for (let i = 0; i < text.length; ) { + if (text[i] === ESC) { + // SGR: ESC [ ... m + if (text[i + 1] === "[") { + let j = i + 2; + while (j < text.length) { + const ch = text[j]; + if (ch === "m") break; + if (ch && ch >= "0" && ch <= "9") { + j += 1; + continue; } - } else { - parts.push(buf); - buf = ch; + if (ch === ";") { + j += 1; + continue; + } + break; + } + if (text[j] === "m") { + tokens.push({ kind: "ansi", value: text.slice(i, j + 1) }); + i = j + 1; + continue; + } + } + + // OSC-8 link open/close: ESC ] 8 ; ; ... ST (ST = ESC \) + if (text[i + 1] === "]" && text.slice(i + 2, i + 5) === "8;;") { + const st = text.indexOf(`${ESC}\\`, i + 5); + if (st >= 0) { + tokens.push({ kind: "ansi", value: text.slice(i, st + 2) }); + i = st + 2; + continue; } - } else { - buf = next; - if (isBreakChar(ch)) lastBreakAt = buf.length; } } - if (buf) parts.push(buf); - return parts; + + const cp = text.codePointAt(i); + if (!cp) break; + const ch = String.fromCodePoint(cp); + tokens.push({ kind: "char", value: ch }); + i += ch.length; + } + + const lines: string[] = []; + const isBreakChar = (ch: string) => + ch === " " || + ch === "\t" || + ch === "\n" || + ch === "\r" || + ch === "/" || + ch === "-" || + ch === "_" || + ch === "."; + const isSpaceChar = (ch: string) => ch === " " || ch === "\t"; + + const buf: Token[] = []; + let bufVisible = 0; + let lastBreakIndex: number | null = null; + + const bufToString = (slice?: Token[]) => + (slice ?? buf).map((t) => t.value).join(""); + + const bufVisibleWidth = (slice: Token[]) => + slice.reduce((acc, t) => acc + (t.kind === "char" ? 1 : 0), 0); + + const pushLine = (value: string) => { + const cleaned = value.replace(/\s+$/, ""); + if (cleaned.trim().length === 0) return; + lines.push(cleaned); }; - for (const token of words) { - const tokenWidth = visibleWidth(token); - const isSpace = /^\s+$/.test(token); + const flushAt = (breakAt: number | null) => { + if (buf.length === 0) return; + if (breakAt == null || breakAt <= 0) { + pushLine(bufToString()); + buf.length = 0; + bufVisible = 0; + lastBreakIndex = null; + return; + } - if (tokenWidth > width && !isSpace) { - flush(); - for (const part of breakLong(token.replace(/^\s+/, ""))) { - push(part); - } + const left = buf.slice(0, breakAt); + const rest = buf.slice(breakAt); + pushLine(bufToString(left)); + + while ( + rest.length > 0 && + rest[0]?.kind === "char" && + isSpaceChar(rest[0].value) + ) { + rest.shift(); + } + + buf.length = 0; + buf.push(...rest); + bufVisible = bufVisibleWidth(buf); + lastBreakIndex = null; + }; + + for (const token of tokens) { + if (token.kind === "ansi") { + buf.push(token); continue; } - if ( - currentWidth + tokenWidth > width && - current.trim().length > 0 && - !isSpace - ) { - flush(); + const ch = token.value; + if (bufVisible + 1 > width && bufVisible > 0) { + flushAt(lastBreakIndex); } - current += token; - currentWidth = visibleWidth(current); + buf.push(token); + bufVisible += 1; + if (isBreakChar(ch)) lastBreakIndex = buf.length; } - flush(); + flushAt(buf.length); return lines.length ? lines : [""]; }