Files
openclaw/src/node-host/worker.ts
Peter Steinberger da69daeb72 feat(onboarding): recommend plugins and skills from installed apps (#109668)
* feat(onboarding): recommend plugins and skills from installed apps

Scan installed macOS apps during classic onboarding (TCC-free), gather
candidates from official catalogs + ClawHub search, let the configured
model pick genuine matches, and offer an opt-in multiselect install step.
Adds a device.apps node-host command (default-off sharing, Android-parity
envelope) so remote gateways can request a paired Mac's inventory, and a
wizard.appRecommendations kill switch. Custom setup-inference completions
no longer inherit the 32-token verification-probe output cap.

* feat(onboarding): recommend apps in guided flow

* fix(onboarding): harden app recommendations against ClawHub self-promotion

Third-party ClawHub skills are never pre-selected regardless of model tier
(publisher-controlled listing text reaches the matcher prompt and could
promote itself); their labels now say they install third-party code.
Installed-app scans follow symlinked .app bundles. Matcher output stays
bounded by the resolved model's own maxTokens budget (documented invariant).

* fix(onboarding): key official catalog candidates by resolved plugin id

Real catalog entries are package manifests without a top-level id; keying the
candidate map and channel/provider classification by entry.id collapsed the
whole official catalog into one undefined-keyed entry, so no official plugin
or channel was ever recommended. Regression test runs against the bundled
catalogs.

* fix(onboarding): satisfy lint, types, deadcode, and migration gates

Split the guided-onboarding test into a self-contained custodian suite to stay
under max-lines. Narrow app-recommendation exports (drop dead node-payload
normalizer, unexport internal types/helpers, route candidate tests through the
public API), replace map-spread with a helper, unexport device.apps result
types, add installedAppsSharing to node-host migration expectations, cast the
wizard multiselect mock, and regenerate the docs map.

* test(onboarding): register new live test in the shard classifier
2026-07-17 14:07:59 +01:00

86 lines
2.6 KiB
TypeScript

/** Private JSONL worker exposing the CLI node-host runtime to the macOS app. */
import { createInterface } from "node:readline";
import { VERSION } from "../version.js";
import { loadNodeHostConfig } from "./config.js";
import { prepareNodeHostRuntime, type NodeHostInventory } from "./runtime.js";
import {
NodeHostWorkerBridgeClient,
parseNodeHostWorkerInput,
stopNodeHostWorkerFromSignal,
} from "./worker-support.js";
function writeMessage(message: unknown): void {
process.stdout.write(`${JSON.stringify(message)}\n`);
}
function emitInventory(inventory: NodeHostInventory): void {
writeMessage({ type: "inventory", inventory });
}
export async function runNodeHostWorker(): Promise<void> {
const nodeConfig = await loadNodeHostConfig();
const prepared = await prepareNodeHostRuntime({
enableDuplexPluginCommands: true,
installedAppsSharingEnabled: nodeConfig?.installedAppsSharing === true,
});
const client = new NodeHostWorkerBridgeClient(writeMessage);
let stopping = false;
let resolveStopped: (() => void) | undefined;
const stopped = new Promise<void>((resolve) => {
resolveStopped = resolve;
});
const stop = async (exitCode: number) => {
if (stopping) {
return;
}
stopping = true;
try {
client.close();
await runtime.close();
process.exitCode = exitCode;
} finally {
resolveStopped?.();
}
};
const runtime = prepared.start({ client, onInventoryChanged: emitInventory });
writeMessage({
type: "ready",
version: VERSION,
manifest: prepared.manifest,
inventory: prepared.initialInventory,
});
const input = createInterface({ input: process.stdin, crlfDelay: Infinity });
input.on("line", (line) => {
const message = parseNodeHostWorkerInput(line);
if (!message) {
writeMessage({ type: "protocol-error", error: "invalid worker request" });
return;
}
if (message.type === "gateway-response") {
client.handleResponse(message);
return;
}
if (message.type === "stop") {
input.close();
void stop(0);
return;
}
if (message.type === "invoke-input") {
runtime.handleInput(message.invokeId, message.seq, message.payloadJSON);
return;
}
if (message.type === "invoke-cancel") {
runtime.cancel(message.invokeId);
return;
}
void runtime.invoke(message.request);
});
input.on("close", () => void stop(0));
process.once("SIGINT", () => void stopNodeHostWorkerFromSignal(input, stop, 130));
process.once("SIGTERM", () => void stopNodeHostWorkerFromSignal(input, stop, 143));
await stopped;
}