feat: wire rule registry and authenticated callback into client runtime

- Add ruleRegistry and onAuthenticated options to YonexusClientRuntime
- Dispatch non-builtin messages to rule registry
- Fire onAuthenticated callback on auth_success
- Reload persisted state on reconnect so externally-written secrets are picked up
- Re-send hello on auth_failed("not_paired") when client has a valid secret
- Always enter waiting_pair_confirm after pair_request regardless of notification status
- Expose __yonexusClient on globalThis for cross-plugin communication
- Wire onStateChange in transport creation (previously missing, prevented connection)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
h z
2026-04-10 20:14:57 +01:00
parent 57b53fc122
commit 8824e768fb
5 changed files with 105 additions and 19 deletions

View File

@@ -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<void> {
@@ -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";
}