fix(status): improve diagnostics and output
This commit is contained in:
@@ -70,7 +70,7 @@
|
|||||||
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
||||||
- Never update the Carbon dependency.
|
- Never update the Carbon dependency.
|
||||||
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
|
- 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.**
|
- 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`.
|
- 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.
|
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# 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
|
## 2026.1.11-4
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ Manage chat provider accounts (WhatsApp/Telegram/Discord/Slack/Signal/iMessage).
|
|||||||
|
|
||||||
Subcommands:
|
Subcommands:
|
||||||
- `providers list`: show configured chat providers and auth profiles (Claude Code + Codex CLI OAuth sync included).
|
- `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`).
|
- 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 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.
|
- `providers remove`: disable by default; pass `--delete` to remove config entries without prompts.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Short guide to verify the WhatsApp Web / Baileys stack without guessing.
|
|||||||
## Quick checks
|
## Quick checks
|
||||||
- `clawdbot status` — local summary: gateway reachability/mode, update hint, creds/auth age, sessions + recent activity.
|
- `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 --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).
|
- `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.
|
- 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`.
|
- Logs: tail `/tmp/clawdbot/clawdbot-*.log` and filter for `web-heartbeat`, `web-reconnect`, `web-auto-reply`, `web-inbound`.
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ Clawdbot extracts these and sends them as media alongside the text.
|
|||||||
```bash
|
```bash
|
||||||
clawdbot status # local status (creds, sessions, queued events)
|
clawdbot status # local status (creds, sessions, queued events)
|
||||||
clawdbot status --all # full diagnosis (read-only, pasteable)
|
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)
|
clawdbot health --json # gateway health snapshot (WS)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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.
|
Tip: `clawdbot status --all` is the best pasteable, read-only debug report.
|
||||||
Gateway snapshot: `clawdbot providers status` shows what the gateway reports (use `status --deep` for local-only probes).
|
Health probes: `clawdbot health` (or `clawdbot status --deep`) asks the running gateway for a health snapshot.
|
||||||
|
|
||||||
## Next steps (optional, but great)
|
## Next steps (optional, but great)
|
||||||
|
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
|
|||||||
|
|
||||||
7) **Health check**
|
7) **Health check**
|
||||||
- Starts the Gateway (if needed) and runs `clawdbot health`.
|
- 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)**
|
8) **Skills (recommended)**
|
||||||
- Reads the available skills and checks requirements.
|
- Reads the available skills and checks requirements.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "clawdbot",
|
"name": "clawdbot",
|
||||||
"version": "2026.1.11-4",
|
"version": "2026.1.11-5",
|
||||||
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
browserControlUrl?: string;
|
browserControlUrl?: string;
|
||||||
browserNoVncUrl?: string;
|
browserNoVncUrl?: string;
|
||||||
hostBrowserAllowed?: boolean;
|
hostBrowserAllowed?: boolean;
|
||||||
|
allowedControlUrls?: string[];
|
||||||
|
allowedControlHosts?: string[];
|
||||||
|
allowedControlPorts?: number[];
|
||||||
elevated?: {
|
elevated?: {
|
||||||
allowed: boolean;
|
allowed: boolean;
|
||||||
defaultLevel: "on" | "off";
|
defaultLevel: "on" | "off";
|
||||||
|
|||||||
@@ -431,4 +431,38 @@ describe("providers command", () => {
|
|||||||
});
|
});
|
||||||
expect(disconnected.join("\n")).toMatch(/disconnected/i);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
|
|||||||
import { resolveGatewayLogPaths } from "../daemon/launchd.js";
|
import { resolveGatewayLogPaths } from "../daemon/launchd.js";
|
||||||
import { resolveGatewayService } from "../daemon/service.js";
|
import { resolveGatewayService } from "../daemon/service.js";
|
||||||
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
|
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
|
||||||
|
import { normalizeControlUiBasePath } from "../gateway/control-ui.js";
|
||||||
import { probeGateway } from "../gateway/probe.js";
|
import { probeGateway } from "../gateway/probe.js";
|
||||||
import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js";
|
import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js";
|
||||||
import { resolveOsSummary } from "../infra/os-summary.js";
|
import { resolveOsSummary } from "../infra/os-summary.js";
|
||||||
@@ -18,10 +19,12 @@ import {
|
|||||||
readRestartSentinel,
|
readRestartSentinel,
|
||||||
summarizeRestartSentinel,
|
summarizeRestartSentinel,
|
||||||
} from "../infra/restart-sentinel.js";
|
} from "../infra/restart-sentinel.js";
|
||||||
|
import { readTailscaleStatusJson } from "../infra/tailscale.js";
|
||||||
import {
|
import {
|
||||||
checkUpdateStatus,
|
checkUpdateStatus,
|
||||||
compareSemverStrings,
|
compareSemverStrings,
|
||||||
} from "../infra/update-check.js";
|
} from "../infra/update-check.js";
|
||||||
|
import { runExec } from "../process/exec.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { renderTable } from "../terminal/table.js";
|
import { renderTable } from "../terminal/table.js";
|
||||||
import { isRich, theme } from "../terminal/theme.js";
|
import { isRich, theme } from "../terminal/theme.js";
|
||||||
@@ -46,12 +49,54 @@ export async function statusAllCommand(
|
|||||||
opts?: { timeoutMs?: number },
|
opts?: { timeoutMs?: number },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await withProgress(
|
await withProgress(
|
||||||
{ label: "Scanning status --all…", indeterminate: true },
|
{ label: "Scanning status --all…", total: 11 },
|
||||||
async (progress) => {
|
async (progress) => {
|
||||||
progress.setLabel("Loading config…");
|
progress.setLabel("Loading config…");
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const osSummary = resolveOsSummary();
|
const osSummary = resolveOsSummary();
|
||||||
const snap = await readConfigFileSnapshot().catch(() => null);
|
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<string, unknown>)
|
||||||
|
: 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…");
|
progress.setLabel("Checking for updates…");
|
||||||
const root = await resolveClawdbotPackageRoot({
|
const root = await resolveClawdbotPackageRoot({
|
||||||
@@ -65,6 +110,7 @@ export async function statusAllCommand(
|
|||||||
fetchGit: true,
|
fetchGit: true,
|
||||||
includeRegistry: true,
|
includeRegistry: true,
|
||||||
});
|
});
|
||||||
|
progress.tick();
|
||||||
|
|
||||||
progress.setLabel("Probing gateway…");
|
progress.setLabel("Probing gateway…");
|
||||||
const connection = buildGatewayConnectionDetails({ config: cfg });
|
const connection = buildGatewayConnectionDetails({ config: cfg });
|
||||||
@@ -113,6 +159,7 @@ export async function statusAllCommand(
|
|||||||
const gatewaySelf = pickGatewaySelfPresence(
|
const gatewaySelf = pickGatewaySelfPresence(
|
||||||
gatewayProbe?.presence ?? null,
|
gatewayProbe?.presence ?? null,
|
||||||
);
|
);
|
||||||
|
progress.tick();
|
||||||
|
|
||||||
progress.setLabel("Checking daemon…");
|
progress.setLabel("Checking daemon…");
|
||||||
const daemon = await (async () => {
|
const daemon = await (async () => {
|
||||||
@@ -137,11 +184,14 @@ export async function statusAllCommand(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
progress.tick();
|
||||||
|
|
||||||
progress.setLabel("Scanning agents…");
|
progress.setLabel("Scanning agents…");
|
||||||
const agentStatus = await getAgentLocalStatuses(cfg);
|
const agentStatus = await getAgentLocalStatuses(cfg);
|
||||||
|
progress.tick();
|
||||||
progress.setLabel("Summarizing providers…");
|
progress.setLabel("Summarizing providers…");
|
||||||
const providers = await buildProvidersTable(cfg, { showSecrets: false });
|
const providers = await buildProvidersTable(cfg, { showSecrets: false });
|
||||||
|
progress.tick();
|
||||||
|
|
||||||
const connectionDetailsForReport = (() => {
|
const connectionDetailsForReport = (() => {
|
||||||
if (!remoteUrlMissing) return connection.message;
|
if (!remoteUrlMissing) return connection.message;
|
||||||
@@ -187,6 +237,7 @@ export async function statusAllCommand(
|
|||||||
const providerIssues = providersStatus
|
const providerIssues = providersStatus
|
||||||
? collectProvidersStatusIssues(providersStatus)
|
? collectProvidersStatusIssues(providersStatus)
|
||||||
: [];
|
: [];
|
||||||
|
progress.tick();
|
||||||
|
|
||||||
progress.setLabel("Checking local state…");
|
progress.setLabel("Checking local state…");
|
||||||
const sentinel = await readRestartSentinel().catch(() => null);
|
const sentinel = await readRestartSentinel().catch(() => null);
|
||||||
@@ -195,6 +246,7 @@ export async function statusAllCommand(
|
|||||||
);
|
);
|
||||||
const port = resolveGatewayPort(cfg);
|
const port = resolveGatewayPort(cfg);
|
||||||
const portUsage = await inspectPortUsage(port).catch(() => null);
|
const portUsage = await inspectPortUsage(port).catch(() => null);
|
||||||
|
progress.tick();
|
||||||
|
|
||||||
const defaultWorkspace =
|
const defaultWorkspace =
|
||||||
agentStatus.agents.find((a) => a.id === agentStatus.defaultId)
|
agentStatus.agents.find((a) => a.id === agentStatus.defaultId)
|
||||||
@@ -322,6 +374,15 @@ export async function statusAllCommand(
|
|||||||
dashboard
|
dashboard
|
||||||
? { Item: "Dashboard", Value: dashboard }
|
? { Item: "Dashboard", Value: dashboard }
|
||||||
: { Item: "Dashboard", Value: "disabled" },
|
: { 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: "Update", Value: updateLine },
|
||||||
{
|
{
|
||||||
Item: "Gateway",
|
Item: "Gateway",
|
||||||
@@ -376,6 +437,46 @@ export async function statusAllCommand(
|
|||||||
: theme.accentDim("SETUP"),
|
: theme.accentDim("SETUP"),
|
||||||
Detail: row.detail,
|
Detail: row.detail,
|
||||||
}));
|
}));
|
||||||
|
const providerIssuesByProvider = (() => {
|
||||||
|
const map = new Map<string, typeof providerIssues>();
|
||||||
|
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({
|
const providersTable = renderTable({
|
||||||
width: tableWidth,
|
width: tableWidth,
|
||||||
@@ -385,7 +486,7 @@ export async function statusAllCommand(
|
|||||||
{ key: "State", header: "State", minWidth: 8 },
|
{ key: "State", header: "State", minWidth: 8 },
|
||||||
{ key: "Detail", header: "Detail", flex: true, minWidth: 28 },
|
{ key: "Detail", header: "Detail", flex: true, minWidth: 28 },
|
||||||
],
|
],
|
||||||
rows: providerRows,
|
rows: providerRowsWithIssues,
|
||||||
});
|
});
|
||||||
|
|
||||||
const agentRows = agentStatus.agents.map((a) => ({
|
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) {
|
if (skillStatus) {
|
||||||
const eligible = skillStatus.skills.filter((s) => s.eligible).length;
|
const eligible = skillStatus.skills.filter((s) => s.eligible).length;
|
||||||
const missing = skillStatus.skills.filter(
|
const missing = skillStatus.skills.filter(
|
||||||
@@ -543,6 +669,7 @@ export async function statusAllCommand(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
progress.setLabel("Reading logs…");
|
||||||
const logPaths = (() => {
|
const logPaths = (() => {
|
||||||
try {
|
try {
|
||||||
return resolveGatewayLogPaths(process.env);
|
return resolveGatewayLogPaths(process.env);
|
||||||
@@ -575,6 +702,7 @@ export async function statusAllCommand(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
progress.tick();
|
||||||
|
|
||||||
if (providersStatus) {
|
if (providersStatus) {
|
||||||
emitCheck(
|
emitCheck(
|
||||||
@@ -623,6 +751,7 @@ export async function statusAllCommand(
|
|||||||
|
|
||||||
progress.setLabel("Rendering…");
|
progress.setLabel("Rendering…");
|
||||||
runtime.log(lines.join("\n"));
|
runtime.log(lines.join("\n"));
|
||||||
|
progress.tick();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -285,6 +285,8 @@ export async function buildProvidersTable(
|
|||||||
);
|
);
|
||||||
const siEnabledAccounts = siAccounts.filter((a) => a.enabled);
|
const siEnabledAccounts = siAccounts.filter((a) => a.enabled);
|
||||||
const siConfiguredAccounts = siEnabledAccounts.filter((a) => a.configured);
|
const siConfiguredAccounts = siEnabledAccounts.filter((a) => a.configured);
|
||||||
|
const siSample = siConfiguredAccounts[0] ?? siEnabledAccounts[0] ?? null;
|
||||||
|
const siBaseUrl = siSample?.baseUrl?.trim() ? siSample.baseUrl.trim() : "";
|
||||||
rows.push({
|
rows.push({
|
||||||
provider: "Signal",
|
provider: "Signal",
|
||||||
enabled: siEnabled,
|
enabled: siEnabled,
|
||||||
@@ -295,7 +297,7 @@ export async function buildProvidersTable(
|
|||||||
: "setup",
|
: "setup",
|
||||||
detail: siEnabled
|
detail: siEnabled
|
||||||
? siConfiguredAccounts.length > 0
|
? siConfiguredAccounts.length > 0
|
||||||
? `configured · accounts ${siConfiguredAccounts.length}/${siEnabledAccounts.length || 1}`
|
? `configured${siBaseUrl ? ` · baseUrl ${siBaseUrl}` : ""} · accounts ${siConfiguredAccounts.length}/${siEnabledAccounts.length || 1}`
|
||||||
: "default config (no overrides)"
|
: "default config (no overrides)"
|
||||||
: "disabled",
|
: "disabled",
|
||||||
});
|
});
|
||||||
@@ -307,6 +309,9 @@ export async function buildProvidersTable(
|
|||||||
);
|
);
|
||||||
const imEnabledAccounts = imAccounts.filter((a) => a.enabled);
|
const imEnabledAccounts = imAccounts.filter((a) => a.enabled);
|
||||||
const imConfiguredAccounts = imEnabledAccounts.filter((a) => a.configured);
|
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({
|
rows.push({
|
||||||
provider: "iMessage",
|
provider: "iMessage",
|
||||||
enabled: imEnabled,
|
enabled: imEnabled,
|
||||||
@@ -317,7 +322,7 @@ export async function buildProvidersTable(
|
|||||||
: "setup",
|
: "setup",
|
||||||
detail: imEnabled
|
detail: imEnabled
|
||||||
? imConfiguredAccounts.length > 0
|
? 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)"
|
: "default config (no overrides)"
|
||||||
: "disabled",
|
: "disabled",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
presence: null,
|
presence: null,
|
||||||
configSnapshot: null,
|
configSnapshot: null,
|
||||||
}),
|
}),
|
||||||
|
callGateway: vi.fn().mockResolvedValue({}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../config/sessions.js", () => ({
|
vi.mock("../config/sessions.js", () => ({
|
||||||
@@ -47,6 +48,10 @@ vi.mock("../web/session.js", () => ({
|
|||||||
vi.mock("../gateway/probe.js", () => ({
|
vi.mock("../gateway/probe.js", () => ({
|
||||||
probeGateway: mocks.probeGateway,
|
probeGateway: mocks.probeGateway,
|
||||||
}));
|
}));
|
||||||
|
vi.mock("../gateway/call.js", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../gateway/call.js")>();
|
||||||
|
return { ...actual, callGateway: mocks.callGateway };
|
||||||
|
});
|
||||||
vi.mock("../gateway/session-utils.js", () => ({
|
vi.mock("../gateway/session-utils.js", () => ({
|
||||||
listAgentsForGateway: () => ({
|
listAgentsForGateway: () => ({
|
||||||
defaultId: "main",
|
defaultId: "main",
|
||||||
@@ -175,4 +180,46 @@ describe("statusCommand", () => {
|
|||||||
else process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
|
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/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
import { resolveGatewayService } from "../daemon/service.js";
|
import { resolveGatewayService } from "../daemon/service.js";
|
||||||
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
|
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
|
||||||
|
import { normalizeControlUiBasePath } from "../gateway/control-ui.js";
|
||||||
import { probeGateway } from "../gateway/probe.js";
|
import { probeGateway } from "../gateway/probe.js";
|
||||||
import { listAgentsForGateway } from "../gateway/session-utils.js";
|
import { listAgentsForGateway } from "../gateway/session-utils.js";
|
||||||
import { info } from "../globals.js";
|
import { info } from "../globals.js";
|
||||||
@@ -29,12 +30,15 @@ import {
|
|||||||
formatUsageReportLines,
|
formatUsageReportLines,
|
||||||
loadProviderUsageSummary,
|
loadProviderUsageSummary,
|
||||||
} from "../infra/provider-usage.js";
|
} from "../infra/provider-usage.js";
|
||||||
|
import { collectProvidersStatusIssues } from "../infra/providers-status-issues.js";
|
||||||
import { peekSystemEvents } from "../infra/system-events.js";
|
import { peekSystemEvents } from "../infra/system-events.js";
|
||||||
|
import { getTailnetHostname } from "../infra/tailscale.js";
|
||||||
import {
|
import {
|
||||||
checkUpdateStatus,
|
checkUpdateStatus,
|
||||||
compareSemverStrings,
|
compareSemverStrings,
|
||||||
type UpdateCheckResult,
|
type UpdateCheckResult,
|
||||||
} from "../infra/update-check.js";
|
} from "../infra/update-check.js";
|
||||||
|
import { runExec } from "../process/exec.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { renderTable } from "../terminal/table.js";
|
import { renderTable } from "../terminal/table.js";
|
||||||
import { theme } from "../terminal/theme.js";
|
import { theme } from "../terminal/theme.js";
|
||||||
@@ -533,7 +537,7 @@ export async function statusCommand(
|
|||||||
const scan = await withProgress(
|
const scan = await withProgress(
|
||||||
{
|
{
|
||||||
label: "Scanning status…",
|
label: "Scanning status…",
|
||||||
total: 7,
|
total: 9,
|
||||||
enabled: opts.json !== true,
|
enabled: opts.json !== true,
|
||||||
},
|
},
|
||||||
async (progress) => {
|
async (progress) => {
|
||||||
@@ -542,6 +546,20 @@ export async function statusCommand(
|
|||||||
const osSummary = resolveOsSummary();
|
const osSummary = resolveOsSummary();
|
||||||
progress.tick();
|
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…");
|
progress.setLabel("Checking for updates…");
|
||||||
const updateTimeoutMs = opts.all ? 6500 : 2500;
|
const updateTimeoutMs = opts.all ? 6500 : 2500;
|
||||||
const update = await getUpdateCheckResult({
|
const update = await getUpdateCheckResult({
|
||||||
@@ -580,6 +598,25 @@ export async function statusCommand(
|
|||||||
: null;
|
: null;
|
||||||
progress.tick();
|
progress.tick();
|
||||||
|
|
||||||
|
progress.setLabel("Querying provider status…");
|
||||||
|
const providersStatus = gatewayReachable
|
||||||
|
? await callGateway<Record<string, unknown>>({
|
||||||
|
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…");
|
progress.setLabel("Summarizing providers…");
|
||||||
const providers = await buildProvidersTable(cfg, {
|
const providers = await buildProvidersTable(cfg, {
|
||||||
// Show token previews in regular status; keep `status --all` redacted.
|
// Show token previews in regular status; keep `status --all` redacted.
|
||||||
@@ -598,6 +635,9 @@ export async function statusCommand(
|
|||||||
return {
|
return {
|
||||||
cfg,
|
cfg,
|
||||||
osSummary,
|
osSummary,
|
||||||
|
tailscaleMode,
|
||||||
|
tailscaleDns,
|
||||||
|
tailscaleHttpsUrl,
|
||||||
update,
|
update,
|
||||||
gatewayConnection,
|
gatewayConnection,
|
||||||
remoteUrlMissing,
|
remoteUrlMissing,
|
||||||
@@ -605,6 +645,7 @@ export async function statusCommand(
|
|||||||
gatewayProbe,
|
gatewayProbe,
|
||||||
gatewayReachable,
|
gatewayReachable,
|
||||||
gatewaySelf,
|
gatewaySelf,
|
||||||
|
providerIssues,
|
||||||
agentStatus,
|
agentStatus,
|
||||||
providers,
|
providers,
|
||||||
summary,
|
summary,
|
||||||
@@ -615,6 +656,9 @@ export async function statusCommand(
|
|||||||
const {
|
const {
|
||||||
cfg,
|
cfg,
|
||||||
osSummary,
|
osSummary,
|
||||||
|
tailscaleMode,
|
||||||
|
tailscaleDns,
|
||||||
|
tailscaleHttpsUrl,
|
||||||
update,
|
update,
|
||||||
gatewayConnection,
|
gatewayConnection,
|
||||||
remoteUrlMissing,
|
remoteUrlMissing,
|
||||||
@@ -622,6 +666,7 @@ export async function statusCommand(
|
|||||||
gatewayProbe,
|
gatewayProbe,
|
||||||
gatewayReachable,
|
gatewayReachable,
|
||||||
gatewaySelf,
|
gatewaySelf,
|
||||||
|
providerIssues,
|
||||||
agentStatus,
|
agentStatus,
|
||||||
providers,
|
providers,
|
||||||
summary,
|
summary,
|
||||||
@@ -769,6 +814,15 @@ export async function statusCommand(
|
|||||||
const overviewRows = [
|
const overviewRows = [
|
||||||
{ Item: "Dashboard", Value: dashboard },
|
{ Item: "Dashboard", Value: dashboard },
|
||||||
{ Item: "OS", Value: `${osSummary.label} · node ${process.versions.node}` },
|
{ 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",
|
Item: "Update",
|
||||||
Value: formatUpdateOneLiner(update).replace(/^Update:\s*/i, ""),
|
Value: formatUpdateOneLiner(update).replace(/^Update:\s*/i, ""),
|
||||||
@@ -801,6 +855,34 @@ export async function statusCommand(
|
|||||||
|
|
||||||
runtime.log("");
|
runtime.log("");
|
||||||
runtime.log(theme.heading("Providers"));
|
runtime.log(theme.heading("Providers"));
|
||||||
|
const providerIssuesByProvider = (() => {
|
||||||
|
const map = new Map<string, typeof providerIssues>();
|
||||||
|
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(
|
runtime.log(
|
||||||
renderTable({
|
renderTable({
|
||||||
width: tableWidth,
|
width: tableWidth,
|
||||||
@@ -810,19 +892,29 @@ export async function statusCommand(
|
|||||||
{ key: "State", header: "State", minWidth: 8 },
|
{ key: "State", header: "State", minWidth: 8 },
|
||||||
{ key: "Detail", header: "Detail", flex: true, minWidth: 24 },
|
{ key: "Detail", header: "Detail", flex: true, minWidth: 24 },
|
||||||
],
|
],
|
||||||
rows: providers.rows.map((row) => ({
|
rows: providers.rows.map((row) => {
|
||||||
Provider: row.provider,
|
const providerKey = providerKeyForLabel(row.provider);
|
||||||
Enabled: row.enabled ? ok("ON") : muted("OFF"),
|
const issues = providerIssuesByProvider.get(providerKey) ?? [];
|
||||||
State:
|
const effectiveState =
|
||||||
row.state === "ok"
|
row.state === "off" ? "off" : issues.length > 0 ? "warn" : row.state;
|
||||||
? ok("OK")
|
const issueSuffix =
|
||||||
: row.state === "warn"
|
issues.length > 0
|
||||||
? warn("WARN")
|
? ` · ${warn(`gateway: ${shortenText(issues[0]?.message ?? "issue", 84)}`)}`
|
||||||
: row.state === "off"
|
: "";
|
||||||
? muted("OFF")
|
return {
|
||||||
: theme.accentDim("SETUP"),
|
Provider: row.provider,
|
||||||
Detail: row.detail,
|
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(),
|
}).trimEnd(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
export type ProviderStatusIssue = {
|
export type ProviderStatusIssue = {
|
||||||
provider: "discord" | "telegram" | "whatsapp";
|
provider:
|
||||||
|
| "discord"
|
||||||
|
| "telegram"
|
||||||
|
| "whatsapp"
|
||||||
|
| "slack"
|
||||||
|
| "signal"
|
||||||
|
| "imessage";
|
||||||
accountId: string;
|
accountId: string;
|
||||||
kind: "intent" | "permissions" | "config" | "auth" | "runtime";
|
kind: "intent" | "permissions" | "config" | "auth" | "runtime";
|
||||||
message: string;
|
message: string;
|
||||||
@@ -40,12 +46,37 @@ type WhatsAppAccountStatus = {
|
|||||||
lastError?: unknown;
|
lastError?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RuntimeAccountStatus = {
|
||||||
|
accountId?: unknown;
|
||||||
|
enabled?: unknown;
|
||||||
|
configured?: unknown;
|
||||||
|
running?: unknown;
|
||||||
|
lastError?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
function asString(value: unknown): string | undefined {
|
function asString(value: unknown): string | undefined {
|
||||||
return typeof value === "string" && value.trim().length > 0
|
return typeof value === "string" && value.trim().length > 0
|
||||||
? value.trim()
|
? value.trim()
|
||||||
: undefined;
|
: 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<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
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(
|
export function collectProvidersStatusIssues(
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
): ProviderStatusIssue[] {
|
): 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;
|
return issues;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
|||||||
import { colorize, isRich, theme } from "../terminal/theme.js";
|
import { colorize, isRich, theme } from "../terminal/theme.js";
|
||||||
import { ensureBinary } from "./binaries.js";
|
import { ensureBinary } from "./binaries.js";
|
||||||
|
|
||||||
|
function parsePossiblyNoisyJsonObject(stdout: string): Record<string, unknown> {
|
||||||
|
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<string, unknown>;
|
||||||
|
}
|
||||||
|
return JSON.parse(trimmed) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getTailnetHostname(exec: typeof runExec = runExec) {
|
export async function getTailnetHostname(exec: typeof runExec = runExec) {
|
||||||
// Derive tailnet hostname (or IP fallback) from tailscale status JSON.
|
// Derive tailnet hostname (or IP fallback) from tailscale status JSON.
|
||||||
const candidates = [
|
const candidates = [
|
||||||
@@ -24,9 +34,7 @@ export async function getTailnetHostname(exec: typeof runExec = runExec) {
|
|||||||
if (candidate.startsWith("/") && !existsSync(candidate)) continue;
|
if (candidate.startsWith("/") && !existsSync(candidate)) continue;
|
||||||
try {
|
try {
|
||||||
const { stdout } = await exec(candidate, ["status", "--json"]);
|
const { stdout } = await exec(candidate, ["status", "--json"]);
|
||||||
const parsed = stdout
|
const parsed = stdout ? parsePossiblyNoisyJsonObject(stdout) : {};
|
||||||
? (JSON.parse(stdout) as Record<string, unknown>)
|
|
||||||
: {};
|
|
||||||
const self =
|
const self =
|
||||||
typeof parsed.Self === "object" && parsed.Self !== null
|
typeof parsed.Self === "object" && parsed.Self !== null
|
||||||
? (parsed.Self as Record<string, unknown>)
|
? (parsed.Self as Record<string, unknown>)
|
||||||
@@ -49,6 +57,17 @@ export async function getTailnetHostname(exec: typeof runExec = runExec) {
|
|||||||
throw lastError ?? new Error("Could not determine Tailscale DNS or IP");
|
throw lastError ?? new Error("Could not determine Tailscale DNS or IP");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function readTailscaleStatusJson(
|
||||||
|
exec: typeof runExec = runExec,
|
||||||
|
opts?: { timeoutMs?: number },
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const { stdout } = await exec("tailscale", ["status", "--json"], {
|
||||||
|
timeoutMs: opts?.timeoutMs ?? 5000,
|
||||||
|
maxBuffer: 400_000,
|
||||||
|
});
|
||||||
|
return stdout ? parsePossiblyNoisyJsonObject(stdout) : {};
|
||||||
|
}
|
||||||
|
|
||||||
export async function ensureGoInstalled(
|
export async function ensureGoInstalled(
|
||||||
exec: typeof runExec = runExec,
|
exec: typeof runExec = runExec,
|
||||||
prompt: typeof promptYesNo = promptYesNo,
|
prompt: typeof promptYesNo = promptYesNo,
|
||||||
|
|||||||
@@ -32,4 +32,56 @@ describe("renderTable", () => {
|
|||||||
const firstLine = out.trimEnd().split("\n")[0] ?? "";
|
const firstLine = out.trimEnd().split("\n")[0] ?? "";
|
||||||
expect(visibleWidth(firstLine)).toBe(width);
|
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}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,74 +39,130 @@ function padCell(text: string, width: number, align: Align): string {
|
|||||||
|
|
||||||
function wrapLine(text: string, width: number): string[] {
|
function wrapLine(text: string, width: number): string[] {
|
||||||
if (width <= 0) return [text];
|
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 = () => {
|
// ANSI-aware wrapping: never split inside ANSI SGR/OSC-8 sequences.
|
||||||
if (current.trim().length === 0) return;
|
// We don't attempt to re-open styling per line; terminals keep SGR state
|
||||||
push(current);
|
// across newlines, so as long as we don't corrupt escape sequences we're safe.
|
||||||
current = "";
|
const ESC = "\u001b";
|
||||||
currentWidth = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const breakLong = (word: string) => {
|
type Token = { kind: "ansi" | "char"; value: string };
|
||||||
const parts: string[] = [];
|
const tokens: Token[] = [];
|
||||||
let buf = "";
|
for (let i = 0; i < text.length; ) {
|
||||||
let lastBreakAt = 0;
|
if (text[i] === ESC) {
|
||||||
const isBreakChar = (ch: string) =>
|
// SGR: ESC [ ... m
|
||||||
ch === "/" || ch === "-" || ch === "_" || ch === ".";
|
if (text[i + 1] === "[") {
|
||||||
for (const ch of Array.from(word)) {
|
let j = i + 2;
|
||||||
const next = buf + ch;
|
while (j < text.length) {
|
||||||
if (visibleWidth(next) > width && buf) {
|
const ch = text[j];
|
||||||
if (lastBreakAt > 0) {
|
if (ch === "m") break;
|
||||||
parts.push(buf.slice(0, lastBreakAt));
|
if (ch && ch >= "0" && ch <= "9") {
|
||||||
buf = `${buf.slice(lastBreakAt)}${ch}`;
|
j += 1;
|
||||||
lastBreakAt = 0;
|
continue;
|
||||||
for (let i = 0; i < buf.length; i += 1) {
|
|
||||||
const c = buf[i];
|
|
||||||
if (c && isBreakChar(c)) lastBreakAt = i + 1;
|
|
||||||
}
|
}
|
||||||
} else {
|
if (ch === ";") {
|
||||||
parts.push(buf);
|
j += 1;
|
||||||
buf = ch;
|
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 flushAt = (breakAt: number | null) => {
|
||||||
const tokenWidth = visibleWidth(token);
|
if (buf.length === 0) return;
|
||||||
const isSpace = /^\s+$/.test(token);
|
if (breakAt == null || breakAt <= 0) {
|
||||||
|
pushLine(bufToString());
|
||||||
|
buf.length = 0;
|
||||||
|
bufVisible = 0;
|
||||||
|
lastBreakIndex = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (tokenWidth > width && !isSpace) {
|
const left = buf.slice(0, breakAt);
|
||||||
flush();
|
const rest = buf.slice(breakAt);
|
||||||
for (const part of breakLong(token.replace(/^\s+/, ""))) {
|
pushLine(bufToString(left));
|
||||||
push(part);
|
|
||||||
}
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const ch = token.value;
|
||||||
currentWidth + tokenWidth > width &&
|
if (bufVisible + 1 > width && bufVisible > 0) {
|
||||||
current.trim().length > 0 &&
|
flushAt(lastBreakIndex);
|
||||||
!isSpace
|
|
||||||
) {
|
|
||||||
flush();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
current += token;
|
buf.push(token);
|
||||||
currentWidth = visibleWidth(current);
|
bufVisible += 1;
|
||||||
|
if (isBreakChar(ch)) lastBreakIndex = buf.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
flush();
|
flushAt(buf.length);
|
||||||
return lines.length ? lines : [""];
|
return lines.length ? lines : [""];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user