Skip to content

Commit 38da2d0

Browse files
authored
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
1 parent dcd19da commit 38da2d0

19 files changed

Lines changed: 664 additions & 28 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ Docs: https://docs.openclaw.ai
9090

9191
- 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.
9292
- 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.
93+
- 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.
9394
- 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.
9495
- 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.
9596
- 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.

docs/platforms/raspberry-pi.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,37 @@ Notes:
212212
- `OPENCLAW_NO_RESPAWN=1` avoids extra startup cost from CLI self-respawn.
213213
- First run warms the cache; later runs benefit most.
214214

215+
### systemd startup tuning (optional)
216+
217+
If this Pi is mostly running OpenClaw, add a service drop-in to reduce restart
218+
jitter and keep startup env stable:
219+
220+
```bash
221+
sudo systemctl edit openclaw
222+
```
223+
224+
```ini
225+
[Service]
226+
Environment=OPENCLAW_NO_RESPAWN=1
227+
Environment=NODE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache
228+
Restart=always
229+
RestartSec=2
230+
TimeoutStartSec=90
231+
```
232+
233+
Then apply:
234+
235+
```bash
236+
sudo systemctl daemon-reload
237+
sudo systemctl restart openclaw
238+
```
239+
240+
If possible, keep OpenClaw state/cache on SSD-backed storage to avoid SD-card
241+
random-I/O bottlenecks during cold starts.
242+
243+
How `Restart=` policies help automated recovery:
244+
[systemd can automate service recovery](https://www.redhat.com/en/blog/systemd-automate-recovery).
245+
215246
### Reduce Memory Usage
216247

217248
```bash

docs/vps.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,34 @@ source ~/.bashrc
6969
- `OPENCLAW_NO_RESPAWN=1` avoids extra startup overhead from a self-respawn path.
7070
- First command run warms cache; subsequent runs are faster.
7171
- For Raspberry Pi specifics, see [Raspberry Pi](/platforms/raspberry-pi).
72+
73+
### systemd tuning checklist (optional)
74+
75+
For VM hosts using `systemd`, consider:
76+
77+
- Add service env for stable startup path:
78+
- `OPENCLAW_NO_RESPAWN=1`
79+
- `NODE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache`
80+
- Keep restart behavior explicit:
81+
- `Restart=always`
82+
- `RestartSec=2`
83+
- `TimeoutStartSec=90`
84+
- Prefer SSD-backed disks for state/cache paths to reduce random-I/O cold-start penalties.
85+
86+
Example:
87+
88+
```bash
89+
sudo systemctl edit openclaw
90+
```
91+
92+
```ini
93+
[Service]
94+
Environment=OPENCLAW_NO_RESPAWN=1
95+
Environment=NODE_COMPILE_CACHE=/var/tmp/openclaw-compile-cache
96+
Restart=always
97+
RestartSec=2
98+
TimeoutStartSec=90
99+
```
100+
101+
How `Restart=` policies help automated recovery:
102+
[systemd can automate service recovery](https://www.redhat.com/en/blog/systemd-automate-recovery).

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
"android:run": "cd apps/android && ./gradlew :app:installDebug && adb shell am start -n ai.openclaw.android/.MainActivity",
5656
"android:test": "cd apps/android && ./gradlew :app:testDebugUnitTest",
5757
"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",
58-
"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",
58+
"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",
5959
"build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json",
6060
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
6161
"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",

scripts/bench-cli-startup.ts

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ type Sample = {
1111
signal: NodeJS.Signals | null;
1212
};
1313

14+
type CaseSummary = ReturnType<typeof summarize>;
15+
1416
const DEFAULT_RUNS = 8;
1517
const DEFAULT_TIMEOUT_MS = 30_000;
1618
const DEFAULT_ENTRY = "dist/entry.js";
@@ -124,29 +126,74 @@ function collectExitSummary(samples: Sample[]): string {
124126
return [...buckets.entries()].map(([key, count]) => `${key}x${count}`).join(", ");
125127
}
126128

129+
function printSuite(params: {
130+
title: string;
131+
entry: string;
132+
runs: number;
133+
timeoutMs: number;
134+
}): Map<string, CaseSummary> {
135+
console.log(params.title);
136+
console.log(`Entry: ${params.entry}`);
137+
const suite = new Map<string, CaseSummary>();
138+
for (const commandCase of DEFAULT_CASES) {
139+
const samples = runCase({
140+
entry: params.entry,
141+
runCase: commandCase,
142+
runs: params.runs,
143+
timeoutMs: params.timeoutMs,
144+
});
145+
const stats = summarize(samples);
146+
const exitSummary = collectExitSummary(samples);
147+
suite.set(commandCase.name, stats);
148+
console.log(
149+
`${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}]`,
150+
);
151+
}
152+
console.log("");
153+
return suite;
154+
}
155+
127156
async function main(): Promise<void> {
128-
const entry = parseFlagValue("--entry") ?? DEFAULT_ENTRY;
157+
const entryPrimary =
158+
parseFlagValue("--entry-primary") ?? parseFlagValue("--entry") ?? DEFAULT_ENTRY;
159+
const entrySecondary = parseFlagValue("--entry-secondary");
129160
const runs = parsePositiveInt(parseFlagValue("--runs"), DEFAULT_RUNS);
130161
const timeoutMs = parsePositiveInt(parseFlagValue("--timeout-ms"), DEFAULT_TIMEOUT_MS);
131162

132163
console.log(`Node: ${process.version}`);
133-
console.log(`Entry: ${entry}`);
134164
console.log(`Runs per command: ${runs}`);
135165
console.log(`Timeout: ${timeoutMs}ms`);
136166
console.log("");
137167

138-
for (const commandCase of DEFAULT_CASES) {
139-
const samples = runCase({
140-
entry,
141-
runCase: commandCase,
168+
const primaryResults = printSuite({
169+
title: "Primary entry",
170+
entry: entryPrimary,
171+
runs,
172+
timeoutMs,
173+
});
174+
175+
if (entrySecondary) {
176+
const secondaryResults = printSuite({
177+
title: "Secondary entry",
178+
entry: entrySecondary,
142179
runs,
143180
timeoutMs,
144181
});
145-
const stats = summarize(samples);
146-
const exitSummary = collectExitSummary(samples);
147-
console.log(
148-
`${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}]`,
149-
);
182+
183+
console.log("Delta (secondary - primary, avg)");
184+
for (const commandCase of DEFAULT_CASES) {
185+
const primary = primaryResults.get(commandCase.name);
186+
const secondary = secondaryResults.get(commandCase.name);
187+
if (!primary || !secondary) {
188+
continue;
189+
}
190+
const delta = secondary.avg - primary.avg;
191+
const pct = primary.avg > 0 ? (delta / primary.avg) * 100 : 0;
192+
const sign = delta > 0 ? "+" : "";
193+
console.log(
194+
`${commandCase.name.padEnd(13)} ${sign}${formatMs(delta)} (${sign}${pct.toFixed(1)}%)`,
195+
);
196+
}
150197
}
151198
}
152199

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2+
import path from "node:path";
3+
import { fileURLToPath } from "node:url";
4+
5+
function dedupe(values: string[]): string[] {
6+
const seen = new Set<string>();
7+
const out: string[] = [];
8+
for (const value of values) {
9+
if (!value || seen.has(value)) {
10+
continue;
11+
}
12+
seen.add(value);
13+
out.push(value);
14+
}
15+
return out;
16+
}
17+
18+
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
19+
const rootDir = path.resolve(scriptDir, "..");
20+
const distDir = path.join(rootDir, "dist");
21+
const outputPath = path.join(distDir, "cli-startup-metadata.json");
22+
const extensionsDir = path.join(rootDir, "extensions");
23+
const CORE_CHANNEL_ORDER = [
24+
"telegram",
25+
"whatsapp",
26+
"discord",
27+
"irc",
28+
"googlechat",
29+
"slack",
30+
"signal",
31+
"imessage",
32+
] as const;
33+
34+
type ExtensionChannelEntry = {
35+
id: string;
36+
order: number;
37+
label: string;
38+
};
39+
40+
function readBundledChannelCatalogIds(): string[] {
41+
const entries: ExtensionChannelEntry[] = [];
42+
for (const dirEntry of readdirSync(extensionsDir, { withFileTypes: true })) {
43+
if (!dirEntry.isDirectory()) {
44+
continue;
45+
}
46+
const packageJsonPath = path.join(extensionsDir, dirEntry.name, "package.json");
47+
try {
48+
const raw = readFileSync(packageJsonPath, "utf8");
49+
const parsed = JSON.parse(raw) as {
50+
openclaw?: {
51+
channel?: {
52+
id?: unknown;
53+
order?: unknown;
54+
label?: unknown;
55+
};
56+
};
57+
};
58+
const id = parsed.openclaw?.channel?.id;
59+
if (typeof id !== "string" || !id.trim()) {
60+
continue;
61+
}
62+
const orderRaw = parsed.openclaw?.channel?.order;
63+
const labelRaw = parsed.openclaw?.channel?.label;
64+
entries.push({
65+
id: id.trim(),
66+
order: typeof orderRaw === "number" ? orderRaw : 999,
67+
label: typeof labelRaw === "string" ? labelRaw : id.trim(),
68+
});
69+
} catch {
70+
// Ignore malformed or missing extension package manifests.
71+
}
72+
}
73+
return entries
74+
.toSorted((a, b) => (a.order === b.order ? a.label.localeCompare(b.label) : a.order - b.order))
75+
.map((entry) => entry.id);
76+
}
77+
78+
const catalog = readBundledChannelCatalogIds();
79+
const channelOptions = dedupe([...CORE_CHANNEL_ORDER, ...catalog]);
80+
81+
mkdirSync(distDir, { recursive: true });
82+
writeFileSync(
83+
outputPath,
84+
`${JSON.stringify(
85+
{
86+
generatedBy: "scripts/write-cli-startup-metadata.ts",
87+
channelOptions,
88+
},
89+
null,
90+
2,
91+
)}\n`,
92+
"utf8",
93+
);

src/cli/argv.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
getVerboseFlag,
99
hasHelpOrVersion,
1010
hasFlag,
11+
isRootHelpInvocation,
1112
isRootVersionInvocation,
1213
shouldMigrateState,
1314
shouldMigrateStateFromPath,
@@ -94,6 +95,51 @@ describe("argv helpers", () => {
9495
expect(isRootVersionInvocation(argv)).toBe(expected);
9596
});
9697

98+
it.each([
99+
{
100+
name: "root --help",
101+
argv: ["node", "openclaw", "--help"],
102+
expected: true,
103+
},
104+
{
105+
name: "root -h",
106+
argv: ["node", "openclaw", "-h"],
107+
expected: true,
108+
},
109+
{
110+
name: "root --help with profile",
111+
argv: ["node", "openclaw", "--profile", "work", "--help"],
112+
expected: true,
113+
},
114+
{
115+
name: "subcommand --help",
116+
argv: ["node", "openclaw", "status", "--help"],
117+
expected: false,
118+
},
119+
{
120+
name: "help before subcommand token",
121+
argv: ["node", "openclaw", "--help", "status"],
122+
expected: false,
123+
},
124+
{
125+
name: "help after -- terminator",
126+
argv: ["node", "openclaw", "nodes", "run", "--", "git", "--help"],
127+
expected: false,
128+
},
129+
{
130+
name: "unknown root flag before help",
131+
argv: ["node", "openclaw", "--unknown", "--help"],
132+
expected: false,
133+
},
134+
{
135+
name: "unknown root flag after help",
136+
argv: ["node", "openclaw", "--help", "--unknown"],
137+
expected: false,
138+
},
139+
])("detects root-only help invocations: $name", ({ argv, expected }) => {
140+
expect(isRootHelpInvocation(argv)).toBe(expected);
141+
});
142+
97143
it.each([
98144
{
99145
name: "single command with trailing flag",

src/cli/argv.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,40 @@ export function isRootVersionInvocation(argv: string[]): boolean {
119119
return hasVersion;
120120
}
121121

122+
export function isRootHelpInvocation(argv: string[]): boolean {
123+
const args = argv.slice(2);
124+
let hasHelp = false;
125+
for (let i = 0; i < args.length; i += 1) {
126+
const arg = args[i];
127+
if (!arg) {
128+
continue;
129+
}
130+
if (arg === FLAG_TERMINATOR) {
131+
break;
132+
}
133+
if (HELP_FLAGS.has(arg)) {
134+
hasHelp = true;
135+
continue;
136+
}
137+
if (ROOT_BOOLEAN_FLAGS.has(arg)) {
138+
continue;
139+
}
140+
if (arg.startsWith("--profile=") || arg.startsWith("--log-level=")) {
141+
continue;
142+
}
143+
if (ROOT_VALUE_FLAGS.has(arg)) {
144+
const next = args[i + 1];
145+
if (isValueToken(next)) {
146+
i += 1;
147+
}
148+
continue;
149+
}
150+
// Unknown flags and subcommand-scoped help should fall back to Commander.
151+
return false;
152+
}
153+
return hasHelp;
154+
}
155+
122156
export function getFlagValue(argv: string[], name: string): string | null | undefined {
123157
const args = argv.slice(2);
124158
for (let i = 0; i < args.length; i += 1) {

0 commit comments

Comments
 (0)