From 16cfd24967584465facb2a20a46544338fa4227a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 03:22:02 +0000 Subject: [PATCH 1/5] fix: colorize daemon status output --- src/cli/daemon-cli.ts | 169 +++++++++++++++++++++++++++++------------- 1 file changed, 117 insertions(+), 52 deletions(-) diff --git a/src/cli/daemon-cli.ts b/src/cli/daemon-cli.ts index 908c4fb7a..f26679f11 100644 --- a/src/cli/daemon-cli.ts +++ b/src/cli/daemon-cli.ts @@ -48,6 +48,7 @@ import { import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js"; import { getResolvedLoggerSettings } from "../logging.js"; import { defaultRuntime } from "../runtime.js"; +import { colorize, isRich, theme } from "../terminal/theme.js"; import { createDefaultDeps } from "./deps.js"; import { withProgress } from "./progress.js"; @@ -523,100 +524,142 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) { return; } + const rich = isRich(); + const label = (value: string) => colorize(rich, theme.muted, value); + const accent = (value: string) => colorize(rich, theme.accent, value); + const infoText = (value: string) => colorize(rich, theme.info, value); + const okText = (value: string) => colorize(rich, theme.success, value); + const warnText = (value: string) => colorize(rich, theme.warn, value); + const errorText = (value: string) => colorize(rich, theme.error, value); + const { service, rpc, legacyServices, extraServices } = status; + const serviceStatus = service.loaded + ? okText(service.loadedText) + : warnText(service.notLoadedText); defaultRuntime.log( - `Service: ${service.label} (${service.loaded ? service.loadedText : service.notLoadedText})`, + `${label("Service:")} ${accent(service.label)} (${serviceStatus})`, ); try { const logFile = getResolvedLoggerSettings().file; - defaultRuntime.log(`File logs: ${logFile}`); + defaultRuntime.log(`${label("File logs:")} ${infoText(logFile)}`); } catch { // ignore missing config/log resolution } if (service.command?.programArguments?.length) { defaultRuntime.log( - `Command: ${service.command.programArguments.join(" ")}`, + `${label("Command:")} ${infoText(service.command.programArguments.join(" "))}`, ); } if (service.command?.sourcePath) { - defaultRuntime.log(`Service file: ${service.command.sourcePath}`); + defaultRuntime.log( + `${label("Service file:")} ${infoText(service.command.sourcePath)}`, + ); } if (service.command?.workingDirectory) { - defaultRuntime.log(`Working dir: ${service.command.workingDirectory}`); + defaultRuntime.log( + `${label("Working dir:")} ${infoText(service.command.workingDirectory)}`, + ); } const daemonEnvLines = safeDaemonEnv(service.command?.environment); if (daemonEnvLines.length > 0) { - defaultRuntime.log(`Daemon env: ${daemonEnvLines.join(" ")}`); + defaultRuntime.log(`${label("Daemon env:")} ${daemonEnvLines.join(" ")}`); } if (service.configAudit?.issues.length) { - defaultRuntime.error("Service config looks out of date or non-standard."); + defaultRuntime.error( + warnText("Service config looks out of date or non-standard."), + ); for (const issue of service.configAudit.issues) { const detail = issue.detail ? ` (${issue.detail})` : ""; - defaultRuntime.error(`Service config issue: ${issue.message}${detail}`); + defaultRuntime.error( + `${warnText("Service config issue:")} ${issue.message}${detail}`, + ); } defaultRuntime.error( - 'Recommendation: run "clawdbot doctor" (or "clawdbot doctor --repair").', + warnText( + 'Recommendation: run "clawdbot doctor" (or "clawdbot doctor --repair").', + ), ); } if (status.config) { const cliCfg = `${status.config.cli.path}${status.config.cli.exists ? "" : " (missing)"}${status.config.cli.valid ? "" : " (invalid)"}`; - defaultRuntime.log(`Config (cli): ${cliCfg}`); + defaultRuntime.log(`${label("Config (cli):")} ${infoText(cliCfg)}`); if (!status.config.cli.valid && status.config.cli.issues?.length) { for (const issue of status.config.cli.issues.slice(0, 5)) { defaultRuntime.error( - `Config issue: ${issue.path || ""}: ${issue.message}`, + `${errorText("Config issue:")} ${issue.path || ""}: ${issue.message}`, ); } } if (status.config.daemon) { const daemonCfg = `${status.config.daemon.path}${status.config.daemon.exists ? "" : " (missing)"}${status.config.daemon.valid ? "" : " (invalid)"}`; - defaultRuntime.log(`Config (daemon): ${daemonCfg}`); + defaultRuntime.log(`${label("Config (daemon):")} ${infoText(daemonCfg)}`); if (!status.config.daemon.valid && status.config.daemon.issues?.length) { for (const issue of status.config.daemon.issues.slice(0, 5)) { defaultRuntime.error( - `Daemon config issue: ${issue.path || ""}: ${issue.message}`, + `${errorText("Daemon config issue:")} ${issue.path || ""}: ${issue.message}`, ); } } } if (status.config.mismatch) { defaultRuntime.error( - "Root cause: CLI and daemon are using different config paths (likely a profile/state-dir mismatch).", + errorText( + "Root cause: CLI and daemon are using different config paths (likely a profile/state-dir mismatch).", + ), ); defaultRuntime.error( - "Fix: rerun `clawdbot daemon install --force` from the same --profile / CLAWDBOT_STATE_DIR you expect.", + errorText( + "Fix: rerun `clawdbot daemon install --force` from the same --profile / CLAWDBOT_STATE_DIR you expect.", + ), ); } } if (status.gateway) { const bindHost = status.gateway.bindHost ?? "n/a"; defaultRuntime.log( - `Gateway: bind=${status.gateway.bindMode} (${bindHost}), port=${status.gateway.port} (${status.gateway.portSource})`, + `${label("Gateway:")} bind=${infoText(status.gateway.bindMode)} (${infoText(bindHost)}), port=${infoText(String(status.gateway.port))} (${infoText(status.gateway.portSource)})`, + ); + defaultRuntime.log( + `${label("Probe target:")} ${infoText(status.gateway.probeUrl)}`, ); - defaultRuntime.log(`Probe target: ${status.gateway.probeUrl}`); const controlUiEnabled = status.config?.daemon?.controlUi?.enabled ?? true; if (!controlUiEnabled) { - defaultRuntime.log("Dashboard: disabled"); + defaultRuntime.log(`${label("Dashboard:")} ${warnText("disabled")}`); } else { const links = resolveControlUiLinks({ port: status.gateway.port, bind: status.gateway.bindMode, basePath: status.config?.daemon?.controlUi?.basePath, }); - defaultRuntime.log(`Dashboard: ${links.httpUrl}`); + defaultRuntime.log(`${label("Dashboard:")} ${infoText(links.httpUrl)}`); } if (status.gateway.probeNote) { - defaultRuntime.log(`Probe note: ${status.gateway.probeNote}`); + defaultRuntime.log( + `${label("Probe note:")} ${infoText(status.gateway.probeNote)}`, + ); } if (status.gateway.bindMode === "tailnet" && !status.gateway.bindHost) { defaultRuntime.error( - "Root cause: gateway bind=tailnet but no tailnet interface was found.", + errorText( + "Root cause: gateway bind=tailnet but no tailnet interface was found.", + ), ); } } const runtimeLine = formatRuntimeStatus(service.runtime); if (runtimeLine) { - defaultRuntime.log(`Runtime: ${runtimeLine}`); + const runtimeStatus = service.runtime?.status ?? "unknown"; + const runtimeColor = + runtimeStatus === "running" + ? theme.success + : runtimeStatus === "stopped" + ? theme.error + : runtimeStatus === "unknown" + ? theme.muted + : theme.warn; + defaultRuntime.log( + `${label("Runtime:")} ${colorize(rich, runtimeColor, runtimeLine)}`, + ); } if ( rpc && @@ -625,44 +668,47 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) { service.runtime?.status === "running" ) { defaultRuntime.log( - "Warm-up: launch agents can take a few seconds. Try again shortly.", + warnText("Warm-up: launch agents can take a few seconds. Try again shortly."), ); } if (rpc) { if (rpc.ok) { - defaultRuntime.log("RPC probe: ok"); + defaultRuntime.log(`${label("RPC probe:")} ${okText("ok")}`); } else { - defaultRuntime.error("RPC probe: failed"); - if (rpc.url) defaultRuntime.error(`RPC target: ${rpc.url}`); + defaultRuntime.error(`${label("RPC probe:")} ${errorText("failed")}`); + if (rpc.url) + defaultRuntime.error(`${label("RPC target:")} ${rpc.url}`); const lines = String(rpc.error ?? "unknown") .split(/\r?\n/) .filter(Boolean); for (const line of lines.slice(0, 12)) { - defaultRuntime.error(` ${line}`); + defaultRuntime.error(` ${errorText(line)}`); } } } if (service.runtime?.missingUnit) { - defaultRuntime.error("Service unit not found."); + defaultRuntime.error(errorText("Service unit not found.")); for (const hint of renderRuntimeHints(service.runtime)) { - defaultRuntime.error(hint); + defaultRuntime.error(errorText(hint)); } } else if (service.loaded && service.runtime?.status === "stopped") { defaultRuntime.error( - "Service is loaded but not running (likely exited immediately).", + errorText("Service is loaded but not running (likely exited immediately)."), ); for (const hint of renderRuntimeHints( service.runtime, (service.command?.environment ?? process.env) as NodeJS.ProcessEnv, )) { - defaultRuntime.error(hint); + defaultRuntime.error(errorText(hint)); } } if (service.runtime?.cachedLabel) { defaultRuntime.error( - `LaunchAgent label cached but plist missing. Clear with: launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}`, + errorText( + `LaunchAgent label cached but plist missing. Clear with: launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}`, + ), ); - defaultRuntime.error("Then reinstall: clawdbot daemon install"); + defaultRuntime.error(errorText("Then reinstall: clawdbot daemon install")); } if (status.port && shouldReportPortUsage(status.port.status, rpc?.ok)) { for (const line of formatPortDiagnostics({ @@ -671,7 +717,7 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) { listeners: status.port.listeners, hints: status.port.hints, })) { - defaultRuntime.error(line); + defaultRuntime.error(errorText(line)); } } if (status.port) { @@ -683,12 +729,12 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) { ), ); if (addrs.length > 0) { - defaultRuntime.log(`Listening: ${addrs.join(", ")}`); + defaultRuntime.log(`${label("Listening:")} ${infoText(addrs.join(", "))}`); } } if (status.portCli && status.portCli.port !== status.port?.port) { defaultRuntime.log( - `Note: CLI config resolves gateway port=${status.portCli.port} (${status.portCli.status}).`, + `${label("Note:")} CLI config resolves gateway port=${status.portCli.port} (${status.portCli.status}).`, ); } if ( @@ -698,52 +744,68 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) { status.port.status !== "busy" ) { defaultRuntime.error( - `Gateway port ${status.port.port} is not listening (service appears running).`, + errorText( + `Gateway port ${status.port.port} is not listening (service appears running).`, + ), ); if (status.lastError) { - defaultRuntime.error(`Last gateway error: ${status.lastError}`); + defaultRuntime.error( + `${errorText("Last gateway error:")} ${status.lastError}`, + ); } if (process.platform === "linux") { defaultRuntime.error( - `Logs: journalctl --user -u ${GATEWAY_SYSTEMD_SERVICE_NAME}.service -n 200 --no-pager`, + errorText( + `Logs: journalctl --user -u ${GATEWAY_SYSTEMD_SERVICE_NAME}.service -n 200 --no-pager`, + ), ); } else if (process.platform === "darwin") { const logs = resolveGatewayLogPaths( (service.command?.environment ?? process.env) as NodeJS.ProcessEnv, ); - defaultRuntime.error(`Logs: ${logs.stdoutPath}`); - defaultRuntime.error(`Errors: ${logs.stderrPath}`); + defaultRuntime.error(`${errorText("Logs:")} ${logs.stdoutPath}`); + defaultRuntime.error(`${errorText("Errors:")} ${logs.stderrPath}`); } } if (legacyServices.length > 0) { - defaultRuntime.error("Legacy Clawdis services detected:"); + defaultRuntime.error(errorText("Legacy Clawdis services detected:")); for (const svc of legacyServices) { - defaultRuntime.error(`- ${svc.label} (${svc.detail})`); + defaultRuntime.error(`- ${errorText(svc.label)} (${svc.detail})`); } - defaultRuntime.error("Cleanup: clawdbot doctor"); + defaultRuntime.error(errorText("Cleanup: clawdbot doctor")); } if (extraServices.length > 0) { - defaultRuntime.error("Other gateway-like services detected (best effort):"); + defaultRuntime.error( + errorText("Other gateway-like services detected (best effort):"), + ); for (const svc of extraServices) { - defaultRuntime.error(`- ${svc.label} (${svc.scope}, ${svc.detail})`); + defaultRuntime.error( + `- ${errorText(svc.label)} (${svc.scope}, ${svc.detail})`, + ); } for (const hint of renderGatewayServiceCleanupHints()) { - defaultRuntime.error(`Cleanup hint: ${hint}`); + defaultRuntime.error(`${errorText("Cleanup hint:")} ${hint}`); } } if (legacyServices.length > 0 || extraServices.length > 0) { defaultRuntime.error( - "Recommendation: run a single gateway per machine. One gateway supports multiple agents.", + errorText( + "Recommendation: run a single gateway per machine. One gateway supports multiple agents.", + ), ); defaultRuntime.error( - "If you need multiple gateways, isolate ports + config/state (see docs: /gateway#multiple-gateways-same-host).", + errorText( + "If you need multiple gateways, isolate ports + config/state (see docs: /gateway#multiple-gateways-same-host).", + ), ); } - defaultRuntime.log("Troubles: run clawdbot status"); - defaultRuntime.log("Troubleshooting: https://docs.clawd.bot/troubleshooting"); + defaultRuntime.log(`${label("Troubles:")} run clawdbot status`); + defaultRuntime.log( + `${label("Troubleshooting:")} https://docs.clawd.bot/troubleshooting`, + ); } export async function runDaemonStatus(opts: DaemonStatusOptions) { @@ -755,7 +817,10 @@ export async function runDaemonStatus(opts: DaemonStatusOptions) { }); printDaemonStatus(status, { json: Boolean(opts.json) }); } catch (err) { - defaultRuntime.error(`Daemon status failed: ${String(err)}`); + const rich = isRich(); + defaultRuntime.error( + colorize(rich, theme.error, `Daemon status failed: ${String(err)}`), + ); defaultRuntime.exit(1); } } From 69546d563d547d759f2b29531a2d4e4cc4821605 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 03:45:01 +0000 Subject: [PATCH 2/5] fix: combine status usage and cost line --- src/auto-reply/status.test.ts | 3 +-- src/auto-reply/status.ts | 5 +++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 7d0c2965f..8813d753d 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -93,8 +93,7 @@ describe("buildStatusMessage", () => { expect(text).toContain("🦞 ClawdBot"); expect(text).toContain("🧠 Model: anthropic/pi:opus · 🔑 api-key"); - expect(text).toContain("🧮 Tokens: 1.2k in / 800 out"); - expect(text).toContain("💵 Cost: $0.0020"); + expect(text).toContain("🧮 Tokens: 1.2k in / 800 out · 💵 Cost: $0.0020"); expect(text).toContain("Context: 16k/32k (50%)"); expect(text).toContain("🧹 Compactions: 2"); expect(text).toContain("Session: agent:main:main"); diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index fef52f199..968be2d76 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -328,12 +328,13 @@ export function buildStatusMessage(args: StatusArgs): string { const versionLine = `🦞 ClawdBot ${VERSION}${commit ? ` (${commit})` : ""}`; const usagePair = formatUsagePair(inputTokens, outputTokens); const costLine = costLabel ? `💵 Cost: ${costLabel}` : null; + const usageCostLine = + usagePair && costLine ? `${usagePair} · ${costLine}` : usagePair ?? costLine; return [ versionLine, modelLine, - usagePair, - costLine, + usageCostLine, `📚 ${contextLine}`, args.usageLine, `🧵 ${sessionLine}`, From 8e27ea737101d9101b5507062fb1f8086179b175 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 03:45:14 +0000 Subject: [PATCH 3/5] feat: add raw stream logging flags --- CHANGELOG.md | 1 + docs/debugging.md | 86 ++++++++++++++++++++++++++ src/agents/pi-embedded-subscribe.ts | 45 ++++++++++++++ src/auto-reply/reply.directive.test.ts | 2 +- src/auto-reply/reply/commands.ts | 1 - src/auto-reply/status.test.ts | 2 - src/auto-reply/status.ts | 8 +-- src/cli/gateway-cli.ts | 12 ++++ 8 files changed, 149 insertions(+), 8 deletions(-) create mode 100644 docs/debugging.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6073b8224..01b4cb5af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Debugging: add raw model stream logging flags and document gateway watch mode. - CLI: improve `logs` output (pretty/plain/JSONL), add gateway unreachable hint, and document logging. - WhatsApp: route queued replies to the original sender instead of the bot's own number. (#534) — thanks @mcinteerj - Models: add OAuth expiry checks in doctor, expanded `models status` auth output (missing auth + `--check` exit codes). (#538) — thanks @latitudeki5223 diff --git a/docs/debugging.md b/docs/debugging.md new file mode 100644 index 000000000..ac8827150 --- /dev/null +++ b/docs/debugging.md @@ -0,0 +1,86 @@ +--- +summary: "Debugging tools: watch mode, raw model streams, and tracing reasoning leakage" +read_when: + - You need to inspect raw model output for reasoning leakage + - You want to run the Gateway in watch mode while iterating + - You need a repeatable debugging workflow +--- + +# Debugging + +This page covers debugging helpers for streaming output, especially when a +provider mixes reasoning into normal text. + +## Gateway watch mode + +For fast iteration, run the gateway under the file watcher: + +```bash +pnpm gateway:watch --force +``` + +This maps to: + +```bash +tsx watch src/entry.ts gateway --force +``` + +Add any gateway CLI flags after `gateway:watch` and they will be passed through +on each restart. + +## Raw stream logging (Clawdbot) + +Clawdbot can log the **raw assistant stream** before any filtering/formatting. +This is the best way to see whether reasoning is arriving as plain text deltas +(or as separate thinking blocks). + +Enable it via CLI: + +```bash +pnpm gateway:watch --force --raw-stream +``` + +Optional path override: + +```bash +pnpm gateway:watch --force --raw-stream --raw-stream-path ~/.clawdbot/logs/raw-stream.jsonl +``` + +Equivalent env vars: + +```bash +CLAWDBOT_RAW_STREAM=1 +CLAWDBOT_RAW_STREAM_PATH=~/.clawdbot/logs/raw-stream.jsonl +``` + +Default file: + +`~/.clawdbot/logs/raw-stream.jsonl` + +## Raw chunk logging (pi-mono) + +To capture **raw OpenAI-compat chunks** before they are parsed into blocks, +pi-mono exposes a separate logger: + +```bash +PI_RAW_STREAM=1 +``` + +Optional path: + +```bash +PI_RAW_STREAM_PATH=~/.pi-mono/logs/raw-openai-completions.jsonl +``` + +Default file: + +`~/.pi-mono/logs/raw-openai-completions.jsonl` + +> Note: this is only emitted by processes using pi-mono’s +> `openai-completions` provider. + +## Safety notes + +- Raw stream logs can include full prompts, tool output, and user data. +- Keep logs local and delete them after debugging. +- If you share logs, scrub secrets and PII first. diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index a5d01251a..c3f703e40 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -1,8 +1,11 @@ +import fs from "node:fs"; +import path from "node:path"; import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core"; import type { AssistantMessage } from "@mariozechner/pi-ai"; import type { AgentSession } from "@mariozechner/pi-coding-agent"; import type { ReasoningLevel } from "../auto-reply/thinking.js"; import { formatToolAggregate } from "../auto-reply/tool-meta.js"; +import { resolveStateDir } from "../config/paths.js"; import { emitAgentEvent } from "../infra/agent-events.js"; import { createSubsystemLogger } from "../logging.js"; import { splitMediaFromOutput } from "../media/parse.js"; @@ -23,6 +26,31 @@ const THINKING_OPEN_GLOBAL_RE = /<\s*think(?:ing)?\s*>/gi; const THINKING_CLOSE_GLOBAL_RE = /<\s*\/\s*think(?:ing)?\s*>/gi; const TOOL_RESULT_MAX_CHARS = 8000; const log = createSubsystemLogger("agent/embedded"); +const RAW_STREAM_ENABLED = process.env.CLAWDBOT_RAW_STREAM === "1"; +const RAW_STREAM_PATH = + process.env.CLAWDBOT_RAW_STREAM_PATH?.trim() || + path.join(resolveStateDir(), "logs", "raw-stream.jsonl"); +let rawStreamReady = false; + +const appendRawStream = (payload: Record) => { + if (!RAW_STREAM_ENABLED) return; + if (!rawStreamReady) { + rawStreamReady = true; + try { + fs.mkdirSync(path.dirname(RAW_STREAM_PATH), { recursive: true }); + } catch { + // ignore raw stream mkdir failures + } + } + try { + void fs.promises.appendFile( + RAW_STREAM_PATH, + `${JSON.stringify(payload)}\n`, + ); + } catch { + // ignore raw stream write failures + } +}; export type { BlockReplyChunking } from "./pi-embedded-block-chunker.js"; @@ -664,6 +692,15 @@ export function subscribeEmbeddedPiSession(params: { typeof assistantRecord?.content === "string" ? assistantRecord.content : ""; + appendRawStream({ + ts: Date.now(), + event: "assistant_text_stream", + runId: params.runId, + sessionId: (params.session as { id?: string }).id, + evtType, + delta, + content, + }); let chunk = ""; if (evtType === "text_delta") { chunk = delta; @@ -756,6 +793,14 @@ export function subscribeEmbeddedPiSession(params: { if (msg?.role === "assistant") { const assistantMessage = msg as AssistantMessage; const rawText = extractAssistantText(assistantMessage); + appendRawStream({ + ts: Date.now(), + event: "assistant_message_end", + runId: params.runId, + sessionId: (params.session as { id?: string }).id, + rawText, + rawThinking: extractAssistantThinking(assistantMessage), + }); const cleaned = params.enforceFinalTag ? stripThinkingSegments(stripUnpairedThinkingTags(rawText)) : stripThinkingSegments(rawText); diff --git a/src/auto-reply/reply.directive.test.ts b/src/auto-reply/reply.directive.test.ts index fcb3087c8..fa8c5051c 100644 --- a/src/auto-reply/reply.directive.test.ts +++ b/src/auto-reply/reply.directive.test.ts @@ -599,7 +599,7 @@ describe("directive behavior", () => { const text = Array.isArray(res) ? res[0]?.text : res?.text; expect(text).toContain("Elevated mode disabled."); - expect(text).toContain("status agent:main:main"); + expect(text).toContain("Session: agent:main:main"); expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); }); }); diff --git a/src/auto-reply/reply/commands.ts b/src/auto-reply/reply/commands.ts index 4879323e0..88e744c0e 100644 --- a/src/auto-reply/reply/commands.ts +++ b/src/auto-reply/reply/commands.ts @@ -555,7 +555,6 @@ export async function handleCommands(params: { const reply = await buildStatusReply({ cfg, command, - provider: command.provider, sessionEntry, sessionKey, sessionScope, diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index 8813d753d..ef0ac5bc1 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -65,7 +65,6 @@ describe("buildStatusMessage", () => { }, }, }, - }, } as ClawdbotConfig, agent: { model: "anthropic/pi:opus", @@ -248,7 +247,6 @@ describe("buildStatusMessage", () => { }, }, }, - }, } as ClawdbotConfig, agent: { model: "anthropic/claude-opus-4-5" }, sessionEntry: { sessionId: "c1", updatedAt: 0, inputTokens: 10 }, diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 968be2d76..668304b20 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -296,7 +296,10 @@ export function buildStatusMessage(args: StatusArgs): string { const activationLine = activationParts.filter(Boolean).join(" · "); const authMode = resolveModelAuthMode(provider, args.config); - const showCost = authMode === "api-key"; + const authLabelValue = + args.modelAuth ?? + (authMode && authMode !== "unknown" ? authMode : undefined); + const showCost = authLabelValue === "api-key" || authLabelValue === "mixed"; const costConfig = showCost ? resolveModelCostConfig({ provider, @@ -319,9 +322,6 @@ export function buildStatusMessage(args: StatusArgs): string { const costLabel = showCost && hasUsage ? formatUsd(cost) : undefined; const modelLabel = model ? `${provider}/${model}` : "unknown"; - const authLabelValue = - args.modelAuth ?? - (authMode && authMode !== "unknown" ? authMode : undefined); const authLabel = authLabelValue ? ` · 🔑 ${authLabelValue}` : ""; const modelLine = `🧠 Model: ${modelLabel}${authLabel}`; const commit = resolveCommitHash(); diff --git a/src/cli/gateway-cli.ts b/src/cli/gateway-cli.ts index 7168a8054..5222af2b4 100644 --- a/src/cli/gateway-cli.ts +++ b/src/cli/gateway-cli.ts @@ -50,6 +50,8 @@ type GatewayRunOpts = { verbose?: boolean; wsLog?: unknown; compact?: boolean; + rawStream?: boolean; + rawStreamPath?: unknown; }; type GatewayRunParams = { @@ -300,6 +302,14 @@ async function runGatewayCommand( } setGatewayWsLogStyle(wsLogStyle); + if (opts.rawStream) { + process.env.CLAWDBOT_RAW_STREAM = "1"; + } + const rawStreamPath = toOptionString(opts.rawStreamPath); + if (rawStreamPath) { + process.env.CLAWDBOT_RAW_STREAM_PATH = rawStreamPath; + } + const cfg = loadConfig(); const portOverride = parsePort(opts.port); if (opts.port !== undefined && portOverride === null) { @@ -565,6 +575,8 @@ function addGatewayRunCommand( "auto", ) .option("--compact", 'Alias for "--ws-log compact"', false) + .option("--raw-stream", "Log raw model stream events to jsonl", false) + .option("--raw-stream-path ", "Raw stream jsonl path") .action(async (opts) => { await runGatewayCommand(opts, params); }); From 9a8fe4d683b3d54c89636d98c43980b2c365d1f5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 04:52:37 +0100 Subject: [PATCH 4/5] feat(agent): add claude cli runner --- CHANGELOG.md | 1 + src/agents/claude-cli-runner.ts | 332 ++++++++++++++++++++++++++++++++ src/commands/agent.ts | 42 +++- src/config/sessions.ts | 1 + src/cron/isolated-agent.ts | 31 ++- 5 files changed, 399 insertions(+), 8 deletions(-) create mode 100644 src/agents/claude-cli-runner.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 01b4cb5af..bb7f9e096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Debugging: add raw model stream logging flags and document gateway watch mode. +- Agent: add claude-cli/opus-4.5 runner via Claude CLI with resume support (tools disabled). - CLI: improve `logs` output (pretty/plain/JSONL), add gateway unreachable hint, and document logging. - WhatsApp: route queued replies to the original sender instead of the bot's own number. (#534) — thanks @mcinteerj - Models: add OAuth expiry checks in doctor, expanded `models status` auth output (missing auth + `--check` exit codes). (#538) — thanks @latitudeki5223 diff --git a/src/agents/claude-cli-runner.ts b/src/agents/claude-cli-runner.ts new file mode 100644 index 000000000..29eb7b13f --- /dev/null +++ b/src/agents/claude-cli-runner.ts @@ -0,0 +1,332 @@ +import os from "node:os"; + +import type { AgentTool } from "@mariozechner/pi-agent-core"; +import { resolveHeartbeatPrompt } from "../auto-reply/heartbeat.js"; +import type { ThinkLevel } from "../auto-reply/thinking.js"; +import type { ClawdbotConfig } from "../config/config.js"; +import { createSubsystemLogger } from "../logging.js"; +import { runCommandWithTimeout } from "../process/exec.js"; +import { resolveUserPath } from "../utils.js"; +import { + buildBootstrapContextFiles, + type EmbeddedContextFile, +} from "./pi-embedded-helpers.js"; +import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js"; +import { buildAgentSystemPrompt } from "./system-prompt.js"; +import { loadWorkspaceBootstrapFiles } from "./workspace.js"; + +const log = createSubsystemLogger("agent/claude-cli"); + +type ClaudeCliUsage = { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + total?: number; +}; + +type ClaudeCliOutput = { + text: string; + sessionId?: string; + usage?: ClaudeCliUsage; +}; + +function resolveUserTimezone(configured?: string): string { + const trimmed = configured?.trim(); + if (trimmed) { + try { + new Intl.DateTimeFormat("en-US", { timeZone: trimmed }).format( + new Date(), + ); + return trimmed; + } catch { + // ignore invalid timezone + } + } + const host = Intl.DateTimeFormat().resolvedOptions().timeZone; + return host?.trim() || "UTC"; +} + +function formatUserTime(date: Date, timeZone: string): string | undefined { + try { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone, + weekday: "long", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }).formatToParts(date); + const map: Record = {}; + for (const part of parts) { + if (part.type !== "literal") map[part.type] = part.value; + } + if ( + !map.weekday || + !map.year || + !map.month || + !map.day || + !map.hour || + !map.minute + ) { + return undefined; + } + return `${map.weekday} ${map.year}-${map.month}-${map.day} ${map.hour}:${map.minute}`; + } catch { + return undefined; + } +} + +function buildModelAliasLines(cfg?: ClawdbotConfig) { + const models = cfg?.agent?.models ?? {}; + const entries: Array<{ alias: string; model: string }> = []; + for (const [keyRaw, entryRaw] of Object.entries(models)) { + const model = String(keyRaw ?? "").trim(); + if (!model) continue; + const alias = String( + (entryRaw as { alias?: string } | undefined)?.alias ?? "", + ).trim(); + if (!alias) continue; + entries.push({ alias, model }); + } + return entries + .sort((a, b) => a.alias.localeCompare(b.alias)) + .map((entry) => `- ${entry.alias}: ${entry.model}`); +} + +function buildSystemPrompt(params: { + workspaceDir: string; + config?: ClawdbotConfig; + defaultThinkLevel?: ThinkLevel; + extraSystemPrompt?: string; + ownerNumbers?: string[]; + tools: AgentTool[]; + contextFiles?: EmbeddedContextFile[]; + modelDisplay: string; +}) { + const userTimezone = resolveUserTimezone(params.config?.agent?.userTimezone); + const userTime = formatUserTime(new Date(), userTimezone); + return buildAgentSystemPrompt({ + workspaceDir: params.workspaceDir, + defaultThinkLevel: params.defaultThinkLevel, + extraSystemPrompt: params.extraSystemPrompt, + ownerNumbers: params.ownerNumbers, + reasoningTagHint: false, + heartbeatPrompt: resolveHeartbeatPrompt( + params.config?.agent?.heartbeat?.prompt, + ), + runtimeInfo: { + host: "clawdbot", + os: `${os.type()} ${os.release()}`, + arch: os.arch(), + node: process.version, + model: params.modelDisplay, + }, + toolNames: params.tools.map((tool) => tool.name), + modelAliasLines: buildModelAliasLines(params.config), + userTimezone, + userTime, + contextFiles: params.contextFiles, + }); +} + +function normalizeClaudeCliModel(modelId: string): string { + const trimmed = modelId.trim(); + if (!trimmed) return "opus"; + const lower = trimmed.toLowerCase(); + if (lower.startsWith("opus")) return "opus"; + if (lower.startsWith("sonnet")) return "sonnet"; + if (lower.startsWith("haiku")) return "haiku"; + return trimmed; +} + +function toUsage(raw: Record): ClaudeCliUsage | undefined { + const pick = (key: string) => + typeof raw[key] === "number" && raw[key] > 0 + ? (raw[key] as number) + : undefined; + const input = pick("input_tokens") ?? pick("inputTokens"); + const output = pick("output_tokens") ?? pick("outputTokens"); + const cacheRead = pick("cache_read_input_tokens") ?? pick("cacheRead"); + const cacheWrite = pick("cache_write_input_tokens") ?? pick("cacheWrite"); + const total = pick("total_tokens") ?? pick("total"); + if (!input && !output && !cacheRead && !cacheWrite && !total) + return undefined; + return { input, output, cacheRead, cacheWrite, total }; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function collectText(value: unknown): string { + if (!value) return ""; + if (typeof value === "string") return value; + if (Array.isArray(value)) { + return value.map((entry) => collectText(entry)).join(""); + } + if (!isRecord(value)) return ""; + if (typeof value.text === "string") return value.text; + if (typeof value.content === "string") return value.content; + if (Array.isArray(value.content)) { + return value.content.map((entry) => collectText(entry)).join(""); + } + if (isRecord(value.message)) return collectText(value.message); + return ""; +} + +function parseClaudeCliJson(raw: string): ClaudeCliOutput | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return null; + } + if (!isRecord(parsed)) return null; + const sessionId = + (typeof parsed.session_id === "string" && parsed.session_id) || + (typeof parsed.sessionId === "string" && parsed.sessionId) || + (typeof parsed.conversation_id === "string" && parsed.conversation_id) || + undefined; + const usage = isRecord(parsed.usage) ? toUsage(parsed.usage) : undefined; + const text = + collectText(parsed.message) || + collectText(parsed.content) || + collectText(parsed.result) || + collectText(parsed); + return { text: text.trim(), sessionId, usage }; +} + +async function runClaudeCliOnce(params: { + prompt: string; + workspaceDir: string; + modelId: string; + systemPrompt: string; + timeoutMs: number; + resumeSessionId?: string; + sessionId?: string; +}): Promise { + const args = [ + "-p", + "--output-format", + "json", + "--model", + normalizeClaudeCliModel(params.modelId), + "--append-system-prompt", + params.systemPrompt, + "--dangerously-skip-permissions", + "--permission-mode", + "dontAsk", + "--tools", + "", + ]; + if (params.resumeSessionId) { + args.push("--resume", params.resumeSessionId); + } else if (params.sessionId) { + args.push("--session-id", params.sessionId); + } + args.push(params.prompt); + + const result = await runCommandWithTimeout(["claude", ...args], { + timeoutMs: params.timeoutMs, + cwd: params.workspaceDir, + }); + const stdout = result.stdout.trim(); + if (result.code !== 0) { + const err = result.stderr.trim() || stdout || "Claude CLI failed."; + throw new Error(err); + } + const parsed = parseClaudeCliJson(stdout); + if (parsed) return parsed; + return { text: stdout }; +} + +export async function runClaudeCliAgent(params: { + sessionId: string; + sessionKey?: string; + sessionFile: string; + workspaceDir: string; + config?: ClawdbotConfig; + prompt: string; + provider?: string; + model?: string; + thinkLevel?: ThinkLevel; + timeoutMs: number; + runId: string; + extraSystemPrompt?: string; + ownerNumbers?: string[]; + resumeSessionId?: string; +}): Promise { + const started = Date.now(); + const resolvedWorkspace = resolveUserPath(params.workspaceDir); + const workspaceDir = resolvedWorkspace; + + const modelId = (params.model ?? "opus").trim() || "opus"; + const modelDisplay = `${params.provider ?? "claude-cli"}/${modelId}`; + + const extraSystemPrompt = [ + params.extraSystemPrompt?.trim(), + "Tools are disabled in this session. Do not call tools.", + ] + .filter(Boolean) + .join("\n"); + + const bootstrapFiles = await loadWorkspaceBootstrapFiles(workspaceDir); + const contextFiles = buildBootstrapContextFiles(bootstrapFiles); + const systemPrompt = buildSystemPrompt({ + workspaceDir, + config: params.config, + defaultThinkLevel: params.thinkLevel, + extraSystemPrompt, + ownerNumbers: params.ownerNumbers, + tools: [], + contextFiles, + modelDisplay, + }); + + let output: ClaudeCliOutput; + try { + output = await runClaudeCliOnce({ + prompt: params.prompt, + workspaceDir, + modelId, + systemPrompt, + timeoutMs: params.timeoutMs, + resumeSessionId: params.resumeSessionId, + sessionId: params.sessionId, + }); + } catch (err) { + if (!params.resumeSessionId) throw err; + log.warn( + `claude-cli resume failed for ${params.resumeSessionId}; retrying with --session-id (${params.sessionId})`, + ); + output = await runClaudeCliOnce({ + prompt: params.prompt, + workspaceDir, + modelId, + systemPrompt, + timeoutMs: params.timeoutMs, + sessionId: params.sessionId, + }); + } + + const text = output.text?.trim(); + const payloads = text ? [{ text }] : undefined; + + return { + payloads, + meta: { + durationMs: Date.now() - started, + agentMeta: { + sessionId: output.sessionId ?? params.sessionId, + provider: params.provider ?? "claude-cli", + model: modelId, + usage: output.usage, + }, + }, + }; +} diff --git a/src/commands/agent.ts b/src/commands/agent.ts index da3902a1a..5f6d25b9e 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -4,6 +4,7 @@ import { resolveAgentWorkspaceDir, } from "../agents/agent-scope.js"; import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; +import { runClaudeCliAgent } from "../agents/claude-cli-runner.js"; import { lookupContextTokens } from "../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS, @@ -347,7 +348,11 @@ export async function agentCommand( const overrideModel = sessionEntry.modelOverride?.trim(); if (overrideModel) { const key = modelKey(overrideProvider, overrideModel); - if (allowedModelKeys.size > 0 && !allowedModelKeys.has(key)) { + if ( + overrideProvider !== "claude-cli" && + allowedModelKeys.size > 0 && + !allowedModelKeys.has(key) + ) { delete sessionEntry.providerOverride; delete sessionEntry.modelOverride; sessionEntry.updatedAt = Date.now(); @@ -362,7 +367,11 @@ export async function agentCommand( if (storedModelOverride) { const candidateProvider = storedProviderOverride || defaultProvider; const key = modelKey(candidateProvider, storedModelOverride); - if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) { + if ( + candidateProvider === "claude-cli" || + allowedModelKeys.size === 0 || + allowedModelKeys.has(key) + ) { provider = candidateProvider; model = storedModelOverride; } @@ -401,6 +410,7 @@ export async function agentCommand( let result: Awaited>; let fallbackProvider = provider; let fallbackModel = model; + const claudeResumeId = sessionEntry?.claudeCliSessionId?.trim(); try { const messageProvider = resolveMessageProvider( opts.messageProvider, @@ -410,8 +420,25 @@ export async function agentCommand( cfg, provider, model, - run: (providerOverride, modelOverride) => - runEmbeddedPiAgent({ + run: (providerOverride, modelOverride) => { + if (providerOverride === "claude-cli") { + return runClaudeCliAgent({ + sessionId, + sessionKey, + sessionFile, + workspaceDir, + config: cfg, + prompt: body, + provider: providerOverride, + model: modelOverride, + thinkLevel: resolvedThinkLevel, + timeoutMs, + runId, + extraSystemPrompt: opts.extraSystemPrompt, + resumeSessionId: claudeResumeId, + }); + } + return runEmbeddedPiAgent({ sessionId, sessionKey, messageProvider, @@ -445,7 +472,8 @@ export async function agentCommand( data: evt.data, }); }, - }), + }); + }, }); result = fallbackResult.result; fallbackProvider = fallbackResult.provider; @@ -501,6 +529,10 @@ export async function agentCommand( model: modelUsed, contextTokens, }; + if (providerUsed === "claude-cli") { + const cliSessionId = result.meta.agentMeta?.sessionId?.trim(); + if (cliSessionId) next.claudeCliSessionId = cliSessionId; + } next.abortedLastRun = result.meta.aborted ?? false; if (hasNonzeroUsage(usage)) { const input = usage.input ?? 0; diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 6dfa7b3be..dfeda9e98 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -112,6 +112,7 @@ export type SessionEntry = { model?: string; contextTokens?: number; compactionCount?: number; + claudeCliSessionId?: string; displayName?: string; provider?: string; subject?: string; diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index a4f7be945..a4db61028 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -1,4 +1,5 @@ import crypto from "node:crypto"; +import { runClaudeCliAgent } from "../agents/claude-cli-runner.js"; import { lookupContextTokens } from "../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS, @@ -422,12 +423,29 @@ export async function runCronIsolatedAgentTurn(params: { sessionKey: params.sessionKey, }); const messageProvider = resolvedDelivery.provider; + const claudeResumeId = cronSession.sessionEntry.claudeCliSessionId?.trim(); const fallbackResult = await runWithModelFallback({ cfg: params.cfg, provider, model, - run: (providerOverride, modelOverride) => - runEmbeddedPiAgent({ + run: (providerOverride, modelOverride) => { + if (providerOverride === "claude-cli") { + return runClaudeCliAgent({ + sessionId: cronSession.sessionEntry.sessionId, + sessionKey: params.sessionKey, + sessionFile, + workspaceDir, + config: params.cfg, + prompt: commandBody, + provider: providerOverride, + model: modelOverride, + thinkLevel, + timeoutMs, + runId: cronSession.sessionEntry.sessionId, + resumeSessionId: claudeResumeId, + }); + } + return runEmbeddedPiAgent({ sessionId: cronSession.sessionEntry.sessionId, sessionKey: params.sessionKey, messageProvider, @@ -448,7 +466,8 @@ export async function runCronIsolatedAgentTurn(params: { (agentCfg?.verboseDefault as "on" | "off" | undefined), timeoutMs, runId: cronSession.sessionEntry.sessionId, - }), + }); + }, }); runResult = fallbackResult.result; fallbackProvider = fallbackResult.provider; @@ -473,6 +492,12 @@ export async function runCronIsolatedAgentTurn(params: { cronSession.sessionEntry.modelProvider = providerUsed; cronSession.sessionEntry.model = modelUsed; cronSession.sessionEntry.contextTokens = contextTokens; + if (providerUsed === "claude-cli") { + const cliSessionId = runResult.meta.agentMeta?.sessionId?.trim(); + if (cliSessionId) { + cronSession.sessionEntry.claudeCliSessionId = cliSessionId; + } + } if (hasNonzeroUsage(usage)) { const input = usage.input ?? 0; const output = usage.output ?? 0; From 709d5d9cd6cd1bb7b51cfc81a5497a6d46618228 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 03:52:38 +0000 Subject: [PATCH 5/5] fix: add spacing to daemon status output --- src/cli/daemon-cli.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/cli/daemon-cli.ts b/src/cli/daemon-cli.ts index f26679f11..fbc3594a1 100644 --- a/src/cli/daemon-cli.ts +++ b/src/cli/daemon-cli.ts @@ -531,6 +531,7 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) { const okText = (value: string) => colorize(rich, theme.success, value); const warnText = (value: string) => colorize(rich, theme.warn, value); const errorText = (value: string) => colorize(rich, theme.error, value); + const spacer = () => defaultRuntime.log(""); const { service, rpc, legacyServices, extraServices } = status; const serviceStatus = service.loaded @@ -564,6 +565,7 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) { if (daemonEnvLines.length > 0) { defaultRuntime.log(`${label("Daemon env:")} ${daemonEnvLines.join(" ")}`); } + spacer(); if (service.configAudit?.issues.length) { defaultRuntime.error( warnText("Service config looks out of date or non-standard."), @@ -613,6 +615,7 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) { ), ); } + spacer(); } if (status.gateway) { const bindHost = status.gateway.bindHost ?? "n/a"; @@ -645,6 +648,7 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) { ), ); } + spacer(); } const runtimeLine = formatRuntimeStatus(service.runtime); if (runtimeLine) { @@ -685,6 +689,7 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) { defaultRuntime.error(` ${errorText(line)}`); } } + spacer(); } if (service.runtime?.missingUnit) { defaultRuntime.error(errorText("Service unit not found.")); @@ -701,6 +706,7 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) { )) { defaultRuntime.error(errorText(hint)); } + spacer(); } if (service.runtime?.cachedLabel) { defaultRuntime.error( @@ -709,6 +715,7 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) { ), ); defaultRuntime.error(errorText("Then reinstall: clawdbot daemon install")); + spacer(); } if (status.port && shouldReportPortUsage(status.port.status, rpc?.ok)) { for (const line of formatPortDiagnostics({ @@ -766,6 +773,7 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) { defaultRuntime.error(`${errorText("Logs:")} ${logs.stdoutPath}`); defaultRuntime.error(`${errorText("Errors:")} ${logs.stderrPath}`); } + spacer(); } if (legacyServices.length > 0) { @@ -774,6 +782,7 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) { defaultRuntime.error(`- ${errorText(svc.label)} (${svc.detail})`); } defaultRuntime.error(errorText("Cleanup: clawdbot doctor")); + spacer(); } if (extraServices.length > 0) { @@ -788,6 +797,7 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) { for (const hint of renderGatewayServiceCleanupHints()) { defaultRuntime.error(`${errorText("Cleanup hint:")} ${hint}`); } + spacer(); } if (legacyServices.length > 0 || extraServices.length > 0) { @@ -801,6 +811,7 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) { "If you need multiple gateways, isolate ports + config/state (see docs: /gateway#multiple-gateways-same-host).", ), ); + spacer(); } defaultRuntime.log(`${label("Troubles:")} run clawdbot status`); defaultRuntime.log(