diff --git a/package.json b/package.json index 47c096f..67609a0 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,9 @@ "description": "Yonexus.Client OpenClaw plugin scaffold", "type": "module", "main": "dist/plugin/index.js", + "openclaw": { + "extensions": ["./dist/Yonexus.Client/plugin/index.js"] + }, "files": [ "dist", "plugin", diff --git a/plugin/core/runtime.ts b/plugin/core/runtime.ts index e75476b..774aa74 100644 --- a/plugin/core/runtime.ts +++ b/plugin/core/runtime.ts @@ -29,6 +29,7 @@ import { } from "./state.js"; import { generateNonce, signMessage } from "../crypto/keypair.js"; import type { ClientConnectionState, ClientTransport } from "./transport.js"; +import type { ClientRuleRegistry } from "./rules.js"; export type YonexusClientPhase = | "idle" @@ -44,6 +45,8 @@ export interface YonexusClientRuntimeOptions { config: YonexusClientConfig; transport: ClientTransport; stateStore: YonexusClientStateStore; + ruleRegistry?: ClientRuleRegistry; + onAuthenticated?: () => void; now?: () => number; } @@ -118,6 +121,7 @@ export class YonexusClientRuntime { } if (!isBuiltinMessage(raw)) { + this.options.ruleRegistry?.dispatch(raw); return; } @@ -159,6 +163,7 @@ export class YonexusClientRuntime { }; await this.options.stateStore.save(this.clientState); this.phase = "authenticated"; + this.options.onAuthenticated?.(); return; } @@ -177,7 +182,17 @@ export class YonexusClientRuntime { handleTransportStateChange(state: ClientConnectionState): void { if (state === "connected") { - this.sendHello(); + // Reload state from disk before hello so that any secret written by an + // external process (e.g. a pairing script) is picked up on reconnect. + this.options.stateStore.load(this.options.config.identifier).then((fresh) => { + if (fresh) { + this.clientState = { ...this.clientState, ...fresh }; + } + this.sendHello(); + }).catch(() => { + // If reload fails, proceed with in-memory state + this.sendHello(); + }); } if (state === "disconnected") { @@ -259,7 +274,10 @@ export class YonexusClientRuntime { adminNotification: payload.adminNotification }; this.lastPairingFailure = undefined; - this.phase = payload.adminNotification === "sent" ? "waiting_pair_confirm" : "pair_required"; + // Always wait for the pairing code regardless of notification status. + // When adminNotification is "failed", the admin can retrieve the code + // via the server CLI command and deliver it through an alternate channel. + this.phase = "waiting_pair_confirm"; } private async handlePairSuccess(envelope: TypedBuiltinEnvelope<"pair_success">): Promise { @@ -316,6 +334,12 @@ export class YonexusClientRuntime { } this.lastPairingFailure = payload.reason; + // If the server lost our session (race condition), re-announce via hello + // so the server creates a new session and we can retry auth. + if (payload.reason === "not_paired" && hasClientSecret(this.clientState)) { + this.sendHello(); + return; + } this.phase = "auth_required"; } diff --git a/plugin/index.ts b/plugin/index.ts index e17e8fc..b006bd1 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -39,30 +39,82 @@ export { type ClientRuleProcessor } from "./core/rules.js"; +import path from "node:path"; +import { validateYonexusClientConfig } from "./core/config.js"; +import { createYonexusClientStateStore } from "./core/state.js"; +import { createClientTransport } from "./core/transport.js"; +import { createYonexusClientRuntime } from "./core/runtime.js"; +import { createClientRuleRegistry } from "./core/rules.js"; + export interface YonexusClientPluginManifest { readonly name: "Yonexus.Client"; readonly version: string; readonly description: string; } -export interface YonexusClientPluginRuntime { - readonly hooks: readonly []; - readonly commands: readonly []; - readonly tools: readonly []; -} - const manifest: YonexusClientPluginManifest = { name: "Yonexus.Client", version: "0.1.0", description: "Yonexus client plugin for cross-instance OpenClaw communication" }; -export function createYonexusClientPlugin(): YonexusClientPluginRuntime { - return { - hooks: [], - commands: [], - tools: [] +let _clientStarted = false; + +export function createYonexusClientPlugin(api: { rootDir: string; pluginConfig: unknown }): void { + if (_clientStarted) return; + _clientStarted = true; + + const config = validateYonexusClientConfig(api.pluginConfig); + const stateStore = createYonexusClientStateStore(path.join(api.rootDir, "state.json")); + + const ruleRegistry = createClientRuleRegistry(); + const onAuthenticatedCallbacks: Array<() => void> = []; + + let runtimeRef: ReturnType | null = null; + const transport = createClientTransport({ + config, + onMessage: (msg) => { + runtimeRef?.handleMessage(msg).catch((err: unknown) => { + console.error("[yonexus-client] message handler error:", err); + }); + }, + onStateChange: (state) => { + runtimeRef?.handleTransportStateChange(state); + } + }); + + // Expose registry and helpers for other plugins loaded in the same process + (globalThis as Record)["__yonexusClient"] = { + ruleRegistry, + sendRule: (ruleId: string, content: string): boolean => + runtimeRef?.sendRuleMessage(ruleId, content) ?? false, + submitPairingCode: (code: string): boolean => + runtimeRef?.submitPairingCode(code) ?? false, + onAuthenticated: onAuthenticatedCallbacks }; + + const runtime = createYonexusClientRuntime({ + config, + transport, + stateStore, + ruleRegistry, + onAuthenticated: () => { + for (const cb of onAuthenticatedCallbacks) cb(); + } + }); + runtimeRef = runtime; + + const shutdown = (): void => { + runtime.stop().catch((err: unknown) => { + console.error("[yonexus-client] shutdown error:", err); + }); + }; + process.once("SIGTERM", shutdown); + process.once("SIGINT", shutdown); + + runtime.start().catch((err: unknown) => { + console.error("[yonexus-client] failed to start:", err); + }); } export default createYonexusClientPlugin; diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json index ada1f87..ec65507 100644 --- a/plugin/openclaw.plugin.json +++ b/plugin/openclaw.plugin.json @@ -1,13 +1,19 @@ { + "id": "yonexus-client", "name": "Yonexus.Client", "version": "0.1.0", "description": "Yonexus client plugin for cross-instance OpenClaw communication", - "entry": "dist/plugin/index.js", + "entry": "./dist/Yonexus.Client/plugin/index.js", "permissions": [], - "config": { - "mainHost": "", - "identifier": "", - "notifyBotToken": "", - "adminUserId": "" + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "mainHost": { "type": "string" }, + "identifier": { "type": "string" }, + "notifyBotToken": { "type": "string" }, + "adminUserId": { "type": "string" } + }, + "required": ["mainHost", "identifier", "notifyBotToken", "adminUserId"] } } diff --git a/scripts/install.mjs b/scripts/install.mjs index 7fa47a5..f6abaa2 100644 --- a/scripts/install.mjs +++ b/scripts/install.mjs @@ -29,6 +29,7 @@ if (mode === "install") { fs.rmSync(targetDir, { recursive: true, force: true }); fs.cpSync(sourceDist, path.join(targetDir, "dist"), { recursive: true }); fs.copyFileSync(path.join(repoRoot, "plugin", "openclaw.plugin.json"), path.join(targetDir, "openclaw.plugin.json")); + fs.copyFileSync(path.join(repoRoot, "package.json"), path.join(targetDir, "package.json")); console.log(`Installed ${pluginName} to ${targetDir}`); process.exit(0); }