openclaw - ✅(Solved) Fix [Bug]: `openclaw <command> --json` leaks `[plugins]` loader chatter to stdout before commander preAction fires (corrupts JSON payload for non-routed commands) [3 pull requests, 1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#81535Fetched 2026-05-14 03:31:07
View on GitHub
Comments
1
Participants
2
Timeline
6
Reactions
2
Timeline (top)
cross-referenced ×3commented ×1mentioned ×1subscribed ×1

For non-routed (commander-attached) --json commands that resolve to a plugin-provided primary (e.g. openclaw memory search --json), the lazy plugin-command-registration phase in runCli loads the primary's plugin before commander's preAction hook calls routeLogsToStderr() — so the plugin loader's [plugins] loading <id> from <path> and [plugins] loaded N plugin(s) ... lines are emitted on stdout in front of the JSON payload and break downstream … | jq / JSON.parse consumers.

Error Message

exit=0 stdout=78b stderr=... [35m[plugins][39m [90mloading memory-core from /home/eric/.npm-global/lib/node_modules/openclaw/dist/extensions/memory-core/index.js[39m [35m[plugins][39m [90mloaded 1 plugin(s) (1 attempted) in 491.9ms[39m { jq: parse error: Invalid numeric literal at line 1, column 2 FAIL: not parseable

Root Cause

The remaining plugin-loader chatter for other plugins (amazon-bedrock, deepinfra, google, …, voyage) goes to stderr as expected — only the early-load "loading the primary command's plugin" lines escape to stdout, because they happen before forceConsoleToStderr is flipped.

Fix Action

Fix / Workaround

  1. Emitter. src/logging/subsystem.ts:286-305's writeConsoleLine deliberately bypasses the patched console (to avoid recursion) and writes through loggingState.rawConsole. For debug/info levels it goes to sink.log — raw stdout — unless loggingState.forceConsoleToStderr is set:
  • Issue #81183 ([Bug]: openclaw commitments [list|dismiss] --json produces empty stdout — all JSON output goes to stderr) — closed 2026-05-13. Same bug class, opposite direction: per-command code used runtime.log(JSON.stringify(...)), which the forceConsoleToStderr redirect caught, sending the JSON payload to stderr.
  • PR #81215 (fix(commitments): write json output to stdout) — merged 2026-05-13. Fixed by routing the commitments JSON branches through writeRuntimeJson(runtime, value) so they bypass the console patch.

Workaround until fixed: strip the chatter prefix manually, e.g. openclaw memory search 'q' --json 2>/dev/null | grep -v '^\\[35m\\[plugins\\]' | jq . — fragile because the chatter line count varies by plugin configuration.

PR fix notes

PR #81536: fix(cli): route plugin loader logs to stderr for --json before parseAsync

Description (problem / solution / changelog)

Summary

  • Detect --json in argv and call the existing routeLogsToStderr() immediately after enableConsoleCapture() in runCli, so lazy plugin command-registration runs with loggingState.forceConsoleToStderr = true and its [plugins] loading <id> from <path> / [plugins] loaded N plugin(s) ... chatter lands on stderr instead of stdout.
  • Keep diagnostic/log output on stderr in --json mode (existing policy, preserved) and keep the JSON payload itself on stdout (existing per-command behavior, also preserved).
  • No changes to the routed flow (tryRouteCliprepareRoutedCommandapplyCliExecutionStartupPresentation) — it already does the same thing earlier; this PR brings the non-routed (commander-attached) --json flow to parity.

Fixes #81535.

Root cause (verbatim from the issue)

runCli (src/cli/run-main.ts) calls enableConsoleCapture() at line 642, then registerPluginCliCommandsFromValidatedConfig at lines 725-732 — which loads the primary's plugin so commander knows about its subcommands. The commander preAction hook (src/cli/program/preaction.ts:85-94) is what flips forceConsoleToStderr for --json via applyCliExecutionStartupPresentation, but it doesn't fire until program.parseAsync() at run-main.ts:765 — strictly after the registration phase.

The plugin loader's logger.debug?.(...) lines at src/plugins/loader.ts:2054 and :2412 flow through createSubsystemLoggerwriteConsoleLine, which routes debug/info to raw stdout when forceConsoleToStderr === false. Result: every non-routed --json command that resolves to a plugin-provided primary gets [plugins] ... chatter prepended to its stdout output.

Change

// src/cli/run-main.ts (inside runCli's main try-block, after enableConsoleCapture())
const { enableConsoleCapture, routeLogsToStderr } = await import("../logging.js");
enableConsoleCapture();
// The commander preAction hook routes logs to stderr for --json commands, but only
// after parseAsync resolves the action. Plugin command registration below loads the
// primary command's plugin to populate the command tree, and that plugin-loader
// chatter would otherwise land on stdout and corrupt JSON output. Route early.
if (hasJsonOutputFlag(normalizedArgv)) {
  routeLogsToStderr();
}

hasJsonOutputFlag is the existing helper at run-main.ts:136-138 (matches both --json and --json=…). routeLogsToStderr is already exported from ../logging.js (re-export from src/logging/console.ts:104). Net diff: +8 / −1, one file.

This mirrors:

  • What prepareRoutedCommand in route.ts:24-29 already does for routed commands.
  • What applyCliExecutionStartupPresentation does later via the preaction hook — just shifted earlier so the plugin command-registration phase also inherits the stderr routing.

Tests

  • Existing unit tests in adjacent code paths verified pass after the change: 46 tests across src/cli/run-main.test.ts, src/cli/route.test.ts, src/cli/plugin-registry-loader.test.ts complete in 2.11s. These tests already verify the routed and preaction forceConsoleToStderr flows; no test exercised the early-load path in runCli itself, so this PR doesn't break any existing assertions.
  • A targeted regression test for the new branch in runCli would need to mock enableConsoleCapture, routeLogsToStderr, and registerPluginCliCommandsFromValidatedConfig to drive runCli past the early-load phase — significant mock surface. Recommend adding such a test in a small follow-up PR rather than blocking this fix; happy to do that as a separate change if a reviewer prefers it bundled.

Real behavior proof

Behavior addressed: openclaw memory search "<query>" --json (and every other non-routed --json command that uses a plugin-provided primary) writes machine-readable JSON to stdout with no [plugins] ... startup chatter prepended, so automation can pipe the output to jq or JSON.parse without a tail -n +N preprocessor.

Real environment tested: Raspberry Pi 5 (Ubuntu 24.04 ARM64, 8GB), OpenClaw 2026.5.10-beta.1 (40b4ffa) built locally from local-integration-4-fixes-20260511 (= upstream/main for the three files this PR analyzes — src/cli/run-main.ts, src/logging/subsystem.ts, src/logging/console.ts are byte-identical between that base and upstream/main, verified via git diff HEAD upstream/main -- <files> returning zero lines).

Exact steps run after this patch:

openclaw memory search "BRAIN autonomy" --json > /tmp/out.json 2> /tmp/err.log
jq . /tmp/out.json > /dev/null && echo "OK: stdout is valid JSON"
grep -q '"id":' /tmp/err.log && echo "FAIL" || echo "OK: stderr has no payload"
grep -q '\[plugins\]' /tmp/out.json && echo "FAIL" || echo "OK: stdout is clean"

Evidence after fix: All three checks pass.

exit=0  stdout=21b  stderr=1.4Kb
{
  "results": []
}
OK: stdout is valid JSON
OK: stderr has no payload
OK: stdout is clean

stderr correctly captures the previously-leaked early-load lines plus the rest of the loader chatter:

[35m[plugins][39m [90mloading memory-core from /home/eric/.npm-global/lib/node_modules/openclaw/dist/extensions/memory-core/index.js[39m
[35m[plugins][39m [90mloaded 1 plugin(s) (1 attempted) in 471.1ms[39m
[35m[plugins][39m [90mloading amazon-bedrock from ...
... (the rest, unchanged from pre-fix)

Regression check — non---json invocation unchanged:

openclaw memory search "BRAIN autonomy" > /tmp/text-out.txt 2> /tmp/text-err.log

stdout still contains the chatter as it always did (the gate is hasJsonOutputFlag, so the non-JSON path is untouched). This is intentional — the existing default for human-facing text output is preserved.

Regression check — status --json (routed command) unchanged:

openclaw status --json > /tmp/s.json 2> /tmp/s.err
jq . /tmp/s.json > /dev/null && echo "OK"   # OK
wc -l /tmp/s.err                              # 0

Routed --json commands take a different bootstrap path (tryRouteCliprepareRoutedCommand already calls routeLogsToStderr() before any plugins load) — they were unaffected before this PR and remain so after.

What was not tested: A full pnpm test / pnpm check:changed did not run to completion on this Pi 5 because tsdown was repeatedly SIGTERM-killed mid-build (memory-pressure flakiness specific to this host, swap was 67% used) — see "Build note for reviewers" below. The 46 adjacent-suite tests cited above did run cleanly. Recommend a reviewer with a less memory-constrained host run the full check suite.

Cross-references — context for this bug class

This PR addresses the loader-chatter-on-stdout half of an existing bug class in the --json output discipline. The JSON-payload-on-stderr half was just resolved in the same week:

  • Sibling issue #81183 (closed 2026-05-13): [Bug]: openclaw commitments [list|dismiss] --json produces empty stdout — all JSON output goes to stderr. Same forceConsoleToStderr infrastructure, opposite direction (per-command code used runtime.log(JSON.stringify(...)), which the redirect caught, sending the JSON payload to stderr).
  • Sibling PR #81215 (merged 2026-05-13, by @giodl73-repo, merged via squash by @galiniliev): fix(commitments): write json output to stdout. Resolved by routing the commitments JSON branches through writeRuntimeJson(runtime, value) so the payload bypasses the console patch. PR description articulates the same policy this PR preserves: "Keep diagnostic/log output separate from machine-readable stdout."

Prior maintainer acknowledgment of the exact symptom this PR fixes:

  • Commit 05ba1335d93 by @steipete, 2026-04-21, fix: tolerate qa cli json startup logs. Added a parseQaCliJsonOutput helper in extensions/qa-lab/src/suite-runtime-agent-process.ts that strips ANSI, attempts JSON.parse(whole), and on failure scans line-by-line for the first {/[. Inline comment: // Some startup repair logs are emitted on stdout before command JSON. The test fixture at suite-runtime-agent-process.test.ts:198 explicitly captures '\\u001b[35m[plugins]\\u001b[39m \\u001b[36mcodex loaded plugin package metadata\\u001b[39m\\n{"results":...}\\n' as expected stdout — i.e., the qa-lab harness was made to tolerate the buggy stdout shape rather than the underlying routing being fixed. With this PR, that line-skipping fallback in parseQaCliJsonOutput is no longer needed for built-in commands; can be simplified in a separate follow-up.

Build note for reviewers

The verification install on the Pi 5 used a compiled-JS-equivalent patch (same logical change applied to dist/cli/run-main.js, taken from the byte-identical sibling worktree's dist) because pnpm build succeeded the first time (~10 min) but was killed mid-tsdown on every subsequent rebuild. SIGTERM source was external (not the tsdown-build.mjs internal watchdog, which is timeout-disabled by default; presumably OS memory pressure on the constrained host). The source-level commit on this branch is the canonical fix; a reviewer with adequate build headroom should produce an identical-shape install via the standard pnpm install && pnpm build && pnpm pack && npm install -g ./openclaw-*.tgz flow.

The verification results above were captured against the install with the equivalent compiled-JS patch applied.

Changed files

  • src/cli/run-main.ts (modified, +8/-1)

PR #81540: fix(cli): route json startup logs to stderr

Description (problem / solution / changelog)

Summary

  • Route console logs to stderr immediately when full CLI startup sees --json
  • Cover the ordering so plugin command registration cannot leak startup chatter to stdout before preAction

Verification

  • pnpm docs:list
  • git diff --check
  • pnpm test src/cli/run-main.exit.test.ts
  • pnpm check:changed (fails in pre-existing src/plugins/registry.runtime-config.test.ts type errors unrelated to this change)

Fixes #81535

Changed files

  • src/cli/run-main.exit.test.ts (modified, +22/-0)
  • src/cli/run-main.ts (modified, +4/-1)

PR #81568: fix(cli): prevent [plugins] loader chatter from leaking to stdout in --json mode

Description (problem / solution / changelog)

Summary

Fixes #81535

When running non-routed CLI commands with --json (e.g. openclaw memory search --json), the lazy plugin-command-registration phase loads the primary command's plugin before commander's preAction hook calls routeLogsToStderr(). This causes [plugins] loading... and [plugins] loaded N plugin(s)... lines to be emitted on stdout, corrupting the JSON payload and breaking downstream jq/JSON.parse consumers.

Root Cause

In run-main.ts, the registerPluginCliCommandsFromValidatedConfig call (line ~726) triggers plugin loading, which uses createSubsystemLogger("plugins") to emit log lines. For non-routed commands, routeLogsToStderr() is only called later in the commander preAction hook, so the plugin loader chatter leaks to stdout.

Fix

Before calling registerPluginCliCommandsFromValidatedConfig, check for --json flag and call routeLogsToStderr() early if present. This ensures all console.* output (including plugin loader logs) is redirected to stderr before any plugin loading occurs.

Testing

Verified the fix logic:

  • hasJsonOutputFlag(parseArgv) correctly detects --json flag
  • routeLogsToStderr() sets loggingState.forceConsoleToStderr = true, which is respected by the subsystem logger used during plugin loading
  • The fix is scoped to only affect --json mode — normal CLI output is unchanged
# Before fix (expected): stdout contains [plugins] chatter + JSON → jq fails
# After fix (expected): stdout contains only JSON → jq succeeds
openclaw memory search "test" --json | jq .

Note: I was unable to build the full project locally (disk space constraint), so I have not run the full test suite. The change is minimal and well-scoped to the code path described in the issue.

AI-assisted

This PR was written with AI assistance. I have reviewed and understand every line of the change.

Changed files

  • src/cli/run-main.ts (modified, +11/-0)

Code Example

openclaw memory search "BRAIN autonomy" --json > /tmp/out.json 2> /tmp/err.log
echo "exit=$?  stdout=$(wc -c </tmp/out.json)b  stderr=$(wc -c </tmp/err.log)b"
head -3 /tmp/out.json
jq -e . </tmp/out.json && echo "OK: parseable" || echo "FAIL: not parseable"

---

exit=0  stdout=78b  stderr=...
[35m[plugins][39m [90mloading memory-core from /home/eric/.npm-global/lib/node_modules/openclaw/dist/extensions/memory-core/index.js[39m
[35m[plugins][39m [90mloaded 1 plugin(s) (1 attempted) in 491.9ms[39m
{
jq: parse error: Invalid numeric literal at line 1, column 2
FAIL: not parseable

---

if (loggingState.forceConsoleToStderr || level === \"error\" || level === \"fatal\") {
     (sink.error ?? console.error)(redacted);
   } else if (level === \"warn\") {
     (sink.warn ?? console.warn)(redacted);
   } else {
     (sink.log ?? console.log)(redacted);   // debug/info land here when flag is false
   }
RAW_BUFFERClick to expand / collapse

Bug type

Behavior bug (incorrect output/state without crash)

Beta release blocker

No

Summary

For non-routed (commander-attached) --json commands that resolve to a plugin-provided primary (e.g. openclaw memory search --json), the lazy plugin-command-registration phase in runCli loads the primary's plugin before commander's preAction hook calls routeLogsToStderr() — so the plugin loader's [plugins] loading <id> from <path> and [plugins] loaded N plugin(s) ... lines are emitted on stdout in front of the JSON payload and break downstream … | jq / JSON.parse consumers.

Steps to reproduce

On OpenClaw 2026.5.10-beta.1 (40b4ffa) with default memory-core plugin enabled:

openclaw memory search "BRAIN autonomy" --json > /tmp/out.json 2> /tmp/err.log
echo "exit=$?  stdout=$(wc -c </tmp/out.json)b  stderr=$(wc -c </tmp/err.log)b"
head -3 /tmp/out.json
jq -e . </tmp/out.json && echo "OK: parseable" || echo "FAIL: not parseable"

Expected behavior

stdout contains only the JSON payload, parseable by jq -e . without preprocessing. Plugin loader chatter (and every other diagnostic) goes to stderr. This matches the behavior of openclaw status --json, openclaw agents list --json, and every other routed --json command — and matches the design intent stated in src/logging/console.ts:102-103 ("This keeps stdout clean for RPC/JSON modes") and :264 ("In --json mode, all console.* writes are diagnostics and should stay off stdout").

Actual behavior

stdout begins with two ANSI-escaped [plugins] ... lines, then the JSON. jq -e . exits non-zero on the polluted input. Captured locally:

exit=0  stdout=78b  stderr=...
[35m[plugins][39m [90mloading memory-core from /home/eric/.npm-global/lib/node_modules/openclaw/dist/extensions/memory-core/index.js[39m
[35m[plugins][39m [90mloaded 1 plugin(s) (1 attempted) in 491.9ms[39m
{
jq: parse error: Invalid numeric literal at line 1, column 2
FAIL: not parseable

The remaining plugin-loader chatter for other plugins (amazon-bedrock, deepinfra, google, …, voyage) goes to stderr as expected — only the early-load "loading the primary command's plugin" lines escape to stdout, because they happen before forceConsoleToStderr is flipped.

OpenClaw version

2026.5.10-beta.1 (40b4ffa). Same source pattern present on main (93233b2) — the relevant files in src/cli/run-main.ts, src/logging/subsystem.ts, and src/logging/console.ts are byte-identical between 40b4ffa and upstream/main, so the bug is current on main.

Operating system

Ubuntu 24.04 ARM64 (Raspberry Pi 5, 8GB).

Install method

npm global (npm install -g openclaw).

Model

N/A — bug is in CLI bootstrap ordering; no model is invoked on the affected code path.

Provider / routing chain

N/A — bug is in CLI bootstrap ordering; no model/provider is invoked on the affected code path.

Logs, screenshots, and evidence

Root cause (verified by source inspection on 40b4ffa and upstream/main):

  1. Emitter. src/logging/subsystem.ts:286-305's writeConsoleLine deliberately bypasses the patched console (to avoid recursion) and writes through loggingState.rawConsole. For debug/info levels it goes to sink.log — raw stdout — unless loggingState.forceConsoleToStderr is set:

    if (loggingState.forceConsoleToStderr || level === \"error\" || level === \"fatal\") {
      (sink.error ?? console.error)(redacted);
    } else if (level === \"warn\") {
      (sink.warn ?? console.warn)(redacted);
    } else {
      (sink.log ?? console.log)(redacted);   // debug/info land here when flag is false
    }

    The plugin loader emits [plugins] loading <id> from <path> and [plugins] loaded N plugin(s) ... via logger.debug?.(...) at src/plugins/loader.ts:2054 and :2412; the logger is createSubsystemLogger from src/logging/subsystem.ts.

  2. Early-load ordering. src/cli/run-main.ts runCli calls enableConsoleCapture() at line 642, then registerPluginCliCommandsFromValidatedConfig at lines 725-732 — which loads the primary's plugin so commander knows about its subcommands. The commander preAction hook (src/cli/program/preaction.ts:85-94) is what flips forceConsoleToStderr for --json via applyCliExecutionStartupPresentation, but it doesn't fire until program.parseAsync() (run-main.ts:765) — strictly after step 2.

  3. Scaffolding already exists for the routed path. src/cli/route.ts:19 (hasFlag(params.argv, \"--json\")) and prepareRoutedCommand already route logs to stderr before the routed flow loads any plugin via ensureCliPluginRegistryLoaded. The routed code path (e.g. status --json) works correctly today. Only the non-routed (commander) flow is affected.

So the bug is: non-routed --json commands inherit forceConsoleToStderr === false during the early lazy-registration plugin load, and the loader's debug lines land on stdout.

Impact and severity

Affected: every non-routed --json CLI command that resolves to a plugin-provided primary. Most visibly the openclaw memory <subcommand> --json family (search, status, promote, rem-harness, rem-backfill, promote-explain — all declared together in extensions/memory-core/src/cli.ts). Any other plugin-registered command that supports --json follows the same path.

Severity: breaks the standard cmd --json > out.json workflow that downstream tooling, shell pipelines, and automation depend on. Scripts have to redirect stderr→stdout and strip an N-line prefix discovered by inspection (tail -n +N), where N depends on whichever plugins happen to load this run.

Frequency: 100% reproducible on every fresh invocation of an affected --json command.

Consequence: silent JSON-parse failures downstream. Same automation-breaker class as #81183 (sibling symmetric bug), just in the opposite direction.

Additional information

Cross-references — sibling symmetric bug (the JSON-payload-on-stderr direction) just closed today:

  • Issue #81183 ([Bug]: openclaw commitments [list|dismiss] --json produces empty stdout — all JSON output goes to stderr) — closed 2026-05-13. Same bug class, opposite direction: per-command code used runtime.log(JSON.stringify(...)), which the forceConsoleToStderr redirect caught, sending the JSON payload to stderr.
  • PR #81215 (fix(commitments): write json output to stdout) — merged 2026-05-13. Fixed by routing the commitments JSON branches through writeRuntimeJson(runtime, value) so they bypass the console patch.

Cross-reference — prior maintainer acknowledgment of this exact loader-chatter-on-stdout symptom:

  • Commit 05ba1335d93 by @steipete, 2026-04-21, fix: tolerate qa cli json startup logs. Adds a parseQaCliJsonOutput helper in extensions/qa-lab/src/suite-runtime-agent-process.ts that strips ANSI, attempts JSON.parse(whole), and on failure scans line-by-line for the first {/[. Inline comment: // Some startup repair logs are emitted on stdout before command JSON. The accompanying test fixture in suite-runtime-agent-process.test.ts:198 explicitly captures '\\u001b[35m[plugins]\\u001b[39m \\u001b[36mcodex loaded plugin package metadata\\u001b[39m\\n{\"results\":...}\\n' as expected stdout — i.e., the qa harness was made to tolerate the chatter rather than the underlying routing being fixed. This issue addresses the underlying routing.

Workaround until fixed: strip the chatter prefix manually, e.g. openclaw memory search 'q' --json 2>/dev/null | grep -v '^\\[35m\\[plugins\\]' | jq . — fragile because the chatter line count varies by plugin configuration.

Proposed fix: a separate PR fixing this with a ~8-line change in src/cli/run-main.ts (gate the existing routeLogsToStderr() on hasJsonOutputFlag(normalizedArgv) immediately after enableConsoleCapture(), mirroring what prepareRoutedCommand already does for routed commands) is being filed as a follow-up to this issue.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

FAQ

Expected behavior

stdout contains only the JSON payload, parseable by jq -e . without preprocessing. Plugin loader chatter (and every other diagnostic) goes to stderr. This matches the behavior of openclaw status --json, openclaw agents list --json, and every other routed --json command — and matches the design intent stated in src/logging/console.ts:102-103 ("This keeps stdout clean for RPC/JSON modes") and :264 ("In --json mode, all console.* writes are diagnostics and should stay off stdout").

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING