diff --git a/CHANGELOG.md b/CHANGELOG.md index af5c992a8e2..b5dccd16f0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ Docs: https://docs.openclaw.ai - Discord: keep progress drafts visible for message-tool-only guild replies under the default coding tool profile. Fixes #82747. Thanks @eliranwong. - CLI/setup: order the model/auth provider picker as OpenAI, Anthropic, xAI, Google, then the remaining providers alphabetically. - Diagnostics/usage/voice-call: treat explicit zero and non-finite limits as empty results and reject invalid voice-call numeric CLI flags. Fixes #82646, #82650, #82651, and #82653. (#82679) Thanks @leno23. +- CLI/config: avoid redundant startup config/plugin checks for the guided `openclaw config` flow and show progress while source checkout CLI artifacts build or load. - Gateway/diagnostics: add opt-in critical memory pressure stability snapshots with gateway logs, V8 heap, cgroup, active-resource, and redacted large session-file evidence. Fixes #82518. - Doctor/Gateway: avoid treating unrelated macOS LaunchAgents as legacy gateways just because their environment values mention old checkout paths. - Gateway/heartbeat: defer heartbeat runs while the target reply operation is queued or active, preventing heartbeat prompts from interleaving with WebChat responses before the streaming lane starts. Fixes #82722. Thanks @Andy-Xie-1145. diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs index aac55620c8c..f32bba6b7dd 100644 --- a/scripts/run-node.mjs +++ b/scripts/run-node.mjs @@ -682,10 +682,88 @@ const logRunner = (message, deps) => { return; } const line = `[openclaw] ${message}\n`; + deps.runNodeProgress?.clearLine(); deps.stderr.write(line); + deps.runNodeProgress?.render(); deps.outputTee?.write(line); }; +const RUN_NODE_PROGRESS_FRAMES = ["-", "\\", "|", "/"]; + +const shouldUseRunNodeProgress = (deps) => + deps.stderr?.isTTY === true && + deps.env.OPENCLAW_RUNNER_PROGRESS !== "0" && + deps.env.CI !== "true" && + !deps.outputTee; + +const createRunNodeProgress = (label, deps) => { + if (!shouldUseRunNodeProgress(deps)) { + return null; + } + const startedAt = Date.now(); + let frameIndex = 0; + let active = true; + let visible = false; + + const clearLine = () => { + if (!visible) { + return; + } + deps.stderr.write("\r\x1b[2K"); + visible = false; + }; + const render = () => { + if (!active) { + return; + } + const elapsedSeconds = Math.max(0, Math.round((Date.now() - startedAt) / 1000)); + const frame = RUN_NODE_PROGRESS_FRAMES[frameIndex % RUN_NODE_PROGRESS_FRAMES.length]; + frameIndex += 1; + deps.stderr.write(`\r[openclaw] ${frame} ${label} (${elapsedSeconds}s)`); + visible = true; + }; + const timer = setInterval(render, 120); + timer.unref?.(); + render(); + + return { + clearLine, + render, + stop() { + if (!active) { + return; + } + active = false; + clearInterval(timer); + clearLine(); + }, + }; +}; + +const withRunNodeProgress = async (deps, label, callback) => { + const previousProgress = deps.runNodeProgress; + const progress = createRunNodeProgress(label, deps); + if (progress) { + deps.runNodeProgress = progress; + } + try { + return await callback(); + } finally { + if (progress) { + progress.stop(); + deps.runNodeProgress = previousProgress; + } + } +}; + +const writeRunnerStream = (deps, stream, chunk) => { + deps.runNodeProgress?.clearLine(); + stream.write(chunk); + deps.runNodeProgress?.render(); +}; + +const shouldPipeSpawnedOutput = (deps) => Boolean(deps.outputTee || deps.runNodeProgress); + const sanitizeCpuProfileNamePart = (value) => { const normalized = String(value ?? "") .trim() @@ -810,7 +888,7 @@ const runOpenClaw = async (deps) => { }; const pipeSpawnedOutput = (childProcess, deps) => { - if (!deps.outputTee) { + if (!shouldPipeSpawnedOutput(deps)) { return; } const stderrFilter = @@ -818,16 +896,18 @@ const pipeSpawnedOutput = (childProcess, deps) => { ? createSyncIoTraceStderrFilter(deps) : null; childProcess.stdout?.on("data", (chunk) => { - deps.stdout.write(chunk); - deps.outputTee.write(chunk); + writeRunnerStream(deps, deps.stdout, chunk); + deps.outputTee?.write(chunk); }); childProcess.stderr?.on("data", (chunk) => { + deps.runNodeProgress?.clearLine(); if (stderrFilter) { stderrFilter.write(chunk); } else { deps.stderr.write(chunk); } - deps.outputTee.write(chunk); + deps.runNodeProgress?.render(); + deps.outputTee?.write(chunk); }); childProcess.stderr?.on("end", () => { stderrFilter?.flush(); @@ -1253,36 +1333,45 @@ export async function runNodeMain(params = {}) { ); logRunner("Building bundled plugin assets.", deps); const buildCmd = deps.execPath; - const assetBuild = deps.spawn(buildCmd, bundledPluginAssetBuildArgs, { - cwd: deps.cwd, - env: deps.env, - stdio: deps.outputTee ? ["inherit", "pipe", "pipe"] : "inherit", - }); - pipeSpawnedOutput(assetBuild, deps); - const assetBuildRes = await waitForSpawnedProcess(assetBuild, deps); - const assetBuildInterruptedExitCode = getInterruptedSpawnExitCode(assetBuildRes); - if (assetBuildInterruptedExitCode !== null) { - return assetBuildInterruptedExitCode; - } - if (assetBuildRes.exitCode !== 0 && assetBuildRes.exitCode !== null) { - return assetBuildRes.exitCode; - } + const compileExitCode = await withRunNodeProgress( + deps, + "Building local CLI artifacts", + async () => { + const assetBuild = deps.spawn(buildCmd, bundledPluginAssetBuildArgs, { + cwd: deps.cwd, + env: deps.env, + stdio: shouldPipeSpawnedOutput(deps) ? ["inherit", "pipe", "pipe"] : "inherit", + }); + pipeSpawnedOutput(assetBuild, deps); + const assetBuildRes = await waitForSpawnedProcess(assetBuild, deps); + const assetBuildInterruptedExitCode = getInterruptedSpawnExitCode(assetBuildRes); + if (assetBuildInterruptedExitCode !== null) { + return assetBuildInterruptedExitCode; + } + if (assetBuildRes.exitCode !== 0 && assetBuildRes.exitCode !== null) { + return assetBuildRes.exitCode; + } - const buildArgs = compilerArgs; - const build = deps.spawn(buildCmd, buildArgs, { - cwd: deps.cwd, - env: deps.env, - stdio: deps.outputTee ? ["inherit", "pipe", "pipe"] : "inherit", - }); - pipeSpawnedOutput(build, deps); + const build = deps.spawn(buildCmd, compilerArgs, { + cwd: deps.cwd, + env: deps.env, + stdio: shouldPipeSpawnedOutput(deps) ? ["inherit", "pipe", "pipe"] : "inherit", + }); + pipeSpawnedOutput(build, deps); - const buildRes = await waitForSpawnedProcess(build, deps); - const interruptedExitCode = getInterruptedSpawnExitCode(buildRes); - if (interruptedExitCode !== null) { - return interruptedExitCode; - } - if (buildRes.exitCode !== 0 && buildRes.exitCode !== null) { - return buildRes.exitCode; + const buildRes = await waitForSpawnedProcess(build, deps); + const interruptedExitCode = getInterruptedSpawnExitCode(buildRes); + if (interruptedExitCode !== null) { + return interruptedExitCode; + } + if (buildRes.exitCode !== 0 && buildRes.exitCode !== null) { + return buildRes.exitCode; + } + return 0; + }, + ); + if (compileExitCode !== 0) { + return compileExitCode; } if (!(await syncRuntimeArtifacts(deps))) { return 1; diff --git a/src/cli/command-catalog.ts b/src/cli/command-catalog.ts index f9fd5a26a96..f4d69fe5c2e 100644 --- a/src/cli/command-catalog.ts +++ b/src/cli/command-catalog.ts @@ -105,6 +105,11 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [ policy: { loadPlugins: "never" }, }, { commandPath: ["configure"], policy: { bypassConfigGuard: true, loadPlugins: "never" } }, + { + commandPath: ["config"], + exact: true, + policy: { bypassConfigGuard: true, loadPlugins: "never", networkProxy: "bypass" }, + }, { commandPath: ["migrate"], policy: { bypassConfigGuard: true, loadPlugins: "never", networkProxy: "bypass" }, diff --git a/src/cli/command-path-policy.test.ts b/src/cli/command-path-policy.test.ts index 7cfee401bda..874c8b66fe2 100644 --- a/src/cli/command-path-policy.test.ts +++ b/src/cli/command-path-policy.test.ts @@ -189,6 +189,15 @@ describe("command-path-policy", () => { bypassConfigGuard: true, loadPlugins: "never", }); + expectResolvedPolicy(["config"], { + bypassConfigGuard: true, + loadPlugins: "never", + networkProxy: "bypass", + }); + expectResolvedPolicy(["config", "set"], { + loadPlugins: "never", + networkProxy: "bypass", + }); expectResolvedPolicy(["config", "validate"], { bypassConfigGuard: true, loadPlugins: "never", diff --git a/src/cli/command-startup-policy.test.ts b/src/cli/command-startup-policy.test.ts index 2d22c979e04..c8e5b197c3a 100644 --- a/src/cli/command-startup-policy.test.ts +++ b/src/cli/command-startup-policy.test.ts @@ -11,8 +11,10 @@ import { describe("command-startup-policy", () => { it("matches config guard bypass commands", () => { expect(shouldBypassConfigGuardForCommandPath(["backup", "create"])).toBe(true); + expect(shouldBypassConfigGuardForCommandPath(["config"])).toBe(true); expect(shouldBypassConfigGuardForCommandPath(["config", "validate"])).toBe(true); expect(shouldBypassConfigGuardForCommandPath(["config", "schema"])).toBe(true); + expect(shouldBypassConfigGuardForCommandPath(["config", "set"])).toBe(false); expect(shouldBypassConfigGuardForCommandPath(["status"])).toBe(false); }); diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index ffc8eed7a7e..d997075dbb8 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -168,6 +168,7 @@ describe("registerPreActionHooks", () => { .option("--json") .action(() => {}); const config = program.command("config"); + config.option("--section
"); setCommandJsonMode(config.command("set"), "parse-only") .argument("") .argument("") @@ -313,6 +314,26 @@ describe("registerPreActionHooks", () => { expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled(); }); + it("lets bare config own config validation and plugin loading", async () => { + await runPreAction({ + parseArgv: ["config"], + processArgv: ["node", "openclaw", "config"], + }); + + expect(ensureConfigReadyMock).not.toHaveBeenCalled(); + expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled(); + }); + + it("lets guided config sections own config validation and plugin loading", async () => { + await runPreAction({ + parseArgv: ["config"], + processArgv: ["node", "openclaw", "config", "--section", "models"], + }); + + expect(ensureConfigReadyMock).not.toHaveBeenCalled(); + expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled(); + }); + it("only allows invalid config for explicit official recovery reinstall requests", async () => { await runPreAction({ parseArgv: ["plugins", "install", "@openclaw/discord"], diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index 89161ca042b..ba00d0aac62 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -75,6 +75,10 @@ function isBareParentDefaultHelpInvocation(actionCommand: Command, argv: string[ return primary === actionCommand.name() || actionCommand.aliases().includes(primary); } +function isGuidedConfigAction(actionCommand: Command): boolean { + return actionCommand.name() === "config" && actionCommand.parent?.parent === undefined; +} + export function registerPreActionHooks(program: Command, programVersion: string) { program.hook("preAction", async (_thisCommand, actionCommand) => { setProcessTitleForCommand(actionCommand); @@ -101,7 +105,7 @@ export function registerPreActionHooks(program: Command, programVersion: string) if (!verbose) { process.env.NODE_NO_WARNINGS ??= "1"; } - if (shouldBypassConfigGuardForCommandPath(commandPath)) { + if (shouldBypassConfigGuardForCommandPath(commandPath) || isGuidedConfigAction(actionCommand)) { return; } await ensureCliExecutionBootstrap({ diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index 02997db1ae9..34f70067e10 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -292,6 +292,24 @@ describe("runCli exit behavior", () => { expect(disposeRegisteredAgentHarnessesMock).toHaveBeenCalledTimes(1); }); + it("shows the standard spinner while loading the full CLI", async () => { + tryRouteCliMock.mockResolvedValueOnce(false); + const parseAsync = vi.fn().mockResolvedValueOnce(undefined); + buildProgramMock.mockReturnValueOnce({ + commands: [{ name: () => "config", aliases: () => [] }], + parseAsync, + }); + + await runCli(["node", "openclaw", "config"]); + + expect(createCliProgressMock).toHaveBeenCalledWith({ + label: "Loading OpenClaw CLI…", + indeterminate: true, + delayMs: 0, + }); + expect(progressDoneMock).toHaveBeenCalledTimes(1); + }); + it("pauses non-tty stdin after full CLI command completion", async () => { tryRouteCliMock.mockResolvedValueOnce(false); const parseAsync = vi.fn().mockResolvedValueOnce(undefined); diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index ec3082b8934..982bc7db52d 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -633,7 +633,6 @@ export async function runCli(argv: string[] = process.argv) { label: "Loading OpenClaw CLI…", indeterminate: true, delayMs: 0, - fallback: "none", }); let startupProgressStopped = false; const stopStartupProgress = () => { diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index 194a82e6bcc..c197e69667f 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -1397,6 +1397,48 @@ describe("run-node script", () => { }); }); + it("shows tty progress while rebuilding source-checkout artifacts", async () => { + await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { + await setupTrackedProject(tmp, { + oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE], + buildPaths: [DIST_ENTRY, BUILD_STAMP], + }); + const { spawn, spawnSync } = createSpawnRecorder(); + const stderrChunks: string[] = []; + const stderr = { + isTTY: true, + write: vi.fn((chunk: string) => { + stderrChunks.push(String(chunk)); + return true; + }), + } as unknown as NodeJS.WriteStream; + const stdout = { + write: vi.fn(() => true), + } as unknown as NodeJS.WriteStream; + + const exitCode = await runNodeMain({ + cwd: tmp, + args: ["status"], + env: { + ...process.env, + OPENCLAW_FORCE_BUILD: "1", + }, + spawn, + spawnSync, + stderr, + stdout, + runRuntimePostBuild: async () => {}, + execPath: process.execPath, + platform: process.platform, + }); + + expect(exitCode).toBe(0); + const stderrText = stderrChunks.join(""); + expect(stderrText).toContain("Building local CLI artifacts"); + expect(stderrText).toContain("\x1b[2K"); + }); + }); + it("rebuilds when git HEAD changes even if source mtimes do not exceed the old build stamp", async () => { await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { await setupTrackedProject(tmp, {