@openclaw-cloud/agent-controller 0.1.3 → 0.1.5

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.
Files changed (64) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/CLAUDE.md +59 -0
  3. package/__tests__/api.test.ts +123 -0
  4. package/__tests__/board-handler.test.ts +6 -3
  5. package/__tests__/chat.test.ts +191 -0
  6. package/__tests__/config.test.ts +100 -0
  7. package/__tests__/file-write.test.ts +119 -0
  8. package/__tests__/gateway-adapter.test.ts +366 -0
  9. package/__tests__/gateway-client.test.ts +272 -0
  10. package/__tests__/onboarding.test.ts +55 -0
  11. package/__tests__/package-install.test.ts +109 -0
  12. package/__tests__/pair.test.ts +60 -0
  13. package/__tests__/self-update.test.ts +123 -0
  14. package/__tests__/stop.test.ts +38 -0
  15. package/bin/agent-controller.js +15 -2
  16. package/dist/commands/self-update.d.ts +1 -0
  17. package/dist/commands/self-update.js +41 -0
  18. package/dist/commands/self-update.js.map +1 -0
  19. package/dist/connection.js +24 -0
  20. package/dist/connection.js.map +1 -1
  21. package/dist/handlers/board-handler.d.ts +1 -0
  22. package/dist/handlers/board-handler.js +5 -2
  23. package/dist/handlers/board-handler.js.map +1 -1
  24. package/dist/handlers/chat.d.ts +4 -0
  25. package/dist/handlers/chat.js +62 -0
  26. package/dist/handlers/chat.js.map +1 -0
  27. package/dist/handlers/file-write.d.ts +2 -0
  28. package/dist/handlers/file-write.js +59 -0
  29. package/dist/handlers/file-write.js.map +1 -0
  30. package/dist/handlers/onboarding.d.ts +2 -0
  31. package/dist/handlers/onboarding.js +18 -0
  32. package/dist/handlers/onboarding.js.map +1 -0
  33. package/dist/handlers/package-install.d.ts +2 -0
  34. package/dist/handlers/package-install.js +59 -0
  35. package/dist/handlers/package-install.js.map +1 -0
  36. package/dist/index.js +18 -3
  37. package/dist/index.js.map +1 -1
  38. package/dist/openclaw/gateway-adapter.d.ts +22 -0
  39. package/dist/openclaw/gateway-adapter.js +108 -0
  40. package/dist/openclaw/gateway-adapter.js.map +1 -0
  41. package/dist/openclaw/gateway-client.d.ts +21 -0
  42. package/dist/openclaw/gateway-client.js +114 -0
  43. package/dist/openclaw/gateway-client.js.map +1 -0
  44. package/dist/openclaw/index.d.ts +8 -0
  45. package/dist/openclaw/index.js +12 -0
  46. package/dist/openclaw/index.js.map +1 -0
  47. package/dist/openclaw/types.d.ts +36 -0
  48. package/dist/openclaw/types.js +2 -0
  49. package/dist/openclaw/types.js.map +1 -0
  50. package/dist/types.d.ts +2 -1
  51. package/package.json +4 -2
  52. package/src/commands/self-update.ts +43 -0
  53. package/src/connection.ts +25 -0
  54. package/src/handlers/board-handler.ts +5 -2
  55. package/src/handlers/chat.ts +79 -0
  56. package/src/handlers/file-write.ts +65 -0
  57. package/src/handlers/onboarding.ts +19 -0
  58. package/src/handlers/package-install.ts +69 -0
  59. package/src/index.ts +19 -3
  60. package/src/openclaw/gateway-adapter.ts +129 -0
  61. package/src/openclaw/gateway-client.ts +131 -0
  62. package/src/openclaw/index.ts +17 -0
  63. package/src/openclaw/types.ts +41 -0
  64. package/src/types.ts +5 -1
