@2389research/coven-openclaw 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/channel.ts ADDED
@@ -0,0 +1,253 @@
1
+ // ABOUTME: ChannelPlugin definition wiring all coven adapters together.
2
+ // ABOUTME: Follows the same pattern as openclaw's Discord channel plugin.
3
+
4
+ import type { ChannelPlugin } from "openclaw/plugin-sdk";
5
+ import {
6
+ listCovenAccountIds,
7
+ resolveCovenAccount,
8
+ type ResolvedCovenAccount,
9
+ } from "./config.js";
10
+ import { normalizeCovenTarget } from "./normalize.js";
11
+ import { CovenGrpcClient, type CovenWelcome } from "./grpc-client.js";
12
+ import { covenSendMessageToInbound } from "./protocol.js";
13
+ import { probeCoven, type CovenProbeResult } from "./status.js";
14
+ import { registerCovenMcp, unregisterCovenMcp } from "./mcp-bridge.js";
15
+
16
+ // Track active gRPC clients per account
17
+ const activeClients = new Map<string, CovenGrpcClient>();
18
+
19
+ export const covenPlugin: ChannelPlugin<ResolvedCovenAccount, CovenProbeResult> = {
20
+ id: "coven" as any,
21
+
22
+ meta: {
23
+ id: "coven" as any,
24
+ label: "Coven Gateway",
25
+ selectionLabel: "Coven Gateway (gRPC)",
26
+ docsPath: "/channels/coven",
27
+ blurb: "Connect openclaw agents to coven-gateway via gRPC AgentStream.",
28
+ order: 90,
29
+ },
30
+
31
+ capabilities: {
32
+ chatTypes: ["direct", "thread"],
33
+ media: true,
34
+ },
35
+
36
+ streaming: {
37
+ blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
38
+ },
39
+
40
+ reload: { configPrefixes: ["channels.coven"] },
41
+
42
+ config: {
43
+ listAccountIds: (cfg) => listCovenAccountIds(cfg),
44
+ resolveAccount: (cfg, accountId) => resolveCovenAccount(cfg, accountId),
45
+ defaultAccountId: () => "default",
46
+ setAccountEnabled: ({ cfg, accountId, enabled }) => {
47
+ const next = structuredClone(cfg);
48
+ if (!next.channels) next.channels = {};
49
+ if (!next.channels.coven) next.channels.coven = {};
50
+ if (!next.channels.coven.accounts) next.channels.coven.accounts = {};
51
+ if (!next.channels.coven.accounts[accountId]) {
52
+ next.channels.coven.accounts[accountId] = {};
53
+ }
54
+ next.channels.coven.accounts[accountId].enabled = enabled;
55
+ return next;
56
+ },
57
+ isConfigured: (account) => Boolean(account.endpoint?.trim()),
58
+ describeAccount: (account) => ({
59
+ accountId: account.accountId,
60
+ name: `coven-${account.accountId}`,
61
+ enabled: account.enabled,
62
+ configured: Boolean(account.endpoint?.trim()),
63
+ }),
64
+ },
65
+
66
+ messaging: {
67
+ normalizeTarget: normalizeCovenTarget,
68
+ targetResolver: {
69
+ looksLikeId: (raw) => raw.startsWith("coven:"),
70
+ hint: "coven:<accountId>:<threadId>",
71
+ },
72
+ },
73
+
74
+ heartbeat: {
75
+ checkReady: async ({ cfg, accountId }) => {
76
+ const account = resolveCovenAccount(cfg, accountId);
77
+ const client = activeClients.get(account.accountId);
78
+ if (!client || !client.connected) {
79
+ return { ok: false, reason: "coven-not-connected" };
80
+ }
81
+ return { ok: true, reason: "ok" };
82
+ },
83
+ },
84
+
85
+ status: {
86
+ defaultRuntime: {
87
+ accountId: "default",
88
+ running: false,
89
+ lastStartAt: null,
90
+ lastStopAt: null,
91
+ lastError: null,
92
+ },
93
+ probeAccount: async ({ account, timeoutMs }) =>
94
+ probeCoven(account, timeoutMs),
95
+ buildAccountSnapshot: ({ account, runtime, probe }) => ({
96
+ accountId: account.accountId,
97
+ name: `coven-${account.accountId}`,
98
+ enabled: account.enabled,
99
+ configured: Boolean(account.endpoint?.trim()),
100
+ running: runtime?.running ?? false,
101
+ lastStartAt: runtime?.lastStartAt ?? null,
102
+ lastStopAt: runtime?.lastStopAt ?? null,
103
+ lastError: runtime?.lastError ?? null,
104
+ probe,
105
+ }),
106
+ },
107
+
108
+ outbound: {
109
+ deliveryMode: "direct",
110
+ chunker: null,
111
+ textChunkLimit: 4000,
112
+ sendText: async ({ to, text }) => {
113
+ const parts = to.split(":");
114
+ if (parts.length < 3 || parts[0] !== "coven") {
115
+ throw new Error(`Invalid coven target: ${to}`);
116
+ }
117
+ const accountId = parts[1];
118
+ const client = activeClients.get(accountId);
119
+ if (!client || !client.connected) {
120
+ throw new Error(`No active coven connection for account: ${accountId}`);
121
+ }
122
+
123
+ client.send({
124
+ response: {
125
+ request_id: "",
126
+ text,
127
+ },
128
+ });
129
+
130
+ return {
131
+ channel: "coven" as any,
132
+ messageId: `coven-${Date.now()}`,
133
+ };
134
+ },
135
+ sendMedia: async ({ to }) => {
136
+ const parts = to.split(":");
137
+ const accountId = parts[1] ?? "default";
138
+ const client = activeClients.get(accountId);
139
+ if (!client || !client.connected) {
140
+ throw new Error(`No active coven connection for account: ${accountId}`);
141
+ }
142
+
143
+ return {
144
+ channel: "coven" as any,
145
+ messageId: `coven-${Date.now()}`,
146
+ };
147
+ },
148
+ },
149
+
150
+ gateway: {
151
+ startAccount: async (ctx) => {
152
+ const account = ctx.account;
153
+ const agentName = account.mode === "single" ? "openclaw" : "default";
154
+
155
+ ctx.log?.info?.(
156
+ `[${account.accountId}] connecting to coven-gateway at ${account.endpoint}`
157
+ );
158
+
159
+ const client = new CovenGrpcClient(account);
160
+ activeClients.set(account.accountId, client);
161
+
162
+ try {
163
+ const welcome = await client.connect(agentName);
164
+ ctx.log?.info?.(
165
+ `[${account.accountId}] registered as ${welcome.agent_id} (instance: ${welcome.instance_id})`
166
+ );
167
+
168
+ if (welcome.mcp_endpoint && welcome.mcp_token) {
169
+ registerCovenMcp(welcome);
170
+ ctx.log?.info?.(
171
+ `[${account.accountId}] MCP bridge registered at ${welcome.mcp_endpoint}`
172
+ );
173
+ }
174
+
175
+ client.on("send_message", (msg: any) => {
176
+ const inbound = covenSendMessageToInbound(msg, account.accountId);
177
+ ctx.log?.info?.(
178
+ `[${account.accountId}] inbound message: ${inbound.requestId} from ${inbound.sender}`
179
+ );
180
+ });
181
+
182
+ client.on("inject_context", (msg: any) => {
183
+ ctx.log?.info?.(
184
+ `[${account.accountId}] context injection: ${msg.injection_id}`
185
+ );
186
+ client.send({
187
+ injection_ack: {
188
+ injection_id: msg.injection_id,
189
+ accepted: true,
190
+ },
191
+ });
192
+ });
193
+
194
+ client.on("cancel_request", (msg: any) => {
195
+ ctx.log?.info?.(
196
+ `[${account.accountId}] cancel request: ${msg.request_id}`
197
+ );
198
+ client.send({
199
+ response: {
200
+ request_id: msg.request_id,
201
+ cancelled: { reason: msg.reason ?? "server_requested" },
202
+ },
203
+ });
204
+ });
205
+
206
+ client.on("shutdown", (msg: any) => {
207
+ ctx.log?.warn?.(
208
+ `[${account.accountId}] server shutdown: ${msg.reason}`
209
+ );
210
+ client.disconnect();
211
+ });
212
+
213
+ client.on("disconnected", async () => {
214
+ ctx.log?.warn?.(
215
+ `[${account.accountId}] disconnected, attempting reconnect...`
216
+ );
217
+ unregisterCovenMcp();
218
+ const newWelcome = await client.reconnect();
219
+ if (newWelcome) {
220
+ ctx.log?.info?.(
221
+ `[${account.accountId}] reconnected as ${newWelcome.agent_id}`
222
+ );
223
+ if (newWelcome.mcp_endpoint && newWelcome.mcp_token) {
224
+ registerCovenMcp(newWelcome);
225
+ }
226
+ } else {
227
+ ctx.log?.error?.(
228
+ `[${account.accountId}] reconnection failed after max attempts`
229
+ );
230
+ }
231
+ });
232
+
233
+ client.on("error", (err: Error) => {
234
+ ctx.log?.error?.(
235
+ `[${account.accountId}] gRPC error: ${err.message}`
236
+ );
237
+ });
238
+ } catch (err) {
239
+ activeClients.delete(account.accountId);
240
+ throw err;
241
+ }
242
+
243
+ return () => {
244
+ const c = activeClients.get(account.accountId);
245
+ if (c) {
246
+ c.disconnect();
247
+ activeClients.delete(account.accountId);
248
+ unregisterCovenMcp();
249
+ }
250
+ };
251
+ },
252
+ },
253
+ };
@@ -0,0 +1,40 @@
1
+ // ABOUTME: JSON schema definition for the coven channel configuration.
2
+ // ABOUTME: Defines the shape of accounts under channels.coven in openclaw config.
3
+
4
+ export type CovenAuthMethod = "ssh" | "jwt" | "none";
5
+ export type CovenAgentMode = "multi" | "single";
6
+
7
+ export type CovenReconnectConfig = {
8
+ maxAttempts: number;
9
+ baseDelayMs: number;
10
+ maxDelayMs: number;
11
+ };
12
+
13
+ export type CovenAccountConfig = {
14
+ endpoint?: string;
15
+ mode?: CovenAgentMode;
16
+ agentFilter?: string[];
17
+ tls?: boolean;
18
+ authMethod?: CovenAuthMethod;
19
+ sshKeyPath?: string;
20
+ jwtSecret?: string;
21
+ jwtToken?: string;
22
+ heartbeatIntervalMs?: number;
23
+ reconnect?: Partial<CovenReconnectConfig>;
24
+ enabled?: boolean;
25
+ };
26
+
27
+ export type CovenChannelConfig = {
28
+ accounts?: Record<string, CovenAccountConfig>;
29
+ };
30
+
31
+ export const DEFAULT_ENDPOINT = "localhost:50051";
32
+ export const DEFAULT_MODE: CovenAgentMode = "multi";
33
+ export const DEFAULT_AUTH_METHOD: CovenAuthMethod = "ssh";
34
+ export const DEFAULT_HEARTBEAT_INTERVAL_MS = 30000;
35
+
36
+ export const DEFAULT_RECONNECT: CovenReconnectConfig = {
37
+ maxAttempts: 10,
38
+ baseDelayMs: 1000,
39
+ maxDelayMs: 60000,
40
+ };
package/src/config.ts ADDED
@@ -0,0 +1,91 @@
1
+ // ABOUTME: Account resolution and config adapter for the coven channel plugin.
2
+ // ABOUTME: Lists accounts, resolves account settings with defaults, handles enable/disable.
3
+
4
+ import {
5
+ DEFAULT_ENDPOINT,
6
+ DEFAULT_MODE,
7
+ DEFAULT_AUTH_METHOD,
8
+ DEFAULT_HEARTBEAT_INTERVAL_MS,
9
+ DEFAULT_RECONNECT,
10
+ type CovenAgentMode,
11
+ type CovenAuthMethod,
12
+ type CovenReconnectConfig,
13
+ type CovenAccountConfig,
14
+ } from "./config-schema.js";
15
+ import { readLinkedConfig } from "./linked-config.js";
16
+
17
+ export type ResolvedCovenAccount = {
18
+ accountId: string;
19
+ endpoint: string;
20
+ mode: CovenAgentMode;
21
+ agentFilter: string[];
22
+ tls: boolean;
23
+ authMethod: CovenAuthMethod;
24
+ sshKeyPath: string;
25
+ jwtSecret: string;
26
+ jwtToken: string;
27
+ heartbeatIntervalMs: number;
28
+ reconnect: CovenReconnectConfig;
29
+ enabled: boolean;
30
+ };
31
+
32
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
33
+ type AnyConfig = Record<string, any>;
34
+
35
+ function getCovenSection(cfg: AnyConfig): AnyConfig | undefined {
36
+ return cfg?.channels?.coven;
37
+ }
38
+
39
+ function getAccountsMap(
40
+ cfg: AnyConfig
41
+ ): Record<string, CovenAccountConfig> | undefined {
42
+ return getCovenSection(cfg)?.accounts;
43
+ }
44
+
45
+ export function listCovenAccountIds(cfg: AnyConfig): string[] {
46
+ const accounts = getAccountsMap(cfg);
47
+ if (!accounts) return [];
48
+ return Object.keys(accounts);
49
+ }
50
+
51
+ export function resolveCovenAccount(
52
+ cfg: AnyConfig,
53
+ accountId?: string | null
54
+ ): ResolvedCovenAccount {
55
+ const resolvedId = accountId ?? "default";
56
+ const accounts = getAccountsMap(cfg) ?? {};
57
+ const raw: CovenAccountConfig = accounts[resolvedId] ?? {};
58
+
59
+ // Read linked config once for fallback values
60
+ const linked = readLinkedConfig();
61
+
62
+ // Use linked config gateway when no explicit endpoint is configured
63
+ const endpoint =
64
+ raw.endpoint ?? (linked?.gateway ? linked.gateway : DEFAULT_ENDPOINT);
65
+
66
+ // Default to "jwt" auth when linked config provides a token and no explicit authMethod
67
+ const authMethod =
68
+ raw.authMethod ?? (linked?.token ? "jwt" : DEFAULT_AUTH_METHOD);
69
+
70
+ // Populate jwtToken from linked config when not explicitly set
71
+ const jwtToken = raw.jwtToken ?? (linked?.token ? linked.token : "");
72
+
73
+ return {
74
+ accountId: resolvedId,
75
+ endpoint,
76
+ mode: raw.mode ?? DEFAULT_MODE,
77
+ agentFilter: raw.agentFilter ?? [],
78
+ tls: raw.tls ?? false,
79
+ authMethod,
80
+ sshKeyPath: raw.sshKeyPath ?? "~/.ssh/id_ed25519",
81
+ jwtSecret: raw.jwtSecret ?? "",
82
+ jwtToken,
83
+ heartbeatIntervalMs: raw.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS,
84
+ reconnect: {
85
+ maxAttempts: raw.reconnect?.maxAttempts ?? DEFAULT_RECONNECT.maxAttempts,
86
+ baseDelayMs: raw.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT.baseDelayMs,
87
+ maxDelayMs: raw.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT.maxDelayMs,
88
+ },
89
+ enabled: raw.enabled ?? true,
90
+ };
91
+ }
@@ -0,0 +1,270 @@
1
+ // ABOUTME: gRPC stream management for the coven-gateway AgentStream connection.
2
+ // ABOUTME: Handles connect, disconnect, reconnect with exponential backoff, and heartbeat.
3
+
4
+ import * as grpc from "@grpc/grpc-js";
5
+ import * as protoLoader from "@grpc/proto-loader";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { EventEmitter } from "node:events";
9
+ import type { ResolvedCovenAccount } from "./config.js";
10
+ import { buildRegistration, type RegistrationParams } from "./registration.js";
11
+
12
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
+ const PROTO_PATH = path.resolve(__dirname, "../proto/coven.proto");
14
+
15
+ export const PROTO_VERSION = "2026-02-14";
16
+
17
+ export type CovenWelcome = {
18
+ server_id: string;
19
+ agent_id: string;
20
+ instance_id: string;
21
+ principal_id: string;
22
+ available_tools: unknown[];
23
+ mcp_token: string;
24
+ mcp_endpoint: string;
25
+ secrets: Record<string, string>;
26
+ };
27
+
28
+ type ProtoDefinition = ReturnType<typeof grpc.loadPackageDefinition>;
29
+
30
+ function loadProtoDefinition(): ProtoDefinition {
31
+ const packageDef = protoLoader.loadSync(PROTO_PATH, {
32
+ keepCase: true,
33
+ longs: String,
34
+ enums: String,
35
+ defaults: true,
36
+ oneofs: true,
37
+ });
38
+ return grpc.loadPackageDefinition(packageDef);
39
+ }
40
+
41
+ let cachedProto: ProtoDefinition | null = null;
42
+
43
+ function getProto(): ProtoDefinition {
44
+ if (!cachedProto) {
45
+ cachedProto = loadProtoDefinition();
46
+ }
47
+ return cachedProto;
48
+ }
49
+
50
+ export class CovenGrpcClient extends EventEmitter {
51
+ private account: ResolvedCovenAccount;
52
+ private grpcClient: any = null;
53
+ private stream: any = null;
54
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
55
+ private _connected = false;
56
+ private _heartbeatsSent = 0;
57
+ private _agentName: string | null = null;
58
+ private _disconnecting = false;
59
+
60
+ constructor(account: ResolvedCovenAccount) {
61
+ super();
62
+ this.account = account;
63
+ }
64
+
65
+ get connected(): boolean {
66
+ return this._connected;
67
+ }
68
+
69
+ get heartbeatsSent(): number {
70
+ return this._heartbeatsSent;
71
+ }
72
+
73
+ async connect(
74
+ agentName: string,
75
+ registrationOverrides?: Partial<RegistrationParams>
76
+ ): Promise<CovenWelcome> {
77
+ this._agentName = agentName;
78
+
79
+ const proto = getProto() as any;
80
+ const credentials = this.account.tls
81
+ ? grpc.credentials.createSsl()
82
+ : grpc.credentials.createInsecure();
83
+
84
+ this.grpcClient = new proto.coven.CovenControl(
85
+ this.account.endpoint,
86
+ credentials
87
+ );
88
+
89
+ const metadata = new grpc.Metadata();
90
+ if (this.account.jwtToken) {
91
+ metadata.set("authorization", `Bearer ${this.account.jwtToken}`);
92
+ }
93
+
94
+ this.stream = this.grpcClient.AgentStream(metadata);
95
+
96
+ this._disconnecting = false;
97
+
98
+ return new Promise<CovenWelcome>((resolve, reject) => {
99
+ const registration = buildRegistration({
100
+ account: this.account,
101
+ agentName,
102
+ ...registrationOverrides,
103
+ });
104
+
105
+ const timeoutId = setTimeout(() => {
106
+ cleanup();
107
+ reject(new Error("Registration timed out waiting for Welcome"));
108
+ }, 10000);
109
+
110
+ const cleanup = () => {
111
+ clearTimeout(timeoutId);
112
+ if (this.stream) {
113
+ this.stream.removeListener("data", onData);
114
+ this.stream.removeListener("error", onError);
115
+ }
116
+ };
117
+
118
+ const onData = (msg: any) => {
119
+ if (msg.welcome) {
120
+ cleanup();
121
+ this._connected = true;
122
+ this.startHeartbeat();
123
+ this.attachStreamHandlers();
124
+ resolve(msg.welcome);
125
+ } else if (msg.registration_error) {
126
+ cleanup();
127
+ reject(
128
+ new Error(
129
+ `Registration rejected: ${msg.registration_error.reason}`
130
+ )
131
+ );
132
+ }
133
+ };
134
+
135
+ const onError = (err: Error) => {
136
+ cleanup();
137
+ reject(err);
138
+ };
139
+
140
+ this.stream.on("data", onData);
141
+ this.stream.on("error", onError);
142
+
143
+ this.stream.write({ register: registration });
144
+ });
145
+ }
146
+
147
+ async disconnect(): Promise<void> {
148
+ this._disconnecting = true;
149
+ this.stopHeartbeat();
150
+ this._connected = false;
151
+
152
+ const stream = this.stream;
153
+ const client = this.grpcClient;
154
+
155
+ this.stream = null;
156
+ this.grpcClient = null;
157
+
158
+ if (stream) {
159
+ // Replace all listeners with a no-op error sink to absorb any
160
+ // CANCELLED errors that fire asynchronously after close/end.
161
+ stream.removeAllListeners("data");
162
+ stream.removeAllListeners("end");
163
+ stream.removeAllListeners("error");
164
+ stream.on("error", () => {});
165
+ stream.end();
166
+ }
167
+
168
+ if (client) {
169
+ client.close();
170
+ }
171
+ }
172
+
173
+ send(message: Record<string, unknown>): void {
174
+ if (!this.stream) {
175
+ throw new Error("Not connected — call connect() first");
176
+ }
177
+ this.stream.write(message);
178
+ }
179
+
180
+ private startHeartbeat(): void {
181
+ this.stopHeartbeat();
182
+ this.heartbeatTimer = setInterval(() => {
183
+ if (this._connected && this.stream) {
184
+ this.stream.write({
185
+ heartbeat: { timestamp_ms: Date.now() },
186
+ });
187
+ this._heartbeatsSent++;
188
+ }
189
+ }, this.account.heartbeatIntervalMs);
190
+ }
191
+
192
+ private stopHeartbeat(): void {
193
+ if (this.heartbeatTimer) {
194
+ clearInterval(this.heartbeatTimer);
195
+ this.heartbeatTimer = null;
196
+ }
197
+ }
198
+
199
+ private attachStreamHandlers(): void {
200
+ if (!this.stream) return;
201
+
202
+ this.stream.on("data", (msg: any) => {
203
+ if (msg.send_message) {
204
+ this.emit("send_message", msg.send_message);
205
+ } else if (msg.inject_context) {
206
+ this.emit("inject_context", msg.inject_context);
207
+ } else if (msg.cancel_request) {
208
+ this.emit("cancel_request", msg.cancel_request);
209
+ } else if (msg.shutdown) {
210
+ this.emit("shutdown", msg.shutdown);
211
+ } else if (msg.tool_approval) {
212
+ this.emit("tool_approval", msg.tool_approval);
213
+ } else if (msg.pack_tool_result) {
214
+ this.emit("pack_tool_result", msg.pack_tool_result);
215
+ }
216
+ });
217
+
218
+ this.stream.on("error", (err: Error) => {
219
+ if (this._disconnecting) return;
220
+ this._connected = false;
221
+ this.stopHeartbeat();
222
+ this.emit("error", err);
223
+ });
224
+
225
+ this.stream.on("end", () => {
226
+ if (this._disconnecting) return;
227
+ this._connected = false;
228
+ this.stopHeartbeat();
229
+ this.emit("disconnected");
230
+ });
231
+ }
232
+
233
+ async reconnect(): Promise<CovenWelcome | null> {
234
+ const { maxAttempts, baseDelayMs, maxDelayMs } = this.account.reconnect;
235
+
236
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
237
+ const delay = Math.min(
238
+ baseDelayMs * Math.pow(2, attempt - 1),
239
+ maxDelayMs
240
+ );
241
+
242
+ await new Promise((r) => setTimeout(r, delay));
243
+
244
+ try {
245
+ if (this.grpcClient) {
246
+ this.grpcClient.close();
247
+ this.grpcClient = null;
248
+ }
249
+
250
+ if (!this._agentName) {
251
+ throw new Error(
252
+ "Cannot reconnect: no agent name from initial connect"
253
+ );
254
+ }
255
+
256
+ const welcome = await this.connect(this._agentName);
257
+ this.emit("reconnected", welcome);
258
+ return welcome;
259
+ } catch (err) {
260
+ this.emit("reconnect_attempt", { attempt, maxAttempts, error: err });
261
+ if (attempt === maxAttempts) {
262
+ this.emit("reconnect_failed", err);
263
+ return null;
264
+ }
265
+ }
266
+ }
267
+
268
+ return null;
269
+ }
270
+ }