From 38da2d076c8a5bbc4249bdb14468336e8d19f035 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 1 Mar 2026 14:23:46 -0800 Subject: [PATCH] CLI: add root --help fast path and lazy channel option resolution (#30975) * CLI argv: add strict root help invocation guard * Entry: add root help fast-path bootstrap bypass * CLI context: lazily resolve channel options * CLI context tests: cover lazy channel option resolution * CLI argv tests: cover root help invocation detection * Changelog: note additional startup path optimizations * Changelog: split startup follow-up into #30975 entry * CLI channel options: load precomputed startup metadata * CLI channel options tests: cover precomputed metadata path * Build: generate CLI startup metadata during build * Build script: invoke CLI startup metadata generator * CLI routes: preload plugins for routed health * CLI routes tests: assert health plugin preload * CLI: add experimental bundled entry and snapshot helper * Tools: compare CLI startup entries in benchmark script * Docs: add startup tuning notes for Pi and VM hosts * CLI: drop bundled entry runtime toggle * Build: remove bundled and snapshot scripts * Tools: remove bundled-entry benchmark shortcut * Docs: remove bundled startup bench examples * Docs: remove Pi bundled entry mention * Docs: remove VM bundled entry mention * Changelog: remove bundled startup follow-up claims * Build: remove snapshot helper script * Build: remove CLI bundle tsdown config * Doctor: add low-power startup optimization hints * Doctor: run startup optimization hint checks * Doctor tests: cover startup optimization host targeting * Doctor tests: mock startup optimization note export * CLI argv: require strict root-only help fast path * CLI argv tests: cover mixed root-help invocations * CLI channel options: merge metadata with runtime catalog * CLI channel options tests: assert dynamic catalog merge * Changelog: align #30975 startup follow-up scope * Docs tests: remove secondary-entry startup bench note * Docs Pi: add systemd recovery reference link * Docs VPS: add systemd recovery reference link --- CHANGELOG.md | 1 + docs/platforms/raspberry-pi.md | 31 ++++++ docs/vps.md | 31 ++++++ package.json | 2 +- scripts/bench-cli-startup.ts | 75 +++++++++++--- scripts/write-cli-startup-metadata.ts | 93 ++++++++++++++++++ src/cli/argv.test.ts | 46 +++++++++ src/cli/argv.ts | 34 +++++++ src/cli/channel-options.test.ts | 98 +++++++++++++++++++ src/cli/channel-options.ts | 39 +++++++- src/cli/program/context.test.ts | 36 +++++-- src/cli/program/context.ts | 21 +++- src/cli/program/routes.test.ts | 4 +- src/cli/program/routes.ts | 3 + ...latform-notes.startup-optimization.test.ts | 81 +++++++++++++++ src/commands/doctor-platform-notes.ts | 78 +++++++++++++++ src/commands/doctor.fast-path-mocks.ts | 1 + src/commands/doctor.ts | 2 + src/entry.ts | 22 ++++- 19 files changed, 667 insertions(+), 31 deletions(-) create mode 100644 scripts/write-cli-startup-metadata.ts create mode 100644 src/cli/channel-options.test.ts create mode 100644 src/commands/doctor-platform-notes.startup-optimization.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index be19b55d863..1d896f08d55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -90,6 +90,7 @@ Docs: https://docs.openclaw.ai - ACP/Harness thread spawn routing: force ACP harness thread creation through `sessions_spawn` (`runtime: "acp"`, `thread: true`) and explicitly forbid `message action=thread-create` for ACP harness requests, avoiding misrouted `Unknown channel` errors. (#30957) Thanks @dutifulbob. - CLI/Startup (Raspberry Pi + small hosts): speed up startup by avoiding unnecessary plugin preload on fast routes, adding root `--version` fast-path bootstrap bypass, parallelizing status JSON/non-JSON scans where safe, and enabling Node compile cache at startup with env override compatibility (`NODE_COMPILE_CACHE`, `NODE_DISABLE_COMPILE_CACHE`). (#5871) Thanks @BookCatKid and @vincentkoc for raising startup reports, and @lupuletic for related startup work in #27973. +- CLI/Startup follow-up: add root `--help` fast-path bootstrap bypass with strict root-only matching, lazily resolve CLI channel options only when commands need them, merge build-time startup metadata (`dist/cli-startup-metadata.json`) with runtime catalog discovery so dynamic catalogs are preserved, and add low-power Linux doctor hints for compile-cache placement and respawn tuning. (#30975) Thanks @vincentkoc. - Telegram/Outbound API proxy env: keep the Node 22 `autoSelectFamily` global-dispatcher workaround while restoring env-proxy support by using `EnvHttpProxyAgent` so `HTTP_PROXY`/`HTTPS_PROXY` continue to apply to outbound requests. (#26207) Thanks @qsysbio-cjw for reporting and @rylena and @vincentkoc for work. - Browser/Security: fail closed on browser-control auth bootstrap errors; if auto-auth setup fails and no explicit token/password exists, browser control server startup now aborts instead of starting unauthenticated. This ships in the next npm release. Thanks @ijxpwastaken. - Docs/Slack manifest scopes: add missing DM/group-DM bot scopes (`im:read`, `im:write`, `mpim:read`, `mpim:write`) to the Slack app manifest example so DM setup guidance is complete. (#29999) Thanks @JcMinarro. diff --git a/docs/platforms/raspberry-pi.md b/docs/platforms/raspberry-pi.md index 9ad54fd835f..79c9c34fd0d 100644 --- a/docs/platforms/raspberry-pi.md +++ b/docs/platforms/raspberry-pi.md @@ -212,6 +212,37 @@ Notes: - `OPENCLAW_NO_RESPAWN=1` avoids extra startup cost from CLI self-respawn. - First run warms the cache; later runs benefit most. +### systemd startup tuning (optional) + +If this Pi is mostly running OpenClaw, add a service drop-in to reduce restart +jitter and keep startup env stable: + +```bash +sudo systemctl edit openclaw +``` + +```ini +[Service] +Environment=OPENCLAW_NO_RESPAWN=1 +Environment=NODE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache +Restart=always +RestartSec=2 +TimeoutStartSec=90 +``` + +Then apply: + +```bash +sudo systemctl daemon-reload +sudo systemctl restart openclaw +``` + +If possible, keep OpenClaw state/cache on SSD-backed storage to avoid SD-card +random-I/O bottlenecks during cold starts. + +How `Restart=` policies help automated recovery: +[systemd can automate service recovery](https://www.redhat.com/en/blog/systemd-automate-recovery). + ### Reduce Memory Usage ```bash diff --git a/docs/vps.md b/docs/vps.md index a378fa2272a..66c2fdaf93f 100644 --- a/docs/vps.md +++ b/docs/vps.md @@ -69,3 +69,34 @@ source ~/.bashrc - `OPENCLAW_NO_RESPAWN=1` avoids extra startup overhead from a self-respawn path. - First command run warms cache; subsequent runs are faster. - For Raspberry Pi specifics, see [Raspberry Pi](/platforms/raspberry-pi). + +### systemd tuning checklist (optional) + +For VM hosts using `systemd`, consider: + +- Add service env for stable startup path: + - `OPENCLAW_NO_RESPAWN=1` + - `NODE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache` +- Keep restart behavior explicit: + - `Restart=always` + - `RestartSec=2` + - `TimeoutStartSec=90` +- Prefer SSD-backed disks for state/cache paths to reduce random-I/O cold-start penalties. + +Example: + +```bash +sudo systemctl edit openclaw +``` + +```ini +[Service] +Environment=OPENCLAW_NO_RESPAWN=1 +Environment=NODE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache +Restart=always +RestartSec=2 +TimeoutStartSec=90 +``` + +How `Restart=` policies help automated recovery: +[systemd can automate service recovery](https://www.redhat.com/en/blog/systemd-automate-recovery). diff --git a/package.json b/package.json index aaaaebf9068..1f72bd29a08 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "android:run": "cd apps/android && ./gradlew :app:installDebug && adb shell am start -n ai.openclaw.android/.MainActivity", "android:test": "cd apps/android && ./gradlew :app:testDebugUnitTest", "android:test:integration": "OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_ANDROID_NODE=1 vitest run --config vitest.live.config.ts src/gateway/android-node.capabilities.live.test.ts", - "build": "pnpm canvas:a2ui:bundle && tsdown && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-compat.ts", + "build": "pnpm canvas:a2ui:bundle && tsdown && pnpm build:plugin-sdk:dts && node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts", "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", "check": "pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope && pnpm check:host-env-policy:swift", diff --git a/scripts/bench-cli-startup.ts b/scripts/bench-cli-startup.ts index f8a0ac938c1..03615529576 100644 --- a/scripts/bench-cli-startup.ts +++ b/scripts/bench-cli-startup.ts @@ -11,6 +11,8 @@ type Sample = { signal: NodeJS.Signals | null; }; +type CaseSummary = ReturnType; + const DEFAULT_RUNS = 8; const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_ENTRY = "dist/entry.js"; @@ -124,30 +126,75 @@ function collectExitSummary(samples: Sample[]): string { return [...buckets.entries()].map(([key, count]) => `${key}x${count}`).join(", "); } -async function main(): Promise { - const entry = parseFlagValue("--entry") ?? DEFAULT_ENTRY; - const runs = parsePositiveInt(parseFlagValue("--runs"), DEFAULT_RUNS); - const timeoutMs = parsePositiveInt(parseFlagValue("--timeout-ms"), DEFAULT_TIMEOUT_MS); - - console.log(`Node: ${process.version}`); - console.log(`Entry: ${entry}`); - console.log(`Runs per command: ${runs}`); - console.log(`Timeout: ${timeoutMs}ms`); - console.log(""); - +function printSuite(params: { + title: string; + entry: string; + runs: number; + timeoutMs: number; +}): Map { + console.log(params.title); + console.log(`Entry: ${params.entry}`); + const suite = new Map(); for (const commandCase of DEFAULT_CASES) { const samples = runCase({ - entry, + entry: params.entry, runCase: commandCase, - runs, - timeoutMs, + runs: params.runs, + timeoutMs: params.timeoutMs, }); const stats = summarize(samples); const exitSummary = collectExitSummary(samples); + suite.set(commandCase.name, stats); console.log( `${commandCase.name.padEnd(13)} avg=${formatMs(stats.avg)} p50=${formatMs(stats.p50)} p95=${formatMs(stats.p95)} min=${formatMs(stats.min)} max=${formatMs(stats.max)} exits=[${exitSummary}]`, ); } + console.log(""); + return suite; +} + +async function main(): Promise { + const entryPrimary = + parseFlagValue("--entry-primary") ?? parseFlagValue("--entry") ?? DEFAULT_ENTRY; + const entrySecondary = parseFlagValue("--entry-secondary"); + const runs = parsePositiveInt(parseFlagValue("--runs"), DEFAULT_RUNS); + const timeoutMs = parsePositiveInt(parseFlagValue("--timeout-ms"), DEFAULT_TIMEOUT_MS); + + console.log(`Node: ${process.version}`); + console.log(`Runs per command: ${runs}`); + console.log(`Timeout: ${timeoutMs}ms`); + console.log(""); + + const primaryResults = printSuite({ + title: "Primary entry", + entry: entryPrimary, + runs, + timeoutMs, + }); + + if (entrySecondary) { + const secondaryResults = printSuite({ + title: "Secondary entry", + entry: entrySecondary, + runs, + timeoutMs, + }); + + console.log("Delta (secondary - primary, avg)"); + for (const commandCase of DEFAULT_CASES) { + const primary = primaryResults.get(commandCase.name); + const secondary = secondaryResults.get(commandCase.name); + if (!primary || !secondary) { + continue; + } + const delta = secondary.avg - primary.avg; + const pct = primary.avg > 0 ? (delta / primary.avg) * 100 : 0; + const sign = delta > 0 ? "+" : ""; + console.log( + `${commandCase.name.padEnd(13)} ${sign}${formatMs(delta)} (${sign}${pct.toFixed(1)}%)`, + ); + } + } } await main(); diff --git a/scripts/write-cli-startup-metadata.ts b/scripts/write-cli-startup-metadata.ts new file mode 100644 index 00000000000..9f52b0fced3 --- /dev/null +++ b/scripts/write-cli-startup-metadata.ts @@ -0,0 +1,93 @@ +import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +function dedupe(values: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const value of values) { + if (!value || seen.has(value)) { + continue; + } + seen.add(value); + out.push(value); + } + return out; +} + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.resolve(scriptDir, ".."); +const distDir = path.join(rootDir, "dist"); +const outputPath = path.join(distDir, "cli-startup-metadata.json"); +const extensionsDir = path.join(rootDir, "extensions"); +const CORE_CHANNEL_ORDER = [ + "telegram", + "whatsapp", + "discord", + "irc", + "googlechat", + "slack", + "signal", + "imessage", +] as const; + +type ExtensionChannelEntry = { + id: string; + order: number; + label: string; +}; + +function readBundledChannelCatalogIds(): string[] { + const entries: ExtensionChannelEntry[] = []; + for (const dirEntry of readdirSync(extensionsDir, { withFileTypes: true })) { + if (!dirEntry.isDirectory()) { + continue; + } + const packageJsonPath = path.join(extensionsDir, dirEntry.name, "package.json"); + try { + const raw = readFileSync(packageJsonPath, "utf8"); + const parsed = JSON.parse(raw) as { + openclaw?: { + channel?: { + id?: unknown; + order?: unknown; + label?: unknown; + }; + }; + }; + const id = parsed.openclaw?.channel?.id; + if (typeof id !== "string" || !id.trim()) { + continue; + } + const orderRaw = parsed.openclaw?.channel?.order; + const labelRaw = parsed.openclaw?.channel?.label; + entries.push({ + id: id.trim(), + order: typeof orderRaw === "number" ? orderRaw : 999, + label: typeof labelRaw === "string" ? labelRaw : id.trim(), + }); + } catch { + // Ignore malformed or missing extension package manifests. + } + } + return entries + .toSorted((a, b) => (a.order === b.order ? a.label.localeCompare(b.label) : a.order - b.order)) + .map((entry) => entry.id); +} + +const catalog = readBundledChannelCatalogIds(); +const channelOptions = dedupe([...CORE_CHANNEL_ORDER, ...catalog]); + +mkdirSync(distDir, { recursive: true }); +writeFileSync( + outputPath, + `${JSON.stringify( + { + generatedBy: "scripts/write-cli-startup-metadata.ts", + channelOptions, + }, + null, + 2, + )}\n`, + "utf8", +); diff --git a/src/cli/argv.test.ts b/src/cli/argv.test.ts index c46e8e4fa8d..fd7ed71d529 100644 --- a/src/cli/argv.test.ts +++ b/src/cli/argv.test.ts @@ -8,6 +8,7 @@ import { getVerboseFlag, hasHelpOrVersion, hasFlag, + isRootHelpInvocation, isRootVersionInvocation, shouldMigrateState, shouldMigrateStateFromPath, @@ -94,6 +95,51 @@ describe("argv helpers", () => { expect(isRootVersionInvocation(argv)).toBe(expected); }); + it.each([ + { + name: "root --help", + argv: ["node", "openclaw", "--help"], + expected: true, + }, + { + name: "root -h", + argv: ["node", "openclaw", "-h"], + expected: true, + }, + { + name: "root --help with profile", + argv: ["node", "openclaw", "--profile", "work", "--help"], + expected: true, + }, + { + name: "subcommand --help", + argv: ["node", "openclaw", "status", "--help"], + expected: false, + }, + { + name: "help before subcommand token", + argv: ["node", "openclaw", "--help", "status"], + expected: false, + }, + { + name: "help after -- terminator", + argv: ["node", "openclaw", "nodes", "run", "--", "git", "--help"], + expected: false, + }, + { + name: "unknown root flag before help", + argv: ["node", "openclaw", "--unknown", "--help"], + expected: false, + }, + { + name: "unknown root flag after help", + argv: ["node", "openclaw", "--help", "--unknown"], + expected: false, + }, + ])("detects root-only help invocations: $name", ({ argv, expected }) => { + expect(isRootHelpInvocation(argv)).toBe(expected); + }); + it.each([ { name: "single command with trailing flag", diff --git a/src/cli/argv.ts b/src/cli/argv.ts index b8b7cc39aa2..d00cb23a778 100644 --- a/src/cli/argv.ts +++ b/src/cli/argv.ts @@ -119,6 +119,40 @@ export function isRootVersionInvocation(argv: string[]): boolean { return hasVersion; } +export function isRootHelpInvocation(argv: string[]): boolean { + const args = argv.slice(2); + let hasHelp = false; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (!arg) { + continue; + } + if (arg === FLAG_TERMINATOR) { + break; + } + if (HELP_FLAGS.has(arg)) { + hasHelp = true; + continue; + } + if (ROOT_BOOLEAN_FLAGS.has(arg)) { + continue; + } + if (arg.startsWith("--profile=") || arg.startsWith("--log-level=")) { + continue; + } + if (ROOT_VALUE_FLAGS.has(arg)) { + const next = args[i + 1]; + if (isValueToken(next)) { + i += 1; + } + continue; + } + // Unknown flags and subcommand-scoped help should fall back to Commander. + return false; + } + return hasHelp; +} + export function getFlagValue(argv: string[], name: string): string | null | undefined { const args = argv.slice(2); for (let i = 0; i < args.length; i += 1) { diff --git a/src/cli/channel-options.test.ts b/src/cli/channel-options.test.ts new file mode 100644 index 00000000000..2333488050b --- /dev/null +++ b/src/cli/channel-options.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const readFileSyncMock = vi.hoisted(() => vi.fn()); +const listCatalogMock = vi.hoisted(() => vi.fn()); +const listPluginsMock = vi.hoisted(() => vi.fn()); +const ensurePluginRegistryLoadedMock = vi.hoisted(() => vi.fn()); + +vi.mock("node:fs", async () => { + const actual = await vi.importActual("node:fs"); + const base = ("default" in actual ? actual.default : actual) as Record; + return { + ...actual, + default: { + ...base, + readFileSync: readFileSyncMock, + }, + readFileSync: readFileSyncMock, + }; +}); + +vi.mock("../channels/registry.js", () => ({ + CHAT_CHANNEL_ORDER: ["telegram", "discord"], +})); + +vi.mock("../channels/plugins/catalog.js", () => ({ + listChannelPluginCatalogEntries: listCatalogMock, +})); + +vi.mock("../channels/plugins/index.js", () => ({ + listChannelPlugins: listPluginsMock, +})); + +vi.mock("./plugin-registry.js", () => ({ + ensurePluginRegistryLoaded: ensurePluginRegistryLoadedMock, +})); + +async function loadModule() { + return await import("./channel-options.js"); +} + +describe("resolveCliChannelOptions", () => { + afterEach(() => { + delete process.env.OPENCLAW_EAGER_CHANNEL_OPTIONS; + vi.resetModules(); + vi.clearAllMocks(); + }); + + it("uses precomputed startup metadata when available", async () => { + readFileSyncMock.mockReturnValue( + JSON.stringify({ channelOptions: ["cached", "telegram", "cached"] }), + ); + listCatalogMock.mockReturnValue([{ id: "catalog-only" }]); + + const mod = await loadModule(); + expect(mod.resolveCliChannelOptions()).toEqual(["cached", "telegram", "catalog-only"]); + expect(listCatalogMock).toHaveBeenCalledOnce(); + }); + + it("falls back to dynamic catalog resolution when metadata is missing", async () => { + readFileSyncMock.mockImplementation(() => { + throw new Error("ENOENT"); + }); + listCatalogMock.mockReturnValue([{ id: "feishu" }, { id: "telegram" }]); + + const mod = await loadModule(); + expect(mod.resolveCliChannelOptions()).toEqual(["telegram", "discord", "feishu"]); + expect(listCatalogMock).toHaveBeenCalledOnce(); + }); + + it("respects eager mode and includes loaded plugin ids", async () => { + process.env.OPENCLAW_EAGER_CHANNEL_OPTIONS = "1"; + readFileSyncMock.mockReturnValue(JSON.stringify({ channelOptions: ["cached"] })); + listCatalogMock.mockReturnValue([{ id: "zalo" }]); + listPluginsMock.mockReturnValue([{ id: "custom-a" }, { id: "custom-b" }]); + + const mod = await loadModule(); + expect(mod.resolveCliChannelOptions()).toEqual([ + "telegram", + "discord", + "zalo", + "custom-a", + "custom-b", + ]); + expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledOnce(); + expect(listPluginsMock).toHaveBeenCalledOnce(); + }); + + it("keeps dynamic catalog resolution when external catalog env is set", async () => { + process.env.OPENCLAW_PLUGIN_CATALOG_PATHS = "/tmp/plugins-catalog.json"; + readFileSyncMock.mockReturnValue(JSON.stringify({ channelOptions: ["cached", "telegram"] })); + listCatalogMock.mockReturnValue([{ id: "custom-catalog" }]); + + const mod = await loadModule(); + expect(mod.resolveCliChannelOptions()).toEqual(["cached", "telegram", "custom-catalog"]); + expect(listCatalogMock).toHaveBeenCalledOnce(); + delete process.env.OPENCLAW_PLUGIN_CATALOG_PATHS; + }); +}); diff --git a/src/cli/channel-options.ts b/src/cli/channel-options.ts index 357133f1d65..e8562f51516 100644 --- a/src/cli/channel-options.ts +++ b/src/cli/channel-options.ts @@ -1,3 +1,6 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import { listChannelPluginCatalogEntries } from "../channels/plugins/catalog.js"; import { listChannelPlugins } from "../channels/plugins/index.js"; import { CHAT_CHANNEL_ORDER } from "../channels/registry.js"; @@ -17,14 +20,46 @@ function dedupe(values: string[]): string[] { return resolved; } +let precomputedChannelOptions: string[] | null | undefined; + +function loadPrecomputedChannelOptions(): string[] | null { + if (precomputedChannelOptions !== undefined) { + return precomputedChannelOptions; + } + try { + const metadataPath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "cli-startup-metadata.json", + ); + const raw = fs.readFileSync(metadataPath, "utf8"); + const parsed = JSON.parse(raw) as { channelOptions?: unknown }; + if (Array.isArray(parsed.channelOptions)) { + precomputedChannelOptions = dedupe( + parsed.channelOptions.filter((value): value is string => typeof value === "string"), + ); + return precomputedChannelOptions; + } + } catch { + // Fall back to dynamic catalog resolution. + } + precomputedChannelOptions = null; + return null; +} + export function resolveCliChannelOptions(): string[] { - const catalog = listChannelPluginCatalogEntries().map((entry) => entry.id); - const base = dedupe([...CHAT_CHANNEL_ORDER, ...catalog]); if (isTruthyEnvValue(process.env.OPENCLAW_EAGER_CHANNEL_OPTIONS)) { + const catalog = listChannelPluginCatalogEntries().map((entry) => entry.id); + const base = dedupe([...CHAT_CHANNEL_ORDER, ...catalog]); ensurePluginRegistryLoaded(); const pluginIds = listChannelPlugins().map((plugin) => plugin.id); return dedupe([...base, ...pluginIds]); } + const precomputed = loadPrecomputedChannelOptions(); + const catalog = listChannelPluginCatalogEntries().map((entry) => entry.id); + const base = precomputed + ? dedupe([...precomputed, ...catalog]) + : dedupe([...CHAT_CHANNEL_ORDER, ...catalog]); return base; } diff --git a/src/cli/program/context.test.ts b/src/cli/program/context.test.ts index 18fc90deba7..1fd90f844f9 100644 --- a/src/cli/program/context.test.ts +++ b/src/cli/program/context.test.ts @@ -14,24 +14,48 @@ const { createProgramContext } = await import("./context.js"); describe("createProgramContext", () => { it("builds program context from version and resolved channel options", () => { - resolveCliChannelOptionsMock.mockReturnValue(["telegram", "whatsapp"]); - - expect(createProgramContext()).toEqual({ + resolveCliChannelOptionsMock.mockClear().mockReturnValue(["telegram", "whatsapp"]); + const ctx = createProgramContext(); + expect(ctx).toEqual({ programVersion: "9.9.9-test", channelOptions: ["telegram", "whatsapp"], messageChannelOptions: "telegram|whatsapp", agentChannelOptions: "last|telegram|whatsapp", }); + expect(resolveCliChannelOptionsMock).toHaveBeenCalledOnce(); }); it("handles empty channel options", () => { - resolveCliChannelOptionsMock.mockReturnValue([]); - - expect(createProgramContext()).toEqual({ + resolveCliChannelOptionsMock.mockClear().mockReturnValue([]); + const ctx = createProgramContext(); + expect(ctx).toEqual({ programVersion: "9.9.9-test", channelOptions: [], messageChannelOptions: "", agentChannelOptions: "last", }); + expect(resolveCliChannelOptionsMock).toHaveBeenCalledOnce(); + }); + + it("does not resolve channel options before access", () => { + resolveCliChannelOptionsMock.mockClear(); + createProgramContext(); + expect(resolveCliChannelOptionsMock).not.toHaveBeenCalled(); + }); + + it("reuses one channel option resolution across all getters", () => { + resolveCliChannelOptionsMock.mockClear().mockReturnValue(["telegram"]); + const ctx = createProgramContext(); + expect(ctx.channelOptions).toEqual(["telegram"]); + expect(ctx.messageChannelOptions).toBe("telegram"); + expect(ctx.agentChannelOptions).toBe("last|telegram"); + expect(resolveCliChannelOptionsMock).toHaveBeenCalledOnce(); + }); + + it("reads program version without resolving channel options", () => { + resolveCliChannelOptionsMock.mockClear(); + const ctx = createProgramContext(); + expect(ctx.programVersion).toBe("9.9.9-test"); + expect(resolveCliChannelOptionsMock).not.toHaveBeenCalled(); }); }); diff --git a/src/cli/program/context.ts b/src/cli/program/context.ts index dc38eb41f4d..9518d857a10 100644 --- a/src/cli/program/context.ts +++ b/src/cli/program/context.ts @@ -9,11 +9,24 @@ export type ProgramContext = { }; export function createProgramContext(): ProgramContext { - const channelOptions = resolveCliChannelOptions(); + let cachedChannelOptions: string[] | undefined; + const getChannelOptions = (): string[] => { + if (cachedChannelOptions === undefined) { + cachedChannelOptions = resolveCliChannelOptions(); + } + return cachedChannelOptions; + }; + return { programVersion: VERSION, - channelOptions, - messageChannelOptions: channelOptions.join("|"), - agentChannelOptions: ["last", ...channelOptions].join("|"), + get channelOptions() { + return getChannelOptions(); + }, + get messageChannelOptions() { + return getChannelOptions().join("|"); + }, + get agentChannelOptions() { + return ["last", ...getChannelOptions()].join("|"); + }, }; } diff --git a/src/cli/program/routes.test.ts b/src/cli/program/routes.test.ts index 5a94548aec2..f9932bc9377 100644 --- a/src/cli/program/routes.test.ts +++ b/src/cli/program/routes.test.ts @@ -18,9 +18,9 @@ describe("program routes", () => { expect(route?.loadPlugins).toBe(true); }); - it("matches health route without eager plugin loading", () => { + it("matches health route and preloads plugins for channel diagnostics", () => { const route = expectRoute(["health"]); - expect(route?.loadPlugins).toBeUndefined(); + expect(route?.loadPlugins).toBe(true); }); it("returns false when status timeout flag value is missing", async () => { diff --git a/src/cli/program/routes.ts b/src/cli/program/routes.ts index 3ab0da290b4..9bc18162a22 100644 --- a/src/cli/program/routes.ts +++ b/src/cli/program/routes.ts @@ -9,6 +9,9 @@ export type RouteSpec = { const routeHealth: RouteSpec = { match: (path) => path[0] === "health", + // Health output uses channel plugin metadata for account fallback/log details. + // Keep routed behavior aligned with non-routed command execution. + loadPlugins: true, run: async (argv) => { const json = hasFlag(argv, "--json"); const verbose = getVerboseFlag(argv, { includeDebug: true }); diff --git a/src/commands/doctor-platform-notes.startup-optimization.test.ts b/src/commands/doctor-platform-notes.startup-optimization.test.ts new file mode 100644 index 00000000000..e61888efbee --- /dev/null +++ b/src/commands/doctor-platform-notes.startup-optimization.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from "vitest"; +import { noteStartupOptimizationHints } from "./doctor-platform-notes.js"; + +describe("noteStartupOptimizationHints", () => { + it("does not warn when compile cache and no-respawn are configured", () => { + const noteFn = vi.fn(); + + noteStartupOptimizationHints( + { + NODE_COMPILE_CACHE: "/var/tmp/openclaw-compile-cache", + OPENCLAW_NO_RESPAWN: "1", + }, + { platform: "linux", arch: "arm64", totalMemBytes: 4 * 1024 ** 3, noteFn }, + ); + + expect(noteFn).not.toHaveBeenCalled(); + }); + + it("warns when compile cache is under /tmp and no-respawn is not set", () => { + const noteFn = vi.fn(); + + noteStartupOptimizationHints( + { + NODE_COMPILE_CACHE: "/tmp/openclaw-compile-cache", + }, + { platform: "linux", arch: "arm64", totalMemBytes: 4 * 1024 ** 3, noteFn }, + ); + + expect(noteFn).toHaveBeenCalledTimes(1); + const [message, title] = noteFn.mock.calls[0] ?? []; + expect(title).toBe("Startup optimization"); + expect(message).toContain("NODE_COMPILE_CACHE points to /tmp"); + expect(message).toContain("OPENCLAW_NO_RESPAWN is not set to 1"); + expect(message).toContain("export NODE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache"); + expect(message).toContain("export OPENCLAW_NO_RESPAWN=1"); + }); + + it("warns when compile cache is disabled via env override", () => { + const noteFn = vi.fn(); + + noteStartupOptimizationHints( + { + NODE_COMPILE_CACHE: "/var/tmp/openclaw-compile-cache", + OPENCLAW_NO_RESPAWN: "1", + NODE_DISABLE_COMPILE_CACHE: "1", + }, + { platform: "linux", arch: "arm64", totalMemBytes: 4 * 1024 ** 3, noteFn }, + ); + + expect(noteFn).toHaveBeenCalledTimes(1); + const [message] = noteFn.mock.calls[0] ?? []; + expect(message).toContain("NODE_DISABLE_COMPILE_CACHE is set"); + expect(message).toContain("unset NODE_DISABLE_COMPILE_CACHE"); + }); + + it("skips startup optimization note on win32", () => { + const noteFn = vi.fn(); + + noteStartupOptimizationHints( + { + NODE_COMPILE_CACHE: "/tmp/openclaw-compile-cache", + }, + { platform: "win32", arch: "arm64", totalMemBytes: 4 * 1024 ** 3, noteFn }, + ); + + expect(noteFn).not.toHaveBeenCalled(); + }); + + it("skips startup optimization note on non-target linux hosts", () => { + const noteFn = vi.fn(); + + noteStartupOptimizationHints( + { + NODE_COMPILE_CACHE: "/tmp/openclaw-compile-cache", + }, + { platform: "linux", arch: "x64", totalMemBytes: 32 * 1024 ** 3, noteFn }, + ); + + expect(noteFn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/doctor-platform-notes.ts b/src/commands/doctor-platform-notes.ts index ebe1a93e2bd..f3b5c04b2cc 100644 --- a/src/commands/doctor-platform-notes.ts +++ b/src/commands/doctor-platform-notes.ts @@ -140,3 +140,81 @@ export function noteDeprecatedLegacyEnvVars( ]; (deps?.noteFn ?? note)(lines.join("\n"), "Environment"); } + +function isTruthyEnvValue(value: string | undefined): boolean { + return typeof value === "string" && value.trim().length > 0; +} + +function isTmpCompileCachePath(cachePath: string): boolean { + const normalized = cachePath.trim().replace(/\/+$/, ""); + return ( + normalized === "/tmp" || + normalized.startsWith("/tmp/") || + normalized === "/private/tmp" || + normalized.startsWith("/private/tmp/") + ); +} + +export function noteStartupOptimizationHints( + env: NodeJS.ProcessEnv = process.env, + deps?: { + platform?: NodeJS.Platform; + arch?: string; + totalMemBytes?: number; + noteFn?: typeof note; + }, +) { + const platform = deps?.platform ?? process.platform; + if (platform === "win32") { + return; + } + const arch = deps?.arch ?? os.arch(); + const totalMemBytes = deps?.totalMemBytes ?? os.totalmem(); + const isArmHost = arch === "arm" || arch === "arm64"; + const isLowMemoryLinux = + platform === "linux" && totalMemBytes > 0 && totalMemBytes <= 8 * 1024 ** 3; + const isStartupTuneTarget = platform === "linux" && (isArmHost || isLowMemoryLinux); + if (!isStartupTuneTarget) { + return; + } + + const noteFn = deps?.noteFn ?? note; + const compileCache = env.NODE_COMPILE_CACHE?.trim() ?? ""; + const disableCompileCache = env.NODE_DISABLE_COMPILE_CACHE?.trim() ?? ""; + const noRespawn = env.OPENCLAW_NO_RESPAWN?.trim() ?? ""; + const lines: string[] = []; + + if (!compileCache) { + lines.push( + "- NODE_COMPILE_CACHE is not set; repeated CLI runs can be slower on small hosts (Pi/VM).", + ); + } else if (isTmpCompileCachePath(compileCache)) { + lines.push( + "- NODE_COMPILE_CACHE points to /tmp; use /var/tmp so cache survives reboots and warms startup reliably.", + ); + } + + if (isTruthyEnvValue(disableCompileCache)) { + lines.push("- NODE_DISABLE_COMPILE_CACHE is set; startup compile cache is disabled."); + } + + if (noRespawn !== "1") { + lines.push( + "- OPENCLAW_NO_RESPAWN is not set to 1; set it to avoid extra startup overhead from self-respawn.", + ); + } + + if (lines.length === 0) { + return; + } + + const suggestions = [ + "- Suggested env for low-power hosts:", + " export NODE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache", + " mkdir -p /var/tmp/openclaw-compile-cache", + " export OPENCLAW_NO_RESPAWN=1", + isTruthyEnvValue(disableCompileCache) ? " unset NODE_DISABLE_COMPILE_CACHE" : undefined, + ].filter((line): line is string => Boolean(line)); + + noteFn([...lines, ...suggestions].join("\n"), "Startup optimization"); +} diff --git a/src/commands/doctor.fast-path-mocks.ts b/src/commands/doctor.fast-path-mocks.ts index 87faf4d7c50..33be4c188f3 100644 --- a/src/commands/doctor.fast-path-mocks.ts +++ b/src/commands/doctor.fast-path-mocks.ts @@ -19,6 +19,7 @@ vi.mock("./doctor-memory-search.js", () => ({ vi.mock("./doctor-platform-notes.js", () => ({ noteDeprecatedLegacyEnvVars: vi.fn(), + noteStartupOptimizationHints: vi.fn(), noteMacLaunchAgentOverrides: vi.fn().mockResolvedValue(undefined), noteMacLaunchctlGatewayEnvOverrides: vi.fn().mockResolvedValue(undefined), })); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 4aa0241da19..c6256053022 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -40,6 +40,7 @@ import { noteMacLaunchAgentOverrides, noteMacLaunchctlGatewayEnvOverrides, noteDeprecatedLegacyEnvVars, + noteStartupOptimizationHints, } from "./doctor-platform-notes.js"; import { createDoctorPrompter, type DoctorOptions } from "./doctor-prompter.js"; import { maybeRepairSandboxImages, noteSandboxScopeWarnings } from "./doctor-sandbox.js"; @@ -92,6 +93,7 @@ export async function doctorCommand( await maybeRepairUiProtocolFreshness(runtime, prompter); noteSourceInstallIssues(root); noteDeprecatedLegacyEnvVars(); + noteStartupOptimizationHints(); const configResult = await loadAndMaybeMigrateDoctorConfig({ options, diff --git a/src/entry.ts b/src/entry.ts index de865b36ef4..25f91d62921 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -3,7 +3,7 @@ import { spawn } from "node:child_process"; import { enableCompileCache } from "node:module"; import process from "node:process"; import { fileURLToPath } from "node:url"; -import { isRootVersionInvocation } from "./cli/argv.js"; +import { isRootHelpInvocation, isRootVersionInvocation } from "./cli/argv.js"; import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js"; import { shouldSkipRespawnForArgv } from "./cli/respawn-policy.js"; import { normalizeWindowsArgv } from "./cli/windows-argv.js"; @@ -141,6 +141,24 @@ if ( return true; } + function tryHandleRootHelpFastPath(argv: string[]): boolean { + if (!isRootHelpInvocation(argv)) { + return false; + } + import("./cli/program.js") + .then(({ buildProgram }) => { + buildProgram().outputHelp(); + }) + .catch((error) => { + console.error( + "[openclaw] Failed to display help:", + error instanceof Error ? (error.stack ?? error.message) : error, + ); + process.exitCode = 1; + }); + return true; + } + process.argv = normalizeWindowsArgv(process.argv); if (!ensureExperimentalWarningSuppressed()) { @@ -157,7 +175,7 @@ if ( process.argv = parsed.argv; } - if (!tryHandleRootVersionFastPath(process.argv)) { + if (!tryHandleRootVersionFastPath(process.argv) && !tryHandleRootHelpFastPath(process.argv)) { import("./cli/run-main.js") .then(({ runCli }) => runCli(process.argv)) .catch((error) => {