@@ -0,0 +1,43 @@
1
+ import { exec } from 'node:child_process';
2
+
3
+ const PACKAGE_NAME = '@openclaw-cloud/agent-controller';
4
+
5
+ function execAsync(cmd: string, timeout: number): Promise<string> {
6
+ return new Promise((resolve, reject) => {
7
+ exec(cmd, { timeout }, (err, stdout) => {
8
+ if (err) reject(err);
9
+ else resolve(stdout.toString().trim());
10
+ });
11
+ });
12
+ }
13
+
14
+ export async function selfUpdate(currentVersion: string): Promise<void> {
15
+ console.log(`Current version: ${currentVersion}`);
16
+
17
+ let latestVersion: string;
18
+ try {
19
+ latestVersion = await execAsync(`npm view ${PACKAGE_NAME} version`, 10_000);
20
+ } catch (err) {
21
+ console.error('Failed to fetch latest version:', err instanceof Error ? err.message : err);
22
+ process.exit(1);
23
+ return; // unreachable in production; guards test flow when process.exit is mocked
24
+ }
25
+
26
+ console.log(`Latest version: ${latestVersion}`);
27
+
28
+ if (currentVersion === latestVersion) {
29
+ console.log('Already up to date.');
30
+ return;
31
+ }
32
+
33
+ console.log(`Updating from ${currentVersion} to ${latestVersion}...`);
34
+ try {
35
+ await execAsync(`npm install -g ${PACKAGE_NAME}@latest`, 120_000);
36
+ console.log(`Successfully updated to ${latestVersion}. Restarting...`);
37
+ process.exit(0);
38
+ return; // unreachable in production; guards test flow when process.exit is mocked
39
+ } catch (err) {
40
+ console.error('Update failed:', err instanceof Error ? err.message : err);
41
+ process.exit(1);
42
+ }
43
+ }
package/src/connection.ts CHANGED
@@ -8,6 +8,10 @@ import { handleConfig } from './handlers/config.js';
8
8
  import { handlePair } from './handlers/pair.js';
9
9
  import { handleStop } from './handlers/stop.js';
10
10
  import { handleBackup } from './handlers/backup.js';
11
+ import { handleChatListSessions, handleChatHistory, handleChatSend } from './handlers/chat.js';
12
+ import { handlePackageInstall } from './handlers/package-install.js';
13
+ import { handleFileWrite } from './handlers/file-write.js';
14
+ import { handleOnboardingComplete } from './handlers/onboarding.js';
11
15
 
12
16
  export interface ConnectionOptions {
13
17
  url: string;
@@ -24,6 +28,9 @@ const handlers: Record<string, (cmd: AgentCommand) => Promise<AgentResponse>> =
24
28
  pair: handlePair,
25
29
  stop: handleStop,
26
30
  backup: handleBackup,
31
+ package_install: handlePackageInstall,
32
+ file_write: handleFileWrite,
33
+ onboarding_complete: handleOnboardingComplete,
27
34
  };
28
35
 
