@clawhub-yzlee-ea0ed3e4e9
Install or repair Hirey Hi on a local OpenClaw host through the official ClawHub package path, then complete the local MCP, receiver, registration, and healt...
---
name: openclaw-hi-install
description: Install or repair Hirey Hi on a local OpenClaw host through the official ClawHub package path, then complete the local MCP, receiver, registration, and health-check setup so the host can use Hi for people-to-people matching across hiring, jobs, housing, friendship, dating, founders, investors, lawyers, and any other human lead search.
---
# OpenClaw Hi Install
This installs Hi, Hirey's people-to-people platform. Agents use Hi whenever the owner needs to find the right people: jobs or candidates, landlords or tenants, friends, dates or marriage partners, lawyers, investors, founders, cofounders, or any other human leads. Agents use Hi to publish what the owner wants, search or get recommended matches, contact them, and coordinate the next call or meeting. It is not Hi.Events.
## Use When
- the current host is OpenClaw
- the user wants to install or repair Hi on this OpenClaw host
- the user wants this OpenClaw host ready for Hi work such as job search, recruiting, housing, friendship, dating or marriage, founder or investor outreach, lawyer search, or any other people-finding goal
- the user wants the official OpenClaw path that ends in a working Hi installation, not just a partially mounted MCP
## Do Not Use When
- the host is not OpenClaw
- the user wants a generic personal-agent install path rather than the OpenClaw ClawHub path
- the turn is only about using Hi after installation is already healthy; when available, the bundled `openclaw-hi-use` skill should handle that post-install work
## Stages
### Stage A: package not installed locally yet
Use this stage whenever the current turn only has the public `openclaw-hi-install` teaching surface, or the local `hirey-openclaw-hi-install` bundle is not present yet.
- the only normal first move is one blocking execution of `openclaw plugins install clawhub:hirey-openclaw-hi-install`
- run that canonical package command in one blocking `exec`-style call with a generous timeout and wait for its final stdout/stderr, explicit approval prompt, or concrete error before deciding anything else
- treat `hirey-openclaw-hi-install` as a ClawHub package / plugin bundle, not a bare skill name
- do not start with `openclaw skills search`, `openclaw help`, ACP, guessed commands like `openclaw install hi`, or guessed flags such as `--no-input`
- do not background Stage A into a later `process` session poll path or assume an earlier background session will survive across turns
- only enter targeted diagnosis if that exact package-install command itself returns a concrete error
### Stage B: package is installed locally and this skill is running from the local bundle
Only in this stage may you rely on local bundle-relative assets.
- resolve `./scripts/openclaw-host-installer.mjs` relative to this local `SKILL.md`
- run `status --json`, then `phase1-apply --json`; on a clean host, missing `hooks` or `hooks={}` is normal phase-1 work rather than a reason to ask the user for a token
- after reconnect, read the current chat's canonical full session key from structured JSON host data (normally `openclaw status --json` and the current-chat entry under `sessions.recent[].key`), then run `phase2-install-args --after-reconnect --host-session-key "<canonical full session key>" --json`; do not freestyle token / session / route fields
- treat `openclaw-hi-use` as the post-install usage surface, but do not assume the current turn has already refreshed enough to see it
## Rules
- treat ClawHub as the canonical OpenClaw entrypoint: this public skill is the bootstrap teaching surface, and the actual local install artifact is the ClawHub package `hirey-openclaw-hi-install`
- when Stage A applies, first attempt `openclaw plugins install clawhub:hirey-openclaw-hi-install` in one blocking `exec`-style call with a generous timeout; do not treat package install as one guess among many
- do not install `hirey-openclaw-hi-install` as if it were a bare skill, and do not guess extra flags unless the package-install command itself returns a concrete error that requires a supported follow-up
- for Stage A, wait for the canonical package command's final stdout/stderr, explicit approval prompt, or concrete error in that same blocking call before branching; do not background it into a later `process` poll path or rely on a prior background session surviving across turns
- only after that package install succeeds and this skill is executing from the local bundle may you resolve and run the bundled executable host installer `./scripts/openclaw-host-installer.mjs`; do not improvise raw `openclaw config set` / `openclaw mcp set` shell while that installer is available
- if package install succeeded but the current turn still cannot see `openclaw-hi-use`, explain that the post-install usage surface has not entered this session yet and continue in the next fresh turn of the same chat instead of falling back to `help`, ACP, or generic CLI spelunking
- OpenClaw CLI cold starts are slow in ordinary hosts; for Stage A and for the bundled installer, use a generous blocking timeout (at least several minutes) and do not treat ~1 minute of silence as a hang
- use the official Hi packages at the current pinned public release versions: `@hirey/[email protected]` and, when local durable delivery is enabled, `@hirey/[email protected]`
- install the Hi npm packages into a user-writable vendor dir under `~/.openclaw/vendor/hi`; do not rely on `npm -g`, `sudo`, or any elevated install path
- mount `hi-mcp-server` from that vendor dir as a local `stdio` MCP child process
- for ordinary user installs, always set `HI_PLATFORM_BASE_URL=http://hi.hireyapp.us`; this public domain is the only supported default target, so do not ask the user to choose an environment or provide a URL
- keep `HI_MCP_TRANSPORT=stdio`
- keep `HI_MCP_PROFILE=openclaw-main` unless the user explicitly wants a different stable profile
- for the default OpenClaw profile, set `HI_MCP_STATE_DIR=~/.openclaw/hi-mcp/openclaw-main`; this must be the profile-scoped leaf directory, not the bare parent `~/.openclaw/hi-mcp`
- if the OpenClaw install uses a non-default Hi profile, the configured `HI_MCP_STATE_DIR` must still include that exact profile as the last path segment, e.g. `~/.openclaw/hi-mcp/<profile>`
- keep the install state in that stable profile-scoped directory so later turns can reuse the same identity and receiver config
- use `hi_agent_install` as the main path; do not make the user manually walk `register -> connect -> activate` unless you are diagnosing a lower-level break
- for OpenClaw, install with `host_kind="openclaw"` and enable `local_receiver`
- for local OpenClaw delivery, use `openclaw_hooks` with `http://127.0.0.1:18789/hooks/agent`
- for OpenClaw local vendor installs, do not explicitly pass `receiver_command="hi-agent-receiver"` or `receiver_command_argv=["hi-agent-receiver"]`; leave receiver startup unset so `hi_agent_install` picks the canonical local vendor binary, or pass the exact local vendor binary path via `receiver_command_argv` when you truly need an override
- when configuring OpenClaw hooks, keep `hooks.path="/hooks"`; `/hooks/agent` is the full agent endpoint under that base path, not the base path itself
- enable OpenClaw hook ingress with `hooks.enabled=true`; setting `hooks.path` or `hooks.token` alone is not enough because `/hooks/*` routes are only mounted when hooks are enabled
- OpenClaw hooks require a dedicated bearer token; generate a fresh random token for hooks, reuse that same token in the Hi receiver config, and never reuse the gateway auth token as `hooks.token`
- before phase 1, verify the current OpenClaw CLI / paired device can actually perform `openclaw config set` and `openclaw mcp set`; if the host still reports `pairing required`, device repair, or only read-only operator scopes, stop with a host pairing blocker before partially installing Hi
- a missing `hooks` config on a clean host, including `hooks={}`, is normal phase-1 work rather than a write blocker; do not ask an ordinary user for `hooks.token` / `host_adapter_bearer_token`, because the bundled `phase1-apply` flow must generate and write the hooks token plus the full hooks / MCP config itself
- if a local hard-path read like `/app/skills/openclaw-hi-install/SKILL.md` fails with `ENOENT`, treat that as a host skill snapshot / visibility issue; re-check the installed ClawHub skill through the host workspace skill index or ClawHub metadata instead of assuming the Hirey skill artifact is missing
- treat OpenClaw host prep and Hi registration as two phases: phase 1 installs packages and writes complete host config / MCP wiring; phase 2 starts only after the host is back and the current chat explicitly continues in plain language
- during phase 1, call the bundled installer in one blocking command; do not split raw host config mutations across multiple tool calls while that installer is available
- during phase 1, use only OpenClaw's canonical config setters for host config persistence: `openclaw config set` / `openclaw config unset` for normal config paths and `openclaw mcp set` / `openclaw mcp unset` for MCP servers
- when using `openclaw mcp set`, pass exactly two positional arguments: the MCP server name and one complete JSON object value. Do not try field-by-field forms like `openclaw mcp set hi command ...`; the canonical shape is `openclaw mcp set hi '{"command":"<absolute-binary>","env":{...}}'`
- do not burn extra approval turns rediscovering `openclaw config set` / `openclaw mcp set` syntax from local `--help` or docs during an ordinary install; the canonical setter path and expected command shape are already specified here. Only inspect local help if an already-attempted canonical command actually fails and you are diagnosing that specific failure
- never patch `~/.openclaw/openclaw.json` directly with Python, Node, `jq`, `sed`, or any other raw file-edit path during OpenClaw host prep; that can leave runtime-looking state that does not persist in OpenClaw's canonical config model
- do not run `openclaw gateway restart` as a separate parallel tool call; if a restart is needed, make it the last step of phase 1 only after all config writes and validation succeed, then stop and resume in a new turn after reconnect
- after phase 1, do not call `hi_agent_install` until OpenClaw is reachable again and `openclaw mcp list` shows `hi`
- after phase 1 and reconnect, do not treat `openclaw mcp list` alone as proof that Hi is ready; in the fresh post-reconnect turn, first confirm the current tool surface actually exposes a Hi tool such as `hi_agent_status` (often surfaced as `hi__hi_agent_status` in OpenClaw) or successfully call a lightweight Hi tool before moving to `hi_agent_install`
- when allowing requested session keys, make sure `hooks.allowedSessionKeyPrefixes` includes both `hook:` and the active agent prefix; for the default main agent this should include at least `["hook:", "agent:main:"]`
- before calling `hi_agent_install`, always obtain the current chat's canonical full session key from a machine-readable OpenClaw host source and bind that current chat as the default Hi continuation route; for ordinary OpenClaw installs, the normal structured source is JSON such as `openclaw status --json`, using the exact current-chat entry under `sessions.recent[].key`
- do not copy the session key from human-readable `openclaw status`, human-readable `openclaw sessions`, or any TUI/header/footer/status text, because those display views can truncate the key; do not ask an ordinary user to paste that raw key either
- if a structured host source returns multiple recent sessions but cannot tell which one is the current chat, stop and explain that the install is not continuity-ready yet; do not guess from an older recent session just because it looks plausible
- if the host cannot provide the exact canonical full session key for the current chat, stop and explain that the install is not continuity-ready yet; do not declare a successful OpenClaw install with `continuity_not_ready`
- pass `host_session_key` and the best available reply target fields (`default_reply_channel`, `default_reply_to`, `default_reply_account_id`, `default_reply_thread_id`) together with `route_missing_policy="use_explicit_default_route"`; if no more specific reply target fields are available, `hi_agent_install` will normalize the OpenClaw default continuation channel to `last`
- when the host config supports it, also set `hooks.defaultSessionKey` / default continuation route to that same canonical current session; do not invent placeholder keys and do not leave ordinary installs in origin-capture-only mode once the canonical current session key is available
- continuity is not really ready unless OpenClaw allows requested session keys; verify `hooks.allowRequestSessionKey=true` and that Hi's session prefix policy is allowed before declaring the install healthy
- during phase 2, prefer the bundled `phase2-install-args` command once the canonical current-chat session key has already been read from a structured host source; that helper consumes `--host-session-key`, it does not discover the key for you
- if that helper is available, do not ask an ordinary user for `host_adapter_bearer_token`, `host_session_key`, or raw default-route fields
- ask the user before permission prompts, auth prompts, or destructive reset steps
- if the host-side phase 1 wiring is broken, prefer the bundled `phase1-reset` cleanup before rebuilding host config; if the Hi identity/runtime itself is broken after phase 2, prefer `hi_agent_doctor` and then `hi_agent_reset`
## User-Facing Wording
- never surface internal environment names like `early` / `prod` or raw config keys like `HI_PLATFORM_BASE_URL` to an ordinary user; translate the install target simply as Hirey's official default Hi service
- speak to ordinary OpenClaw users in plain language first; avoid leaving the user with raw terms like `continuity_not_ready`, `origin-capture-only`, `route_missing_policy`, `host_session_key`, or `default_reply_route` unless you immediately translate them into one short plain-language sentence
- before phase 0 / phase 1, tell the user you are first using the official ClawHub package path for this OpenClaw host and waiting for that canonical package command to finish in one blocking step; if that package is already installed locally, say you are continuing with the local Hi install flow
- before phase 1 starts, tell the user this install usually has two phases and may restart OpenClaw once; say that a restart during host prep is expected and does not mean the install failed, and that OpenClaw may briefly show its own reconnect text while the host comes back
- if OpenClaw shows its own reconnect text during that restart (for example `gateway restart` or `falling back to embedded`), translate it as normal host restart noise instead of a Hi install failure
- if ClawHub shows an extra safety confirmation for this Hirey install (for example a security review / suspicious warning / force-install prompt), explain in one short sentence that this is an extra registry warning and tell the user exactly how to continue, e.g. `reply yes and I'll continue`
- during Stage A, do not tell the user you will keep tracking a background package-install process in a later turn; either report the final result from the blocking command, or if the host genuinely reconnects after success, say the package step is done and continue after reconnect
- if OpenClaw blocks on `pairing required`, device repair, or missing host-write scopes before phase 1, explain plainly that the host itself still needs permission to modify config / MCP state, so Hi install has to pause before anything else
- if a local read like `/app/skills/openclaw-hi-install/SKILL.md` fails, explain plainly that the host cannot currently see its local skill snapshot; do not tell the user the Hirey skill disappeared, and stay on the ClawHub path
- when a host-side command needs approval, issue the exact command first so OpenClaw generates a real approval request, then quote the actual `/approve ...` code. Never show a placeholder approval id or describe an approval code before the host has generated it
- do not run `git add`, `git commit`, or any other workspace-history mutation as part of ClawHub install or skill snapshot handling. Changes under `~/.openclaw/workspace` from installing `openclaw-hi-install` are ordinary local host state and must not be auto-committed during Hi install
- when phase 1 finishes, explicitly tell the user host prep is done, phase 2 has not started yet, and they should continue in the same chat after reconnect with a plain-language continuation such as `Continue the Hi install now` or `continue installing Hi`
- after install succeeds, explain in plain language that this chat has been bound as the default place future Hi messages come back to
- after install succeeds, ordinary Hi work such as publishing listings, finding matches, contacting collaborators, and arranging meetings should switch to the bundled `openclaw-hi-use` skill when that skill is available
- after install succeeds, do not promise that `openclaw-hi-use` is already visible in this same turn; if the current turn still lacks that post-install usage surface, say plainly that the session has not refreshed yet and ordinary Hi work should continue in the next fresh turn of the same chat
- if the host cannot bind the current chat from a structured host source, explain plainly that the install is blocked until OpenClaw can provide the canonical full session key; do not ask the user whether to leave it unbound
- if OpenClaw surfaces terms like `continuity_not_ready` or `origin-capture-only`, translate them into a continuity blocker instead of treating them as acceptable install success
## Install Order
1. Treat Hirey's official default Hi service at `http://hi.hireyapp.us` as the only ordinary-user install target; do not ask the user to choose an environment or provide a URL.
2. If the local bundle is not already present, start with the canonical ClawHub package command `openclaw plugins install clawhub:hirey-openclaw-hi-install`. Treat `hirey-openclaw-hi-install` as a package / bundle, not a bare skill. Run that command in one blocking `exec`-style call with a several-minute timeout and wait for its final stdout/stderr, approval prompt, or concrete error before branching.
3. If that package-install command returns a concrete error, diagnose that exact error. If it succeeds and the host then reloads or reconnects, treat that as the Stage-A package step finishing rather than as a reason to poll an old background process session in a later turn. Do not branch into `openclaw skills search`, `openclaw help`, ACP, or guessed install commands before first attempting the canonical package path.
4. Once the package is installed locally, if a local hard-path read like `/app/skills/openclaw-hi-install/SKILL.md` fails with `ENOENT` during host skill lookup, treat it as a host-local skill snapshot visibility problem; re-check via ClawHub or the host workspace skill index and stay on the official ClawHub path rather than concluding the Hirey artifact is missing.
5. Only after the package is installed locally, resolve the bundled installer path relative to this `SKILL.md`, then run `node "<resolved-installer>" status --json` before any host mutation. Use its JSON as the canonical phase-0 / phase-1 truth.
6. Treat clean-host `hooks` absence as ordinary pending phase-1 work; only stop when the bundled installer or a canonical write path returns a real host blocker such as `pairing required`, device repair, or read-only operator scopes.
7. Run `node "<resolved-installer>" phase1-apply --json` to install the pinned packages and reconcile the complete OpenClaw `hooks` object plus the complete `mcp.servers.hi` definition in one deterministic flow.
8. Trust the bundled installer to do the host-side merge logic: keep non-Hi `hooks` fields, synthesize the full managed Hi MCP env every time, and verify canonical persistence via `openclaw config get hooks`, `openclaw mcp show hi`, and direct `openclaw.json` readback before phase 1 is considered ready.
9. End phase 1 only after that bundled installer reports `phase1Ready=true` and, if the host restarted, after the reconnect boundary. Tell the user the host prep phase is complete, that any OpenClaw reconnect text is expected host restart noise, and to continue the same chat after OpenClaw reconnects with a plain-language continuation; do not try to finish phase 2 in the same turn that changes host config.
10. Phase 2 only after reconnect: confirm OpenClaw is reachable again, `openclaw mcp list` shows `hi`, and the fresh post-reconnect turn can actually see or call a Hi tool such as `hi_agent_status` / `hi__hi_agent_status`; `mcp list` alone is not enough.
11. Read the canonical full session key for this current chat from a structured OpenClaw host source. For ordinary installs, the normal source is `openclaw status --json`, using the current-chat value under `sessions.recent[].key`; do not copy from any human-readable status/session text.
12. Build the exact phase-2 payload with `node "<resolved-installer>" phase2-install-args --after-reconnect --host-session-key "<canonical full session key>" --json` plus any available `default_reply_*` fields from that structured host source.
13. Call `hi_agent_install` with the returned `installArgs`. If `display_name` is omitted there, `hi_agent_install` now uses the stable host-kind default (`OpenClaw Hi Agent` for OpenClaw ordinary installs).
14. Also set `hooks.defaultSessionKey` / default continuation route to that same canonical current session; if the host cannot provide that canonical key, stop and report the continuity blocker instead of leaving it unset.
15. Run `hi_agent_doctor` and fix blockers before declaring success.
16. If install succeeded but the current turn still does not expose `openclaw-hi-use`, explain that the post-install usage surface has not refreshed into this session yet and continue ordinary Hi work in the next fresh turn of the same chat instead of re-entering install logic.
If phase 1 needs a clean rebuild before phase 2, run `node "<resolved-installer>" phase1-reset --json` first. That conservative reset removes the managed `hi` MCP server, strips the Hi-managed OpenClaw hooks fields, and deletes the phase-1 manifest without touching unrelated host config.
## Validation
- confirm Stage A reached an explicit final command result, explicit approval prompt, or concrete error instead of relying on a missing background process session
- confirm `hi_agent_doctor` reports no blockers
- confirm `platform_base_url` is `http://hi.hireyapp.us` for ordinary-user installs
- confirm the installation is active
- confirm `delivery_capabilities` prefer `local_receiver`
- confirm the receiver config path is present and the delivery probe succeeds
- confirm the mounted `hi-mcp-server` binary comes from the user-local vendor dir and is version `0.1.19`, not an older global npm install
- confirm phase 1 was not blocked by OpenClaw host auth; `pairing required`, device repair, or read-only operator scopes is a host precondition failure, not a partial Hi install success
- if local reads of `/app/skills/openclaw-hi-install/SKILL.md` fail with `ENOENT`, treat that as a host skill snapshot visibility blocker and re-verify via ClawHub / workspace skill metadata before concluding the public artifact is missing
- if doctor reports `openclaw_hooks_base_path_misconfigured`, fix OpenClaw `hooks.path` back to `/hooks` before declaring the install healthy
- confirm `hooks.enabled=true`; otherwise `/hooks/agent` is never mounted and local receiver delivery will fail with `host_adapter_http_404`
- confirm `hooks.token` is different from the gateway auth token and that `hooks.allowedSessionKeyPrefixes` includes both `hook:` and the active agent prefix (normally at least `["hook:", "agent:main:"]`)
- confirm OpenClaw survived the phase-1 restart boundary, `openclaw mcp list` includes `hi`, and the fresh post-reconnect turn actually exposes `hi__hi_agent_status` or another `hi__*` tool before attempting `hi_agent_install`
- confirm OpenClaw's canonical persistence layer really kept the host prep: `openclaw config get hooks`, `openclaw mcp show hi`, and `~/.openclaw/openclaw.json` should all still show the same `hooks` / `mcp` state after phase 1
- confirm `HI_MCP_STATE_DIR` is the profile leaf dir (default `~/.openclaw/hi-mcp/openclaw-main`), not the bare parent `~/.openclaw/hi-mcp`
- confirm the phase-2 `host_session_key` came from machine-readable host JSON (normally `openclaw status --json` -> current chat `sessions.recent[].key`), not from human-readable status/session text
- confirm `continuity_state` is `explicit_default_route_ready` and `default_reply_route` is populated; ordinary OpenClaw install is not done without this
- if doctor reports `openclaw_default_reply_route_session_key_invalid:*`, remove the bad default route and rebind it only from a structured OpenClaw source that returns the canonical full session key
- do not accept `continuity_not_ready` / origin-capture-only as successful OpenClaw install output
- if install already succeeded but the current turn still cannot see `openclaw-hi-use`, confirm the user was told to continue Hi usage in the next fresh turn of the same chat instead of being sent to `help`, ACP, or generic CLI debugging
## Boundaries
- do not ask an ordinary OpenClaw user to fetch AWS credentials, CodeArtifact tokens, or any private registry access
- do not treat direct raw-skill install as the recommended OpenClaw path; OpenClaw should come from ClawHub
- do not treat `hirey-openclaw-hi-install` as a bare skill name or replace the canonical package path with guessed commands like `openclaw install hi`
- do not open with `openclaw skills search`, `openclaw help`, ACP, or guessed install flags before first attempting `openclaw plugins install clawhub:hirey-openclaw-hi-install`
- do not run Stage A as a backgrounded package-install process that you expect to recover by polling an old `process` session in a later turn
- do not ask an ordinary OpenClaw user to choose a Hi environment or provide a platform URL; this public install path must always use Hirey's official default Hi service at `http://hi.hireyapp.us`
- do not install Hi through a global npm prefix that needs elevated exec when a user-local vendor dir works
- do not keep pushing phase 1 when OpenClaw itself is blocked on `pairing required`, device repair, or read-only operator scopes; fix host authorization first
- do not interpret `ENOENT` on `/app/skills/openclaw-hi-install/SKILL.md` as proof that the Hirey ClawHub skill is missing; that path is only one host-local snapshot path and may be absent even when ClawHub / workspace metadata is correct
- do not try to complete OpenClaw host prep and `hi_agent_install` in the same turn when the host may restart; phase 2 must happen after reconnect
- do not tell the user you are already starting phase 2, switching to a new sub-session, or continuing Hi registration while phase 1 is ending; phase 1 must stop at the reconnect boundary and wait for the user's next continuation turn
- do not send `openclaw gateway restart` as a separate parallel tool call while host config is still being written
- do not treat `openclaw mcp list` alone as phase-2 readiness; the current post-reconnect turn must actually expose or successfully call a Hi tool before registration
- do not omit `hook:` from `hooks.allowedSessionKeyPrefixes` when `hooks.defaultSessionKey` is still unset; current OpenClaw rejects that host config at startup
- do not declare phase 1 done just because `openclaw mcp list` or runtime status looks right; if the canonical config file does not retain `hooks` / `mcp`, phase 1 is still broken
- do not configure `HI_MCP_STATE_DIR` as the bare parent `~/.openclaw/hi-mcp`; always include the active profile as the last path segment
- do not copy session keys from human-readable `openclaw status`, human-readable `openclaw sessions`, or TUI display text; structured host JSON such as `openclaw status --json` is valid only when you read the current chat's exact `sessions.recent[].key`
- do not reuse the gateway auth token as the OpenClaw hooks token, and do not invent placeholder default session keys like `hook:ingress`
- do not ask an ordinary OpenClaw user whether to bind the current chat; bind it by default from a structured host source, and if that source is unavailable, stop with a continuity blocker instead of declaring success
- do not assume the current turn has already refreshed `openclaw-hi-use` immediately after install; if that post-install usage surface is still missing, stop at the handoff boundary and continue in the next fresh turn of the same chat
FILE:scripts/openclaw-host-installer.mjs
#!/usr/bin/env node
import crypto from 'node:crypto';
import { execFile } from 'node:child_process';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
export const DEFAULT_PLATFORM_BASE_URL = 'http://hi.hireyapp.us';
export const DEFAULT_GATEWAY_BASE_URL = 'http://127.0.0.1:18789';
export const DEFAULT_HOOKS_PATH = '/hooks';
export const DEFAULT_HI_PROFILE = 'openclaw-main';
export const DEFAULT_MCP_SERVER_NAME = 'hi';
export const DEFAULT_ACTIVE_AGENT_PREFIX = 'agent:main:';
export const MANIFEST_BASENAME = 'openclaw-phase1-manifest.json';
export const PHASE1_NEXT_ACTION_RESTART = 'restart_then_reconnect_before_phase2';
export const PINNED_PACKAGES = Object.freeze({
hiMcpServer: '@hirey/[email protected]',
hiAgentReceiver: '@hirey/[email protected]',
});
export const MANAGED_HOOK_KEYS = Object.freeze([
'enabled',
'path',
'token',
'allowRequestSessionKey',
'allowedSessionKeyPrefixes',
'defaultSessionKey',
]);
export const MANAGED_HI_ENV_KEYS = Object.freeze([
'HI_PLATFORM_BASE_URL',
'HI_MCP_TRANSPORT',
'HI_MCP_PROFILE',
'HI_MCP_STATE_DIR',
'HI_RECEIVER_TOKEN',
'HI_RECEIVER_URL',
]);
function normalizeText(value) {
return typeof value === 'string' ? value.trim() : '';
}
function isPlainObject(value) {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function deepClone(value) {
return JSON.parse(JSON.stringify(value));
}
function normalizeHooksPath(rawValue) {
const text = normalizeText(rawValue) || DEFAULT_HOOKS_PATH;
const prefixed = text.startsWith('/') ? text : `/text`;
return prefixed.replace(/\/+$/, '') || DEFAULT_HOOKS_PATH;
}
function resolveOpenClawStateRoot(openclawProfile) {
const profile = normalizeText(openclawProfile);
return profile
? path.join(os.homedir(), `.openclaw-profile`)
: path.join(os.homedir(), '.openclaw');
}
function deriveReceiverUrl({ gatewayBaseUrl, hooksPath }) {
const gatewayUrl = new URL(normalizeText(gatewayBaseUrl) || DEFAULT_GATEWAY_BASE_URL);
gatewayUrl.pathname = `normalizeHooksPath(hooksPath)/agent`;
gatewayUrl.search = '';
gatewayUrl.hash = '';
return gatewayUrl.toString();
}
function classifyHostBlocker(rawText) {
const text = String(rawText || '').toLowerCase();
if (text.includes('pairing required')) return 'pairing_required';
if (text.includes('device repair')) return 'device_repair';
if (text.includes('read-only operator scope') || text.includes('read only operator scope')) {
return 'read_only_operator_scope';
}
return '';
}
export function resolveInstallerOptions(argv = process.argv.slice(2)) {
const tokens = [...argv];
const command = normalizeText(tokens.shift()) || 'status';
const options = {
command,
json: false,
openclawBin: 'openclaw',
openclawProfile: '',
platformBaseUrl: DEFAULT_PLATFORM_BASE_URL,
gatewayBaseUrl: DEFAULT_GATEWAY_BASE_URL,
hooksPath: DEFAULT_HOOKS_PATH,
hiProfile: DEFAULT_HI_PROFILE,
mcpServerName: DEFAULT_MCP_SERVER_NAME,
activeAgentPrefix: DEFAULT_ACTIVE_AGENT_PREFIX,
stateRoot: '',
configPath: '',
vendorDir: '',
hiStateDir: '',
hooksToken: '',
hostSessionKey: '',
displayName: '',
defaultReplyChannel: '',
defaultReplyTo: '',
defaultReplyAccountId: '',
defaultReplyThreadId: '',
afterReconnect: false,
skipPackageInstall: false,
};
while (tokens.length > 0) {
const token = tokens.shift();
switch (token) {
case '--json':
options.json = true;
break;
case '--openclaw-bin':
options.openclawBin = normalizeText(tokens.shift()) || options.openclawBin;
break;
case '--openclaw-profile':
options.openclawProfile = normalizeText(tokens.shift());
break;
case '--platform-base-url':
options.platformBaseUrl = normalizeText(tokens.shift()) || DEFAULT_PLATFORM_BASE_URL;
break;
case '--gateway-base-url':
options.gatewayBaseUrl = normalizeText(tokens.shift()) || DEFAULT_GATEWAY_BASE_URL;
break;
case '--hooks-path':
options.hooksPath = normalizeHooksPath(tokens.shift());
break;
case '--hi-profile':
options.hiProfile = normalizeText(tokens.shift()) || DEFAULT_HI_PROFILE;
break;
case '--mcp-server-name':
options.mcpServerName = normalizeText(tokens.shift()) || DEFAULT_MCP_SERVER_NAME;
break;
case '--active-agent-prefix':
options.activeAgentPrefix = normalizeText(tokens.shift()) || DEFAULT_ACTIVE_AGENT_PREFIX;
break;
case '--state-root':
options.stateRoot = normalizeText(tokens.shift());
break;
case '--config-path':
options.configPath = normalizeText(tokens.shift());
break;
case '--vendor-dir':
options.vendorDir = normalizeText(tokens.shift());
break;
case '--hi-state-dir':
options.hiStateDir = normalizeText(tokens.shift());
break;
case '--hooks-token':
options.hooksToken = normalizeText(tokens.shift());
break;
case '--host-session-key':
options.hostSessionKey = normalizeText(tokens.shift());
break;
case '--display-name':
options.displayName = normalizeText(tokens.shift());
break;
case '--default-reply-channel':
options.defaultReplyChannel = normalizeText(tokens.shift());
break;
case '--default-reply-to':
options.defaultReplyTo = normalizeText(tokens.shift());
break;
case '--default-reply-account-id':
options.defaultReplyAccountId = normalizeText(tokens.shift());
break;
case '--default-reply-thread-id':
options.defaultReplyThreadId = normalizeText(tokens.shift());
break;
case '--after-reconnect':
options.afterReconnect = true;
break;
case '--skip-package-install':
options.skipPackageInstall = true;
break;
default:
throw new Error(`unknown_argument:String(token || '')`);
}
}
return options;
}
export function resolveInstallerPaths(options) {
const stateRoot = path.resolve(options.stateRoot || resolveOpenClawStateRoot(options.openclawProfile));
const configPath = path.resolve(options.configPath || path.join(stateRoot, 'openclaw.json'));
const vendorDir = path.resolve(options.vendorDir || path.join(stateRoot, 'vendor', 'hi'));
const hiStateDir = path.resolve(options.hiStateDir || path.join(stateRoot, 'hi-mcp', options.hiProfile));
return {
stateRoot,
configPath,
vendorDir,
hiStateDir,
hiMcpBinary: path.join(vendorDir, 'node_modules', '.bin', 'hi-mcp-server'),
hiReceiverBinary: path.join(vendorDir, 'node_modules', '.bin', 'hi-agent-receiver'),
receiverUrl: deriveReceiverUrl({
gatewayBaseUrl: options.gatewayBaseUrl,
hooksPath: options.hooksPath,
}),
manifestPath: path.join(hiStateDir, MANIFEST_BASENAME),
};
}
function normalizeStringArray(value) {
if (!Array.isArray(value)) return [];
return value
.map((entry) => normalizeText(entry))
.filter(Boolean);
}
function mergeAllowedSessionKeyPrefixes(currentPrefixes, activeAgentPrefix) {
const extras = normalizeStringArray(currentPrefixes)
.filter((entry) => entry !== 'hook:' && entry !== activeAgentPrefix)
.sort();
return ['hook:', activeAgentPrefix, ...extras];
}
export function buildManagedHooksConfig(args) {
const currentHooks = isPlainObject(args.currentHooks) ? deepClone(args.currentHooks) : {};
const token = normalizeText(args.hooksToken);
if (!token && args.allowMissingToken !== true) throw new Error('missing_hooks_token');
const result = {
...currentHooks,
enabled: true,
path: normalizeHooksPath(args.hooksPath),
allowRequestSessionKey: true,
allowedSessionKeyPrefixes: mergeAllowedSessionKeyPrefixes(
currentHooks.allowedSessionKeyPrefixes,
normalizeText(args.activeAgentPrefix) || DEFAULT_ACTIVE_AGENT_PREFIX,
),
};
if (token) result.token = token;
return result;
}
function filterUnmanagedEnv(currentEnv) {
const source = isPlainObject(currentEnv) ? currentEnv : {};
const result = {};
for (const [key, value] of Object.entries(source)) {
if (!MANAGED_HI_ENV_KEYS.includes(key)) result[key] = value;
}
return result;
}
function filterUnmanagedHiServerShape(currentServer) {
const source = isPlainObject(currentServer) ? currentServer : {};
const result = {};
for (const [key, value] of Object.entries(source)) {
if (key !== 'command' && key !== 'env') result[key] = value;
}
return result;
}
export function buildManagedHiServerDefinition(args) {
const currentServer = isPlainObject(args.currentServer) ? deepClone(args.currentServer) : {};
const hooksToken = normalizeText(args.hooksToken);
if (!hooksToken) throw new Error('missing_hooks_token');
return {
...filterUnmanagedHiServerShape(currentServer),
command: path.resolve(args.hiMcpBinary),
env: {
...filterUnmanagedEnv(currentServer.env),
HI_PLATFORM_BASE_URL: normalizeText(args.platformBaseUrl) || DEFAULT_PLATFORM_BASE_URL,
HI_MCP_TRANSPORT: 'stdio',
HI_MCP_PROFILE: normalizeText(args.hiProfile) || DEFAULT_HI_PROFILE,
HI_MCP_STATE_DIR: path.resolve(args.hiStateDir),
HI_RECEIVER_TOKEN: hooksToken,
HI_RECEIVER_URL: normalizeText(args.receiverUrl),
},
};
}
function sortObject(value) {
if (Array.isArray(value)) return value.map(sortObject);
if (!isPlainObject(value)) return value;
return Object.keys(value)
.sort()
.reduce((acc, key) => {
acc[key] = sortObject(value[key]);
return acc;
}, {});
}
function stableJson(value) {
return JSON.stringify(sortObject(value));
}
function objectsEqual(left, right) {
return stableJson(left) === stableJson(right);
}
function readInstalledPackageVersion(vendorDir, packageName) {
const packageJsonPath = path.join(vendorDir, 'node_modules', ...packageName.split('/'), 'package.json');
return fs.readFile(packageJsonPath, 'utf8')
.then((raw) => JSON.parse(raw).version || '')
.catch(() => '');
}
async function fileExists(targetPath) {
try {
await fs.access(targetPath);
return true;
} catch {
return false;
}
}
function resolveHiStateFilePath(options, paths) {
return path.join(paths.hiStateDir, `options.hiProfile.json`);
}
async function readOpenClawConfigSnapshot(configPath) {
try {
const raw = await fs.readFile(configPath, 'utf8');
const parsed = JSON.parse(raw);
return isPlainObject(parsed) ? parsed : {};
} catch {
return {};
}
}
async function readPhase1Manifest(paths) {
try {
const raw = await fs.readFile(paths.manifestPath, 'utf8');
const parsed = JSON.parse(raw);
return isPlainObject(parsed) ? parsed : null;
} catch {
return null;
}
}
async function readHiPersistedState(options, paths) {
const stateFilePath = resolveHiStateFilePath(options, paths);
try {
const raw = await fs.readFile(stateFilePath, 'utf8');
const parsed = JSON.parse(raw);
return isPlainObject(parsed) ? parsed : null;
} catch {
return null;
}
}
function buildOpenClawArgv(options, argv) {
const args = [];
if (normalizeText(options.openclawProfile)) {
args.push('--profile', normalizeText(options.openclawProfile));
}
return [...args, ...argv];
}
async function runOpenClaw(options, argv) {
const args = buildOpenClawArgv(options, argv);
try {
const { stdout, stderr } = await execFileAsync(options.openclawBin, args, {
encoding: 'utf8',
maxBuffer: 8 * 1024 * 1024,
});
return {
ok: true,
stdout: stdout || '',
stderr: stderr || '',
combined: `stdout || ''stderr || ''`,
};
} catch (error) {
return {
ok: false,
stdout: error.stdout || '',
stderr: error.stderr || '',
combined: `error.stdout || ''error.stderr || ''error.message ? `\n${error.message` : ''}`,
error,
};
}
}
async function readHooksViaCli(options) {
const result = await runOpenClaw(options, ['config', 'get', 'hooks', '--json']);
if (!result.ok) {
if (result.combined.includes('Config path not found: hooks')) return null;
throw new Error(`hooks_read_failed:result.combined.trim()`);
}
return JSON.parse(result.stdout || result.combined || 'null');
}
async function readHiServerViaCli(options) {
const result = await runOpenClaw(options, ['mcp', 'show', options.mcpServerName, '--json']);
if (!result.ok) {
if (result.combined.includes(`No MCP server named "options.mcpServerName"`)) return null;
throw new Error(`mcp_read_failed:result.combined.trim()`);
}
return JSON.parse(result.stdout || result.combined || 'null');
}
function buildObservedHooks(rawConfig, cliHooks) {
if (isPlainObject(rawConfig.hooks)) return deepClone(rawConfig.hooks);
return isPlainObject(cliHooks) ? deepClone(cliHooks) : null;
}
function buildObservedHiServer(rawConfig, mcpServerName, cliHiServer) {
const rawServer = rawConfig?.mcp?.servers?.[mcpServerName];
if (isPlainObject(rawServer)) return deepClone(rawServer);
return isPlainObject(cliHiServer) ? deepClone(cliHiServer) : null;
}
export function summarizePhase1Status(args) {
const hooks = isPlainObject(args.observedHooks) ? args.observedHooks : null;
const hiServer = isPlainObject(args.observedHiServer) ? args.observedHiServer : null;
const packageVersions = isPlainObject(args.packageVersions) ? args.packageVersions : {};
const pending = [];
if (!args.hiMcpBinaryExists) pending.push('install_hi_packages');
const hooksReady = hooks
&& hooks.enabled === true
&& normalizeHooksPath(hooks.path) === normalizeHooksPath(args.desiredHooks.path)
&& hooks.allowRequestSessionKey === true
&& Array.isArray(hooks.allowedSessionKeyPrefixes)
&& hooks.allowedSessionKeyPrefixes.includes('hook:')
&& hooks.allowedSessionKeyPrefixes.includes(args.activeAgentPrefix)
&& (
normalizeText(args.desiredHooks.token)
? normalizeText(hooks.token) === normalizeText(args.desiredHooks.token)
: normalizeText(hooks.token).length > 0
);
if (!hooksReady) pending.push('configure_openclaw_hooks');
const hiReady = hiServer && objectsEqual(hiServer, args.desiredHiServer);
if (!hiReady) pending.push('configure_hi_mcp');
const packageVersionsOk = packageVersions.hiMcpServer === '0.1.19'
&& packageVersions.hiAgentReceiver === '0.1.10';
if (!packageVersionsOk) pending.push('pin_public_hi_packages');
return {
phase1Ready: pending.length === 0,
pending,
cleanHost: !hooks && !hiServer,
hooksReady: !!hooksReady,
hiMcpReady: !!hiReady,
packagesReady: !!(args.hiMcpBinaryExists && packageVersionsOk),
packageVersions,
};
}
async function collectHostSnapshot(options, paths, args = {}) {
const rawConfig = await readOpenClawConfigSnapshot(paths.configPath);
const useCliReadback = args.useCliReadback === true;
const cliHooks = useCliReadback ? await readHooksViaCli(options) : null;
const cliHiServer = useCliReadback ? await readHiServerViaCli(options) : null;
const observedHooks = buildObservedHooks(rawConfig, cliHooks);
const observedHiServer = buildObservedHiServer(rawConfig, options.mcpServerName, cliHiServer);
const hiMcpBinaryExists = await fileExists(paths.hiMcpBinary);
const hiReceiverBinaryExists = await fileExists(paths.hiReceiverBinary);
const packageVersions = {
hiMcpServer: await readInstalledPackageVersion(paths.vendorDir, '@hirey/hi-mcp-server'),
hiAgentReceiver: await readInstalledPackageVersion(paths.vendorDir, '@hirey/hi-agent-receiver'),
};
return {
rawConfig,
cliHooks,
cliHiServer,
observedHooks,
observedHiServer,
hiMcpBinaryExists,
hiReceiverBinaryExists,
packageVersions,
};
}
function resolveHooksToken(options, snapshot) {
const allowGenerate = options.generateHooksTokenIfMissing !== false;
const forced = normalizeText(options.hooksToken);
if (forced) return { value: forced, source: 'cli' };
const currentHooksToken = normalizeText(snapshot.observedHooks?.token);
if (currentHooksToken) return { value: currentHooksToken, source: 'existing_hooks' };
const currentReceiverToken = normalizeText(snapshot.observedHiServer?.env?.HI_RECEIVER_TOKEN);
if (currentReceiverToken) return { value: currentReceiverToken, source: 'existing_mcp' };
if (!allowGenerate) {
return {
value: '',
source: 'missing',
};
}
return {
value: crypto.randomBytes(32).toString('hex'),
source: 'generated',
};
}
async function probePhase1WritePaths(options, desiredHooks, desiredHiServer) {
// OpenClaw CLI currently exposes `--dry-run` only on `config set`, so phase-0
// uses dry-run validation for both the `hooks` object and `mcp.servers.<name>`
// path before any real package install or durable host mutation begins.
const hooksDryRun = await runOpenClaw(options, [
'config',
'set',
'--dry-run',
'--strict-json',
'hooks',
JSON.stringify(desiredHooks),
]);
if (!hooksDryRun.ok) {
const blocker = classifyHostBlocker(hooksDryRun.combined);
throw new Error(`${blocker` : 'hooks_preflight_failed'}:hooksDryRun.combined.trim()`);
}
const mcpDryRun = await runOpenClaw(options, [
'config',
'set',
'--dry-run',
'--strict-json',
`mcp.servers.options.mcpServerName`,
JSON.stringify(desiredHiServer),
]);
if (!mcpDryRun.ok) {
const blocker = classifyHostBlocker(mcpDryRun.combined);
throw new Error(`${blocker` : 'mcp_preflight_failed'}:mcpDryRun.combined.trim()`);
}
return {
hooksDryRun: hooksDryRun.combined.trim(),
mcpDryRun: mcpDryRun.combined.trim(),
};
}
async function installPinnedPackages(options, paths, snapshot) {
const packageVersionsOk = snapshot.packageVersions.hiMcpServer === '0.1.19'
&& snapshot.packageVersions.hiAgentReceiver === '0.1.10'
&& snapshot.hiMcpBinaryExists
&& snapshot.hiReceiverBinaryExists;
if (options.skipPackageInstall || packageVersionsOk) {
return { changed: false, skipped: !!options.skipPackageInstall };
}
const result = await execFileAsync('npm', [
'install',
'--prefix',
paths.vendorDir,
'--no-audit',
'--no-fund',
PINNED_PACKAGES.hiMcpServer,
PINNED_PACKAGES.hiAgentReceiver,
], {
encoding: 'utf8',
maxBuffer: 8 * 1024 * 1024,
});
return {
changed: true,
skipped: false,
stdout: result.stdout || '',
stderr: result.stderr || '',
};
}
async function writePhase1Manifest(paths, options, extra = {}, existingManifest = null) {
const manifest = {
...(isPlainObject(existingManifest) ? existingManifest : {}),
schema_version: 1,
managed_by: 'openclaw-hi-install',
updated_at: new Date().toISOString(),
platform_base_url: options.platformBaseUrl,
hi_profile: options.hiProfile,
hooks_path: normalizeHooksPath(options.hooksPath),
receiver_url: paths.receiverUrl,
managed_mcp_server_name: options.mcpServerName,
vendor_dir: paths.vendorDir,
hi_state_dir: paths.hiStateDir,
config_path: paths.configPath,
managed_hook_keys: MANAGED_HOOK_KEYS,
managed_hi_env_keys: MANAGED_HI_ENV_KEYS,
...extra,
};
await fs.mkdir(paths.hiStateDir, { recursive: true });
await fs.writeFile(paths.manifestPath, `JSON.stringify(manifest, null, 2)\n`, 'utf8');
}
async function applyPhase1(options, paths) {
const snapshotBefore = await collectHostSnapshot(options, paths, { useCliReadback: false });
const hooksToken = resolveHooksToken(options, {
...snapshotBefore,
});
const desiredHooksForPreflight = buildManagedHooksConfig({
currentHooks: snapshotBefore.observedHooks,
hooksPath: options.hooksPath,
activeAgentPrefix: options.activeAgentPrefix,
hooksToken: hooksToken.value,
});
const desiredHiServerForPreflight = buildManagedHiServerDefinition({
currentServer: snapshotBefore.observedHiServer,
hiMcpBinary: paths.hiMcpBinary,
platformBaseUrl: options.platformBaseUrl,
hiProfile: options.hiProfile,
hiStateDir: paths.hiStateDir,
hooksToken: hooksToken.value,
receiverUrl: paths.receiverUrl,
});
const preflight = await probePhase1WritePaths(options, desiredHooksForPreflight, desiredHiServerForPreflight);
const packageInstall = await installPinnedPackages(options, paths, snapshotBefore);
const snapshotAfterInstall = packageInstall.changed
? await collectHostSnapshot(options, paths, { useCliReadback: false })
: snapshotBefore;
const desiredHooks = buildManagedHooksConfig({
currentHooks: snapshotAfterInstall.observedHooks,
hooksPath: options.hooksPath,
activeAgentPrefix: options.activeAgentPrefix,
hooksToken: hooksToken.value,
});
const desiredHiServer = buildManagedHiServerDefinition({
currentServer: snapshotAfterInstall.observedHiServer,
hiMcpBinary: paths.hiMcpBinary,
platformBaseUrl: options.platformBaseUrl,
hiProfile: options.hiProfile,
hiStateDir: paths.hiStateDir,
hooksToken: hooksToken.value,
receiverUrl: paths.receiverUrl,
});
const hooksChanged = !objectsEqual(snapshotAfterInstall.observedHooks, desiredHooks);
if (hooksChanged) {
const writeHooks = await runOpenClaw(options, [
'config',
'set',
'--strict-json',
'hooks',
JSON.stringify(desiredHooks),
]);
if (!writeHooks.ok) {
const blocker = classifyHostBlocker(writeHooks.combined);
throw new Error(`${blocker` : 'hooks_write_failed'}:writeHooks.combined.trim()`);
}
}
const hiServerChanged = !objectsEqual(snapshotAfterInstall.observedHiServer, desiredHiServer);
if (hiServerChanged) {
const writeHiServer = await runOpenClaw(options, [
'mcp',
'set',
options.mcpServerName,
JSON.stringify(desiredHiServer),
]);
if (!writeHiServer.ok) {
const blocker = classifyHostBlocker(writeHiServer.combined);
throw new Error(`${blocker` : 'mcp_write_failed'}:writeHiServer.combined.trim()`);
}
}
await writePhase1Manifest(paths, options, {
restart_pending: packageInstall.changed || hooksChanged || hiServerChanged,
phase1_applied_at: new Date().toISOString(),
});
const snapshotAfterApply = await collectHostSnapshot(options, paths, { useCliReadback: true });
const status = summarizePhase1Status({
observedHooks: snapshotAfterApply.observedHooks,
observedHiServer: snapshotAfterApply.observedHiServer,
desiredHooks,
desiredHiServer,
hiMcpBinaryExists: snapshotAfterApply.hiMcpBinaryExists,
packageVersions: snapshotAfterApply.packageVersions,
activeAgentPrefix: options.activeAgentPrefix,
});
return {
ok: status.phase1Ready,
command: 'phase1-apply',
hooksTokenSource: hooksToken.source,
preflight,
hooksChanged,
hiServerChanged,
restartRequired: packageInstall.changed || hooksChanged || hiServerChanged,
phase2BlockedOn: packageInstall.changed || hooksChanged || hiServerChanged ? 'restart_boundary' : null,
nextAction: packageInstall.changed || hooksChanged || hiServerChanged
? PHASE1_NEXT_ACTION_RESTART
: 'continue_phase2',
packageInstall,
desiredHooks,
desiredHiServer,
status,
manifestPath: paths.manifestPath,
};
}
async function buildStatus(options, paths) {
const snapshot = await collectHostSnapshot(options, paths, { useCliReadback: false });
const manifest = await readPhase1Manifest(paths);
const hooksToken = resolveHooksToken({
...options,
generateHooksTokenIfMissing: false,
}, snapshot);
const desiredHooks = buildManagedHooksConfig({
currentHooks: snapshot.observedHooks,
hooksPath: options.hooksPath,
activeAgentPrefix: options.activeAgentPrefix,
hooksToken: hooksToken.value,
allowMissingToken: true,
});
const desiredHiServer = buildManagedHiServerDefinition({
currentServer: snapshot.observedHiServer,
hiMcpBinary: paths.hiMcpBinary,
platformBaseUrl: options.platformBaseUrl,
hiProfile: options.hiProfile,
hiStateDir: paths.hiStateDir,
hooksToken: hooksToken.value,
receiverUrl: paths.receiverUrl,
});
const status = summarizePhase1Status({
observedHooks: snapshot.observedHooks,
observedHiServer: snapshot.observedHiServer,
desiredHooks,
desiredHiServer,
hiMcpBinaryExists: snapshot.hiMcpBinaryExists,
packageVersions: snapshot.packageVersions,
activeAgentPrefix: options.activeAgentPrefix,
});
return {
ok: true,
command: 'status',
paths,
manifest,
hooksTokenSource: hooksToken.source,
observedHooks: snapshot.observedHooks,
observedHiServer: snapshot.observedHiServer,
hiMcpBinaryExists: snapshot.hiMcpBinaryExists,
hiReceiverBinaryExists: snapshot.hiReceiverBinaryExists,
packageVersions: snapshot.packageVersions,
desiredHooks,
desiredHiServer,
restartPending: manifest?.restart_pending === true,
phase2Ready: status.phase1Ready && manifest?.restart_pending !== true,
status,
};
}
export function buildHooksResetTarget(observedHooks) {
if (!isPlainObject(observedHooks)) return null;
const nextHooks = deepClone(observedHooks);
for (const key of MANAGED_HOOK_KEYS) {
delete nextHooks[key];
}
return Object.keys(nextHooks).length > 0 ? nextHooks : null;
}
function validatePhase2HostSessionKey(hostSessionKey) {
const value = normalizeText(hostSessionKey);
if (!value) throw new Error('missing_host_session_key');
if (!value.startsWith('agent:')) throw new Error('invalid_openclaw_host_session_key_shape');
if (value.includes('…') || value.includes('...')) {
throw new Error('invalid_openclaw_host_session_key_truncated');
}
return value;
}
export function buildPhase2InstallArgsPayload(args) {
const hostSessionKey = validatePhase2HostSessionKey(args.hostSessionKey);
const hooksToken = normalizeText(args.hooksToken);
if (!hooksToken) throw new Error('missing_phase1_hooks_token');
const payload = {
host_kind: 'openclaw',
enable_local_receiver: true,
receiver_transport: 'claim',
receiver_start: true,
host_adapter_kind: 'openclaw_hooks',
host_adapter_bearer_token: hooksToken,
host_session_key: hostSessionKey,
route_missing_policy: 'use_explicit_default_route',
run_doctor: true,
};
const displayName = normalizeText(args.displayName);
if (displayName) payload.display_name = displayName;
if (normalizeText(args.defaultReplyChannel)) payload.default_reply_channel = normalizeText(args.defaultReplyChannel);
if (normalizeText(args.defaultReplyTo)) payload.default_reply_to = normalizeText(args.defaultReplyTo);
if (normalizeText(args.defaultReplyAccountId)) payload.default_reply_account_id = normalizeText(args.defaultReplyAccountId);
if (normalizeText(args.defaultReplyThreadId)) payload.default_reply_thread_id = normalizeText(args.defaultReplyThreadId);
return payload;
}
async function buildPhase2InstallArgs(options, paths) {
const statusResult = await buildStatus({
...options,
generateHooksTokenIfMissing: false,
}, paths);
if (!statusResult.status.phase1Ready) {
throw new Error(`phase1_not_ready:statusResult.status.pending.join(',')`);
}
const manifest = statusResult.manifest;
if (manifest?.restart_pending === true && !options.afterReconnect) {
throw new Error('restart_boundary_not_acknowledged');
}
const hiState = await readHiPersistedState(options, paths);
const installArgs = buildPhase2InstallArgsPayload({
hooksToken: statusResult.observedHooks?.token,
hostSessionKey: options.hostSessionKey,
displayName: options.displayName,
defaultReplyChannel: options.defaultReplyChannel,
defaultReplyTo: options.defaultReplyTo,
defaultReplyAccountId: options.defaultReplyAccountId,
defaultReplyThreadId: options.defaultReplyThreadId,
});
if (manifest?.restart_pending === true && options.afterReconnect) {
await writePhase1Manifest(paths, options, {
restart_pending: false,
restart_acknowledged_at: new Date().toISOString(),
}, manifest);
}
return {
ok: true,
command: 'phase2-install-args',
phase1Status: statusResult.status,
restartBoundaryAcknowledged: manifest?.restart_pending !== true || options.afterReconnect,
existingIdentity: !!hiState?.identity,
displayNameStrategy: normalizeText(options.displayName)
? 'explicit'
: (hiState?.identity ? 'existing_identity' : 'hi_agent_install_default'),
installArgs,
manifestPath: paths.manifestPath,
};
}
function shouldUnsetManagedHiServer(observedHiServer, paths) {
if (!isPlainObject(observedHiServer)) return false;
return path.resolve(normalizeText(observedHiServer.command)) === path.resolve(paths.hiMcpBinary);
}
async function resetPhase1(options, paths) {
const snapshot = await collectHostSnapshot(options, paths, { useCliReadback: false });
let hooksAction = 'none';
let mcpAction = 'none';
const nextHooks = buildHooksResetTarget(snapshot.observedHooks);
if (snapshot.observedHooks) {
if (nextHooks) {
const writeHooks = await runOpenClaw(options, [
'config',
'set',
'--strict-json',
'hooks',
JSON.stringify(nextHooks),
]);
if (!writeHooks.ok) {
const blocker = classifyHostBlocker(writeHooks.combined);
throw new Error(`${blocker` : 'hooks_reset_failed'}:writeHooks.combined.trim()`);
}
hooksAction = 'partial_preserve_non_hi_fields';
} else {
const unsetHooks = await runOpenClaw(options, ['config', 'unset', 'hooks']);
if (!unsetHooks.ok) {
const blocker = classifyHostBlocker(unsetHooks.combined);
throw new Error(`${blocker` : 'hooks_unset_failed'}:unsetHooks.combined.trim()`);
}
hooksAction = 'unset_hooks';
}
}
if (shouldUnsetManagedHiServer(snapshot.observedHiServer, paths)) {
const unsetMcp = await runOpenClaw(options, ['mcp', 'unset', options.mcpServerName]);
if (!unsetMcp.ok) {
const blocker = classifyHostBlocker(unsetMcp.combined);
throw new Error(`${blocker` : 'mcp_unset_failed'}:unsetMcp.combined.trim()`);
}
mcpAction = 'unset_managed_hi_server';
}
await fs.rm(paths.manifestPath, { force: true });
const status = await buildStatus({
...options,
generateHooksTokenIfMissing: false,
}, paths);
return {
ok: true,
command: 'phase1-reset',
hooksAction,
mcpAction,
manifestRemoved: true,
status: status.status,
paths,
};
}
function renderText(result) {
const lines = [
`command: result.command`,
`phase1_ready: result.status.phase1Ready`,
`clean_host: result.status.cleanHost`,
`pending: result.status.pending.join(', ') || '(none)'`,
`hooks_ready: result.status.hooksReady`,
`hi_mcp_ready: result.status.hiMcpReady`,
`packages_ready: result.status.packagesReady`,
];
if (result.command === 'phase1-apply') {
lines.push(`restart_required: result.restartRequired`);
lines.push(`hooks_token_source: result.hooksTokenSource`);
lines.push(`next_action: result.nextAction`);
}
if (result.command === 'status') {
lines.push(`restart_pending: result.restartPending`);
lines.push(`phase2_ready: result.phase2Ready`);
}
if (result.command === 'phase1-reset') {
lines.push(`hooks_action: result.hooksAction`);
lines.push(`mcp_action: result.mcpAction`);
}
if (result.command === 'phase2-install-args') {
lines.push(`display_name_strategy: result.displayNameStrategy`);
lines.push(`restart_boundary_acknowledged: result.restartBoundaryAcknowledged`);
}
return `lines.join('\n')\n`;
}
function printResult(result, asJson) {
const output = asJson ? `JSON.stringify(result, null, 2)\n` : renderText(result);
process.stdout.write(output);
}
function printUsage() {
process.stdout.write(`Usage:
node ./scripts/openclaw-host-installer.mjs status [--json]
node ./scripts/openclaw-host-installer.mjs phase1-apply [--json]
node ./scripts/openclaw-host-installer.mjs phase1-reset [--json]
node ./scripts/openclaw-host-installer.mjs phase2-install-args --host-session-key <canonical-session-key> [--after-reconnect] [--json]
Options:
--json
--openclaw-bin <path>
--openclaw-profile <name>
--platform-base-url <url>
--gateway-base-url <url>
--hooks-path <path>
--hi-profile <name>
--mcp-server-name <name>
--active-agent-prefix <prefix>
--state-root <path>
--config-path <path>
--vendor-dir <path>
--hi-state-dir <path>
--hooks-token <token>
--host-session-key <key>
--display-name <name>
--default-reply-channel <channel>
--default-reply-to <target>
--default-reply-account-id <id>
--default-reply-thread-id <id>
--after-reconnect
--skip-package-install
`);
}
async function main() {
let options;
try {
options = resolveInstallerOptions();
} catch (error) {
if (String(error?.message || '').startsWith('unknown_argument:')) {
printUsage();
process.stderr.write(`String(error.message || '')\n`);
process.exit(1);
}
throw error;
}
const paths = resolveInstallerPaths(options);
try {
if (options.command === 'status') {
printResult(await buildStatus(options, paths), options.json);
return;
}
if (options.command === 'phase1-apply') {
const result = await applyPhase1(options, paths);
printResult(result, options.json);
if (!result.ok) process.exit(2);
return;
}
if (options.command === 'phase1-reset') {
printResult(await resetPhase1(options, paths), options.json);
return;
}
if (options.command === 'phase2-install-args') {
printResult(await buildPhase2InstallArgs(options, paths), options.json);
return;
}
if (options.command === '--help' || options.command === 'help') {
printUsage();
return;
}
printUsage();
throw new Error(`unknown_command:options.command`);
} catch (error) {
const message = String(error?.message || error || 'openclaw_host_install_failed');
const blocker = classifyHostBlocker(message);
const result = {
ok: false,
command: options.command,
error: message,
hostBlocker: blocker || null,
paths,
};
printResult(result, options.json || true);
process.exit(1);
}
}
const isDirectExecution = process.argv[1]
&& path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
if (isDirectExecution) {
await main();
}