29
36
  async function fetchCentrifugoToken(backendUrl: string, agentToken: string, agentId: string): Promise<string> {
@@ -100,6 +107,24 @@ export function createConnection(opts: ConnectionOptions): {
100
107
  return;
101
108
  }
102
109
 
110
+ // Chat commands — handle before generic dispatch
111
+ if (command.type === 'chat_list_sessions' || command.type === 'chat_history' || command.type === 'chat_send') {
112
+ const publishFn = async (data: unknown): Promise<void> => { await client.publish(commandChannel, data); };
113
+ console.log(`[WS] Handling chat command: type=${command.type} id=${command.id}`);
114
+ switch (command.type) {
115
+ case 'chat_list_sessions':
116
+ handleChatListSessions(command, publishFn).catch(console.error);
117
+ break;
118
+ case 'chat_history':
119
+ handleChatHistory(command, publishFn).catch(console.error);
120
+ break;
121
+ case 'chat_send':
122
+ handleChatSend(command, publishFn, opts.agentId).catch(console.error);
123
+ break;
124
+ }
125
+ return;
126
+ }
127
+
103
128
  const handler = handlers[command.type];
104
129
  if (!handler) {
105
130
  console.error(`[WS] Unknown command type: ${command.type}`);
@@ -8,6 +8,7 @@ export class BoardHandler {
8
8
  private boardState: BoardState = { state: 'idle', cardId: null };
9
9
  private myColumnIds: string[] = [];
10
10
  private boardId: string | null = null;
11
+ private workspaceId: string | null = null;
11
12
  private api: AgentApi;
12
13
 
13
14
  constructor(api: AgentApi) {
@@ -18,12 +19,14 @@ export class BoardHandler {
18
19
  try {
19
20
  const data = await this.api.get('/api/agent/board') as BoardInfo;
20
21
  this.boardId = data.board.id;
22
+ this.workspaceId = data.board.workspaceId;
21
23
  this.myColumnIds = data.board.myColumnIds;
22
- console.log(`Board initialized: ${this.boardId}, watching ${this.myColumnIds.length} column(s)`);
24
+ console.log(`Board initialized: ${this.boardId} (workspace: ${this.workspaceId}), watching ${this.myColumnIds.length} column(s)`);
23
25
 
24
26
  // Backfill: check for existing unassigned cards in my columns
25
27
  await this.checkQueue(data);
26
- return this.boardId;
28
+ // Return workspaceId for channel subscription (board:<workspaceId>)
29
+ return this.workspaceId;
27
30
  } catch (err) {
28
31
  console.error('Board initialization failed:', err instanceof Error ? err.message : err);
29
32
  return null;
@@ -0,0 +1,79 @@
1
+ import type { AgentCommand } from '../types.js';
2
+ import { getChatProvider } from '../openclaw/index.js';
3
+
4
+ export async function handleChatListSessions(
5
+ command: AgentCommand,
6
+ publish: (data: unknown) => Promise<void>,
7
+ ): Promise<void> {
8
+ const provider = getChatProvider();
9
+ if (!provider) {
10
+ await publish({ type: 'chat_sessions_response', correlationId: command.id, sessions: [], error: 'Chat provider not initialized' });
11
+ return;
12
+ }
13
+ try {
14
+ const sessions = await provider.listSessions();
15
+ await publish({ type: 'chat_sessions_response', correlationId: command.id, sessions });
16
+ } catch (err) {
17
+ await publish({ type: 'chat_sessions_response', correlationId: command.id, sessions: [], error: err instanceof Error ? err.message : String(err) });
18
+ }
19
+ }
20
+
21
+ export async function handleChatHistory(
22
+ command: AgentCommand,
23
+ publish: (data: unknown) => Promise<void>,
24
+ ): Promise<void> {
25
+ const { sessionKey, limit } = command.payload as { sessionKey: string; limit?: number };
26
+ const provider = getChatProvider();
27
+ if (!provider) {
28
+ await publish({ type: 'chat_history_response', correlationId: command.id, messages: [], error: 'Chat provider not initialized' });
29
+ return;
30
+ }
31
+ try {
32
+ const messages = await provider.getHistory(sessionKey, limit ?? 200);
33
+ await publish({ type: 'chat_history_response', correlationId: command.id, messages });
34
+ } catch (err) {
35
+ await publish({ type: 'chat_history_response', correlationId: command.id, messages: [], error: err instanceof Error ? err.message : String(err) });
36
+ }
37
+ }
38
+
39
+ export async function handleChatSend(
40
+ command: AgentCommand,
41
+ publish: (data: unknown) => Promise<void>,
42
+ agentId: string,
43
+ ): Promise<void> {
44
+ const { sessionKey, text, attachments } = command.payload as {
45
+ sessionKey: string;
46
+ text: string;
47
+ attachments?: Array<{ type: 'image'; mimeType: string; content: string }>;
48
+ };
49
+ const correlationId = command.id;
50
+
51
+ // Typing indicator
52
+ await publish({ type: 'chat_typing', agentId, state: true }).catch(() => {});
53
+
54
+ const provider = getChatProvider();
55
+ if (!provider) {
56
+ await publish({ type: 'chat_response', correlationId, sessionKey, text: '', error: 'Chat provider not initialized' });
57
+ return;
58
+ }
59
+
60
+ try {
61
+ await provider.sendMessage({
62
+ sessionKey,
63
+ text,
64
+ idempotencyKey: correlationId,
65
+ attachments, // base64 inline — Centrifugo limit raised to 10MB
66
+ onDelta: async (accumulated) => {
67
+ await publish({ type: 'chat_delta', correlationId, sessionKey, text: accumulated }).catch(() => {});
68
+ },
69
+ onDone: async (finalText) => {
70
+ await publish({ type: 'chat_response', correlationId, sessionKey, text: finalText }).catch(() => {});
71
+ },
72
+ onError: async (error) => {
73
+ await publish({ type: 'chat_response', correlationId, sessionKey, text: '', error }).catch(() => {});
74
+ },
75
+ });
76
+ } catch (err) {
77
+ await publish({ type: 'chat_response', correlationId, sessionKey, text: '', error: err instanceof Error ? err.message : String(err) }).catch(() => {});
78
+ }
79
+ }
@@ -0,0 +1,65 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import type { AgentCommand, AgentResponse } from '../types.js';
4
+
5
+ const WORKSPACE_DIR = process.env.WORKSPACE_DIR ?? '/etc/openclaw/workspace';
6
+
7
+ export async function handleFileWrite(command: AgentCommand): Promise<AgentResponse> {
8
+ const filePath = command.payload.path as string;
9
+ const content = command.payload.content;
10
+ const overwrite = (command.payload.overwrite as boolean | undefined) ?? true;
11
+
12
+ if (!filePath || content === undefined) {
13
+ return {
14
+ id: command.id,
15
+ type: 'file_write',
16
+ success: false,
17
+ error: 'Missing "path" or "content" in payload',
18
+ };
19
+ }
20
+
21
+ if (filePath.includes('..') || filePath.startsWith('/')) {
22
+ return {
23
+ id: command.id,
24
+ type: 'file_write',
25
+ success: false,
26
+ error: 'Invalid path: must not contain ".." or start with "/"',
27
+ };
28
+ }
29
+
30
+ const resolvedPath = path.join(WORKSPACE_DIR, filePath);
31
+
32
+ try {
33
+ if (!overwrite) {
34
+ try {
35
+ await fs.access(resolvedPath);
36
+ // File exists — skip
37
+ return {
38
+ id: command.id,
39
+ type: 'file_write',
40
+ success: true,
41
+ data: { path: resolvedPath, written: false, skipped: true },
42
+ };
43
+ } catch {
44
+ // File does not exist — proceed with write
45
+ }
46
+ }
47
+
48
+ await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
49
+ await fs.writeFile(resolvedPath, content as string, 'utf-8');
50
+
51
+ return {
52
+ id: command.id,
53
+ type: 'file_write',
54
+ success: true,
55
+ data: { path: resolvedPath, written: true },
56
+ };
57
+ } catch (err) {
58
+ return {
59
+ id: command.id,
60
+ type: 'file_write',
61
+ success: false,
62
+ error: err instanceof Error ? err.message : String(err),
63
+ };
64
+ }
65
+ }
@@ -0,0 +1,19 @@
1
+ import { exec } from 'node:child_process';
2
+ import type { AgentCommand, AgentResponse } from '../types.js';
3
+
4
+ export function handleOnboardingComplete(command: AgentCommand): Promise<AgentResponse> {
5
+ return new Promise((resolve) => {
6
+ exec('openclaw gateway restart', { timeout: 30_000 }, (error, stdout, stderr) => {
7
+ resolve({
8
+ id: command.id,
9
+ type: 'onboarding_complete',
10
+ success: !error,
11
+ data: {
12
+ stdout: stdout.toString(),
13
+ stderr: stderr.toString(),
14
+ },
15
+ ...(error ? { error: error.message } : {}),
16
+ });
17
+ });
18
+ });
19
+ }
@@ -0,0 +1,69 @@
1
+ import { exec } from 'node:child_process';
2
+ import type { AgentCommand, AgentResponse } from '../types.js';
3
+
4
+ const INSTALL_TIMEOUT_MS = 300_000; // 5 minutes total
5
+
6
+ function execPackage(cmd: string, timeoutMs: number): Promise<void> {
7
+ return new Promise((resolve, reject) => {
8
+ exec(cmd, { timeout: timeoutMs }, (error) => {
9
+ if (error) reject(error);
10
+ else resolve();
11
+ });
12
+ });
13
+ }
14
+
15
+ export async function handlePackageInstall(command: AgentCommand): Promise<AgentResponse> {
16
+ const packages = command.payload.packages as {
17
+ apt?: string[];
18
+ npm?: string[];
19
+ pip?: string[];
20
+ } | undefined;
21
+
22
+ const apt = packages?.apt ?? [];
23
+ const npm = packages?.npm ?? [];
24
+ const pip = packages?.pip ?? [];
25
+
26
+ if (apt.length === 0 && npm.length === 0 && pip.length === 0) {
27
+ return {
28
+ id: command.id,
29
+ type: 'package_install',
30
+ success: true,
31
+ data: { installed: { apt: [], npm: [], pip: [] }, errors: [] },
32
+ };
33
+ }
34
+
35
+ const installed: { apt: string[]; npm: string[]; pip: string[] } = { apt: [], npm: [], pip: [] };
36
+ const errors: Array<{ name: string; error: string }> = [];
37
+ const deadline = Date.now() + INSTALL_TIMEOUT_MS;
38
+
39
+ async function tryInstall(pkg: string, cmd: string, type: 'apt' | 'npm' | 'pip'): Promise<void> {
40
+ const remaining = deadline - Date.now();
41
+ if (remaining <= 0) {
42
+ errors.push({ name: pkg, error: 'Install timeout exceeded' });
43
+ return;
44
+ }
45
+ try {
46
+ await execPackage(cmd, remaining);
47
+ installed[type].push(pkg);
48
+ } catch (err) {
49
+ errors.push({ name: pkg, error: err instanceof Error ? err.message : String(err) });
50
+ }
51
+ }
52
+
53
+ for (const pkg of apt) {
54
+ await tryInstall(pkg, `apt-get install -y ${pkg}`, 'apt');
55
+ }
56
+ for (const pkg of npm) {
57
+ await tryInstall(pkg, `npm install -g ${pkg}`, 'npm');
58
+ }
59
+ for (const pkg of pip) {
60
+ await tryInstall(pkg, `pip install ${pkg}`, 'pip');
61
+ }
62
+
63
+ return {
64
+ id: command.id,
65
+ type: 'package_install',
66
+ success: errors.length === 0,
67
+ data: { installed, errors },
68
+ };
69
+ }
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ import { createConnection } from './connection.js';
2
2
  import { startHeartbeat } from './heartbeat.js';
3
3
  import { createAgentApi } from './api.js';
4
4
  import { BoardHandler } from './handlers/board-handler.js';
5
+ import { createChatProvider } from './openclaw/index.js';
5
6
  import type { BoardEvent } from './types.js';
6
7
 
7
8
  function requireEnv(name: string): string {
@@ -30,6 +31,21 @@ export function main(): void {
30
31
 
31
32
  const { client } = createConnection({ url, token, agentId, backendUrl: backendUrl || undefined });
32
33
 
34
+ // Chat provider via OpenClaw Gateway WS
35
+ const gatewayWsUrl = process.env.OPENCLAW_GATEWAY_WS || 'ws://localhost:18789';
36
+ const gatewayToken = process.env.OPENCLAW_GATEWAY_TOKEN || '';
37
+
38
+ if (gatewayToken) {
39
+ const chatProvider = createChatProvider(gatewayWsUrl, gatewayToken);
40
+ chatProvider.connect().then(() => {
41
+ console.log('OpenClaw Gateway WS connected (chat provider ready)');
42
+ }).catch((e) => {
43
+ console.warn('Chat provider connect failed:', e instanceof Error ? e.message : e);
44
+ });
45
+ } else {
46
+ console.warn('OPENCLAW_GATEWAY_TOKEN not set, chat provider disabled');
47
+ }
48
+
33
49
  // Board handler (optional — requires BACKEND_URL and AGENT_TOKEN)
34
50
  let boardHandler: BoardHandler | null = null;
35
51
  const apiBaseUrl = process.env.BACKEND_URL || backendUrl;
@@ -38,13 +54,13 @@ export function main(): void {
38
54
  boardHandler = new BoardHandler(api);
39
55
 
40
56
  // Initialize board handler after connection is established
41
- boardHandler.initialize().then((boardId) => {
42
- if (!boardId) {
57
+ boardHandler.initialize().then((workspaceId) => {
58
+ if (!workspaceId) {
43
59
  console.log('No board found for this agent, board handler dormant');
44
60
  return;
45
61
  }
46
62
 
47
- const boardChannel = `board:${boardId}`;
63
+ const boardChannel = `board:${workspaceId}`;
48
64
  const boardSub = client.newSubscription(boardChannel);
49
65
 
50
66
  boardSub.on('publication', (ctx) => {
@@ -0,0 +1,129 @@
1
+ import { GatewayClient } from './gateway-client.js';
2
+ import type { IChatProvider, ChatSession, ChatMessage, ChatAttachment } from './types.js';
3
+
4
+ export class OpenclawGatewayAdapter implements IChatProvider {
5
+ private client: GatewayClient;
6
+ private streamCallbacks = new Map<string, {
7
+ onDelta?: (text: string) => void;
8
+ onDone?: (text: string) => void;
9
+ onError?: (error: string) => void;
10
+ lastText: string;
11
+ }>();
12
+
13
+ constructor(url: string, token: string) {
14
+ this.client = new GatewayClient(url, token);
15
+ this.client.setEventHandler(this.handleEvent.bind(this));
16
+ }
17
+
18
+ async connect(): Promise<void> {
19
+ await this.client.connect();
20
+ }
21
+
22
+ disconnect(): void {
23
+ this.client.disconnect();
24
+ }
25
+
26
+ isConnected(): boolean {
27
+ return this.client.isConnected();
28
+ }
29
+
30
+ async listSessions(): Promise<ChatSession[]> {
31
+ const result = await this.client.request('sessions.list', {});
32
+ return (result?.sessions ?? []).map((s: any) => ({
33
+ key: s.key,
34
+ sessionId: s.sessionId ?? '',
35
+ kind: s.kind ?? 'direct',
36
+ updatedAt: s.updatedAt ?? 0,
37
+ model: s.model,
38
+ }));
39
+ }
40
+
41
+ async getHistory(sessionKey: string, limit = 200): Promise<ChatMessage[]> {
42
+ const result = await this.client.request('chat.history', { sessionKey, limit });
43
+ return (result?.messages ?? [])
44
+ .filter((m: any) => m.role === 'user' || m.role === 'assistant')
45
+ .map((m: any) => {
46
+ const content = Array.isArray(m.content)
47
+ ? m.content.filter((c: any) => c.type === 'text').map((c: any) => c.text).join('')
48
+ : String(m.content ?? '');
49
+ return {
50
+ role: m.role,
51
+ content,
52
+ timestamp: typeof m.timestamp === 'number'
53
+ ? new Date(m.timestamp).toISOString()
54
+ : m.timestamp ?? new Date().toISOString(),
55
+ };
56
+ });
57
+ }
58
+
59
+ async sendMessage(params: {
60
+ sessionKey: string;
61
+ text: string;
62
+ idempotencyKey: string;
63
+ attachments?: ChatAttachment[];
64
+ onDelta?: (text: string) => void;
65
+ onDone?: (text: string) => void;
66
+ onError?: (error: string) => void;
67
+ }): Promise<void> {
68
+ // Register stream callbacks BEFORE sending
69
+ this.streamCallbacks.set(params.idempotencyKey, {
70
+ onDelta: params.onDelta,
71
+ onDone: params.onDone,
72
+ onError: params.onError,
73
+ lastText: '',
74
+ });
75
+
76
+ const payload: any = {
77
+ sessionKey: params.sessionKey,
78
+ message: params.text,
79
+ deliver: false,
80
+ idempotencyKey: params.idempotencyKey,
81
+ };
82
+ if (params.attachments?.length) {
83
+ payload.attachments = params.attachments;
84
+ }
85
+
86
+ try {
87
+ await this.client.request('chat.send', payload);
88
+ // Request accepted — response comes via events
89
+ } catch (err) {
90
+ this.streamCallbacks.delete(params.idempotencyKey);
91
+ params.onError?.(err instanceof Error ? err.message : String(err));
92
+ }
93
+ }
94
+
95
+ async abort(sessionKey: string, runId?: string): Promise<void> {
96
+ await this.client.request('chat.abort', { sessionKey, runId }).catch(() => {});
97
+ }
98
+
99
+ private handleEvent(event: { event: string; payload: any }): void {
100
+ const data = event.payload;
101
+ if (!data || !data.sessionKey) return;
102
+
103
+ // Stream events have runId matching idempotencyKey
104
+ const runId = data.runId;
105
+ const cb = runId ? this.streamCallbacks.get(runId) : null;
106
+ if (!cb) return;
107
+
108
+ if (data.state === 'delta' && data.message != null) {
109
+ // message is accumulated text
110
+ const text = typeof data.message === 'string'
111
+ ? data.message
112
+ : Array.isArray(data.message)
113
+ ? data.message.filter((c: any) => c.type === 'text').map((c: any) => c.text).join('')
114
+ : '';
115
+ cb.lastText = text;
116
+ cb.onDelta?.(text);
117
+ } else if (data.state === 'final') {
118
+ this.streamCallbacks.delete(runId!);
119
+ cb.onDone?.(cb.lastText);
120
+ } else if (data.state === 'aborted') {
121
+ this.streamCallbacks.delete(runId!);
122
+ cb.onDone?.(cb.lastText); // still send whatever we have
123
+ } else if (data.state === 'error') {
124
+ this.streamCallbacks.delete(runId!);
125
+ const errMsg = typeof data.error === 'string' ? data.error : data.error?.message ?? 'Unknown error';
126
+ cb.onError?.(errMsg);
127
+ }
128
+ }
129
+ }
@@ -0,0 +1,131 @@
1
+ import WebSocket from 'ws';
2
+ import { randomUUID } from 'crypto';
3
+
4
+ interface PendingRequest {
5
+ resolve: (payload: any) => void;
6
+ reject: (err: Error) => void;
7
+ }
8
+
9
+ export type EventHandler = (event: { event: string; payload: any; seq?: number }) => void;
10
+
11
+ export class GatewayClient {
12
+ private ws: WebSocket | null = null;
13
+ private pending = new Map<string, PendingRequest>();
14
+ private onEvent: EventHandler | null = null;
15
+ private connected = false;
16
+ private token: string;
17
+ private url: string;
18
+
19
+ constructor(url: string, token: string) {
20
+ this.url = url;
21
+ this.token = token;
22
+ }
23
+
24
+ setEventHandler(handler: EventHandler): void {
25
+ this.onEvent = handler;
26
+ }
27
+
28
+ connect(): Promise<void> {
29
+ return new Promise((resolve, reject) => {
30
+ this.ws = new WebSocket(this.url);
31
+
32
+ this.ws.on('open', () => {
33
+ // Wait for connect.challenge event, then authenticate
34
+ });
35
+
36
+ this.ws.on('message', (raw) => {
37
+ try {
38
+ const msg = JSON.parse(raw.toString());
39
+ this.handleMessage(msg);
40
+ } catch {}
41
+ });
42
+
43
+ this.ws.on('error', (e) => {
44
+ if (!this.connected) reject(e);
45
+ });
46
+
47
+ this.ws.on('close', () => {
48
+ this.connected = false;
49
+ this.flushPending(new Error('Connection closed'));
50
+ });
51
+
52
+ // Wait for connect.challenge → authenticate → resolve
53
+ const origHandler = this.onEvent;
54
+ this.onEvent = async (ev) => {
55
+ if (ev.event === 'connect.challenge') {
56
+ try {
57
+ await this.request('connect', {
58
+ auth: { token: this.token },
59
+ scopes: ['operator.admin'],
60
+ caps: [],
61
+ });
62
+ this.connected = true;
63
+ this.onEvent = origHandler;
64
+ resolve();
65
+ } catch (e) {
66
+ reject(e);
67
+ }
68
+ }
69
+ };
70
+
71
+ setTimeout(() => {
72
+ if (!this.connected) reject(new Error('Connect timeout'));
73
+ }, 10_000);
74
+ });
75
+ }
76
+
77
+ disconnect(): void {
78
+ this.ws?.close();
79
+ this.ws = null;
80
+ this.connected = false;
81
+ this.flushPending(new Error('Disconnected'));
82
+ }
83
+
84
+ isConnected(): boolean {
85
+ return this.connected && this.ws?.readyState === WebSocket.OPEN;
86
+ }
87
+
88
+ request(method: string, params: any): Promise<any> {
89
+ return new Promise((resolve, reject) => {
90
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
91
+ return reject(new Error('Not connected'));
92
+ }
93
+
94
+ const id = randomUUID();
95
+ this.pending.set(id, { resolve, reject });
96
+ this.ws.send(JSON.stringify({ type: 'req', id, method, params }));
97
+
98
+ // Timeout per request
99
+ setTimeout(() => {
100
+ if (this.pending.has(id)) {
101
+ this.pending.delete(id);
102
+ reject(new Error(`Request timeout: ${method}`));
103
+ }
104
+ }, 120_000);
105
+ });
106
+ }
107
+
108
+ private handleMessage(msg: any): void {
109
+ if (msg.type === 'event') {
110
+ this.onEvent?.(msg);
111
+ return;
112
+ }
113
+ if (msg.type === 'res') {
114
+ const req = this.pending.get(msg.id);
115
+ if (!req) return;
116
+ this.pending.delete(msg.id);
117
+ if (msg.ok) {
118
+ req.resolve(msg.payload);
119
+ } else {
120
+ req.reject(new Error(msg.error?.message ?? 'Request failed'));
121
+ }
122
+ }
123
+ }
124
+
125
+ private flushPending(err: Error): void {
126
+ for (const [, req] of this.pending) {
127
+ req.reject(err);
128
+ }
129
+ this.pending.clear();
130
+ }
131
+ }
@@ -0,0 +1,17 @@
1
+ export { OpenclawGatewayAdapter } from './gateway-adapter.js';
2
+ export type { IChatProvider, ChatSession, ChatMessage, ChatAttachment } from './types.js';
3
+
4
+ import { OpenclawGatewayAdapter } from './gateway-adapter.js';
5
+ import type { IChatProvider } from './types.js';
6
+
7
+ let _provider: IChatProvider | null = null;
8
+
9
+ export function createChatProvider(url: string, token: string): IChatProvider & { connect(): Promise<void>; disconnect(): void } {
10
+ const adapter = new OpenclawGatewayAdapter(url, token);
11
+ _provider = adapter;
12
+ return adapter;
13
+ }
14
+
15
+ export function getChatProvider(): IChatProvider | null {
16
+ return _provider;
17
+ }