@rethinking-studio/clawcontrol 1.0.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/README.md ADDED
@@ -0,0 +1,29 @@
1
+ # ClawControl
2
+
3
+ 配合 [ClawAI](https://apps.apple.com/app/id6759418062) iOS 应用的 OpenClaw 插件。通过该插件,你可以在手机上远程连接并控制本地的 OpenClaw。
4
+
5
+ ## 使用前准备
6
+
7
+ 想使用 iOS 端远程连接 OpenClaw,需要先安装并配置本插件。
8
+
9
+ 1. 安装插件到 OpenClaw
10
+ 2. 运行 `openclaw channels add`,选择 ClawControl 进行配置
11
+ 3. 按提示生成配对码,用 ClawAI App 扫码完成配对
12
+ 4. 配对成功后即可在手机上使用
13
+
14
+ ## 应用下载
15
+
16
+ [iOS App Store - ClawAI](https://apps.apple.com/app/id6759418062)
17
+
18
+ ## 安装
19
+
20
+ ```bash
21
+ openclaw plugins install @rethinking-studio/clawcontrol
22
+ ```
23
+
24
+ 或从 GitHub 克隆后本地安装:
25
+
26
+ ```bash
27
+ git clone https://github.com/Rethinking-studio/clawcontrol.git
28
+ openclaw plugins install ./clawcontrol
29
+ ```
package/index.ts ADDED
@@ -0,0 +1,480 @@
1
+ /**
2
+ * ClawControl Channel Plugin for OpenClaw
3
+ *
4
+ * 功能:
5
+ * 1. 提供 clawcontrol 渠道,iOS 端可通过 WebSocket 与之通信
6
+ * 2. 使用 OpenClaw 的 registerChannel API,不会阻塞 CLI
7
+ */
8
+
9
+ import os from 'os';
10
+ import type { ChannelPlugin, ChannelOnboardingAdapter } from 'openclaw/plugin-sdk';
11
+ import * as wsModule from './src/websocket.js';
12
+ import * as cli from './src/cli.js';
13
+ import * as chat from './src/chat.js';
14
+
15
+ // 渠道 ID
16
+ const CHANNEL_ID = 'clawcontrol';
17
+
18
+ // 配置类型
19
+ interface ClawControlConfig {
20
+ enabled?: boolean;
21
+ backendUrl?: string;
22
+ apiToken?: string;
23
+ deviceId?: string;
24
+ deviceName?: string;
25
+ }
26
+
27
+ interface ClawControlAccount {
28
+ accountId: string;
29
+ enabled: boolean;
30
+ configured: boolean;
31
+ name: string;
32
+ backendUrl?: string;
33
+ apiToken?: string;
34
+ deviceId?: string;
35
+ deviceName?: string;
36
+ }
37
+
38
+ // 解析配置
39
+ function resolveClawControlAccount(cfg: any): ClawControlAccount {
40
+ const channelConfig = cfg?.channels?.clawcontrol as ClawControlConfig | undefined;
41
+ const hasToken = !!channelConfig?.apiToken;
42
+
43
+ return {
44
+ accountId: 'default',
45
+ enabled: channelConfig?.enabled ?? false,
46
+ configured: hasToken,
47
+ name: channelConfig?.deviceName || 'ClawControl',
48
+ backendUrl: channelConfig?.backendUrl,
49
+ apiToken: channelConfig?.apiToken,
50
+ deviceId: channelConfig?.deviceId,
51
+ deviceName: channelConfig?.deviceName,
52
+ };
53
+ }
54
+
55
+ // ClawControl Onboarding 适配器
56
+ const clawcontrolOnboardingAdapter: ChannelOnboardingAdapter = {
57
+ channel: CHANNEL_ID,
58
+ getStatus: async ({ cfg }) => {
59
+ const account = resolveClawControlAccount(cfg);
60
+ const configured = account.configured;
61
+ const statusLines: string[] = [];
62
+
63
+ if (!configured) {
64
+ statusLines.push('ClawControl: needs token');
65
+ } else {
66
+ statusLines.push(`ClawControl: configured (device: ${account.name || 'unknown'})`);
67
+ }
68
+
69
+ return {
70
+ channel: CHANNEL_ID,
71
+ configured,
72
+ statusLines,
73
+ selectionHint: configured ? 'configured' : 'needs token',
74
+ quickstartScore: configured ? 2 : 0,
75
+ };
76
+ },
77
+ configure: async ({ cfg, prompter }) => {
78
+ const account = resolveClawControlAccount(cfg);
79
+ const defaultUrl = 'https://clawai.rethinkingstudio.com';
80
+ const baseUrl = (
81
+ process.env.CLAWCONTROL_BACKEND_URL ||
82
+ (account.backendUrl && !account.backendUrl.includes('localhost') ? account.backendUrl : null) ||
83
+ defaultUrl
84
+ ).replace(/\/$/, '');
85
+
86
+ const proceed = await prompter.confirm({
87
+ message: '是否现在生成配对码?',
88
+ initialValue: true,
89
+ });
90
+ if (!proceed) {
91
+ return { cfg };
92
+ }
93
+
94
+ await prompter.note('Creating pairing token...');
95
+
96
+ let createRes: Response;
97
+ try {
98
+ createRes = await fetch(`${baseUrl}/plugin/create-pairing-token`, {
99
+ method: 'POST',
100
+ headers: { 'Content-Type': 'application/json' },
101
+ body: JSON.stringify({ backendUrl: baseUrl }),
102
+ });
103
+ } catch (e: any) {
104
+ const code = e?.cause?.code ?? e?.code;
105
+ const msg = code === 'ECONNREFUSED'
106
+ ? `Cannot connect to ${baseUrl} - is the backend running? Default: https://clawai.rethinkingstudio.com`
107
+ : code === 'ENOTFOUND'
108
+ ? `DNS lookup failed for ${baseUrl} - check network. Set CLAWCONTROL_BACKEND_URL if using different backend`
109
+ : e?.message || String(e);
110
+ throw new Error(`Fetch failed: ${msg}`);
111
+ }
112
+
113
+ if (!createRes.ok) {
114
+ const err = await createRes.text();
115
+ throw new Error(`Failed to create pairing token: ${err}`);
116
+ }
117
+
118
+ const { pairingToken, expiresIn } = (await createRes.json()) as { pairingToken: string; expiresIn: number };
119
+
120
+ const qrContent = JSON.stringify({ backendUrl: baseUrl, pairingToken });
121
+
122
+ await prompter.note(
123
+ `Scan the QR code below with ClawControl iOS App (expires in ${expiresIn}s):`
124
+ );
125
+
126
+ const qrcode = await import('qrcode-terminal');
127
+ qrcode.default.generate(qrContent, { small: true });
128
+
129
+ console.log('\nOr manually enter pairing code:', pairingToken);
130
+ console.log('Waiting for scan...\n');
131
+
132
+ const pollInterval = 2000;
133
+ const deadline = Date.now() + expiresIn * 1000;
134
+ let apiToken: string | null = null;
135
+
136
+ while (Date.now() < deadline) {
137
+ await new Promise((r) => setTimeout(r, pollInterval));
138
+ const statusRes = await fetch(`${baseUrl}/plugin/pairing-status?pairingToken=${encodeURIComponent(pairingToken)}`);
139
+ const status = (await statusRes.json()) as { status: string; claimed?: boolean; apiToken?: string };
140
+ if (status.claimed && status.apiToken) {
141
+ apiToken = status.apiToken;
142
+ break;
143
+ }
144
+ if (status.status === 'invalid' || status.status === 'expired') {
145
+ throw new Error('Pairing token expired or invalid');
146
+ }
147
+ }
148
+
149
+ if (!apiToken) {
150
+ throw new Error('Pairing timed out. Please try again.');
151
+ }
152
+
153
+ const deviceId = `clawcontrol-${Date.now()}`;
154
+ const deviceName = os.hostname();
155
+
156
+ const next = {
157
+ ...cfg,
158
+ channels: {
159
+ ...cfg.channels,
160
+ clawcontrol: {
161
+ enabled: true,
162
+ backendUrl: baseUrl,
163
+ apiToken,
164
+ deviceId,
165
+ deviceName,
166
+ },
167
+ },
168
+ };
169
+
170
+ return {
171
+ cfg: next,
172
+ statusLines: [`ClawControl: configured (device: ${deviceName})`],
173
+ };
174
+ },
175
+ disable: (cfg) => {
176
+ const next = { ...cfg } as any;
177
+ delete next.channels?.clawcontrol;
178
+ return next;
179
+ },
180
+ };
181
+
182
+ // 执行 CLI 命令的辅助函数
183
+ function execCommand(command: string): Promise<{ stdout: string; stderr: string }> {
184
+ return new Promise((resolve, reject) => {
185
+ const { exec } = require('child_process');
186
+ exec(command, { timeout: 30000 }, (error: any, stdout: string, stderr: string) => {
187
+ if (error) {
188
+ reject(new Error(stderr || error.message));
189
+ return;
190
+ }
191
+ resolve({ stdout, stderr });
192
+ });
193
+ });
194
+ }
195
+
196
+ // 导出 ChannelPlugin
197
+ export const clawcontrolPlugin: ChannelPlugin<ClawControlAccount> = {
198
+ id: CHANNEL_ID,
199
+ meta: {
200
+ id: CHANNEL_ID,
201
+ label: 'ClawControl',
202
+ selectionLabel: 'ClawControl (iOS)',
203
+ docsPath: '/channels/clawcontrol',
204
+ docsLabel: 'clawcontrol',
205
+ blurb: 'Control OpenClaw from your iOS device',
206
+ aliases: ['ios', 'clawcontrol'],
207
+ order: 100,
208
+ },
209
+ capabilities: {
210
+ chatTypes: ['direct'],
211
+ polls: false,
212
+ threads: false,
213
+ media: false,
214
+ reactions: false,
215
+ edit: false,
216
+ reply: false,
217
+ },
218
+ reload: { configPrefixes: ['channels.clawcontrol'] },
219
+ onboarding: clawcontrolOnboardingAdapter,
220
+ configSchema: {
221
+ schema: {
222
+ type: 'object',
223
+ additionalProperties: false,
224
+ properties: {
225
+ enabled: { type: 'boolean' },
226
+ backendUrl: { type: 'string' },
227
+ apiToken: { type: 'string' },
228
+ deviceId: { type: 'string' },
229
+ deviceName: { type: 'string' },
230
+ },
231
+ },
232
+ },
233
+ config: {
234
+ listAccountIds: (cfg) => ['default'],
235
+ resolveAccount: (cfg, accountId) => resolveClawControlAccount(cfg),
236
+ defaultAccountId: () => 'default',
237
+ setAccountEnabled: ({ cfg, accountId, enabled }) => {
238
+ const current = cfg?.channels?.clawcontrol as ClawControlConfig | undefined;
239
+ return {
240
+ ...cfg,
241
+ channels: {
242
+ ...cfg.channels,
243
+ clawcontrol: {
244
+ ...current,
245
+ enabled,
246
+ },
247
+ },
248
+ };
249
+ },
250
+ deleteAccount: ({ cfg, accountId }) => {
251
+ const next = { ...cfg } as any;
252
+ delete next.channels?.clawcontrol;
253
+ return next;
254
+ },
255
+ isConfigured: (account) => account.configured,
256
+ describeAccount: (account) => ({
257
+ accountId: account.accountId,
258
+ enabled: account.enabled,
259
+ configured: account.configured,
260
+ name: account.name,
261
+ }),
262
+ resolveAllowFrom: () => [],
263
+ formatAllowFrom: (params) => {
264
+ const raw = params?.allowFrom;
265
+ console.log('[ClawControl formatAllowFrom] raw=', JSON.stringify(raw), 'type=', typeof raw, 'isArray=', Array.isArray(raw));
266
+ if (Array.isArray(raw)) return raw.map(String);
267
+ if (raw != null) return [String(raw)];
268
+ return [];
269
+ },
270
+ },
271
+ status: {
272
+ defaultRuntime: {
273
+ accountId: 'default',
274
+ running: false,
275
+ connected: false,
276
+ lastStartAt: null,
277
+ lastStopAt: null,
278
+ lastConnectedAt: null,
279
+ lastDisconnect: null,
280
+ lastError: null,
281
+ port: null,
282
+ },
283
+ buildChannelSummary: ({ snapshot }) => ({
284
+ configured: snapshot.configured ?? false,
285
+ connected: snapshot.connected ?? false,
286
+ running: snapshot.running ?? false,
287
+ lastStartAt: snapshot.lastStartAt ?? null,
288
+ lastStopAt: snapshot.lastStopAt ?? null,
289
+ lastConnectedAt: snapshot.lastConnectedAt ?? null,
290
+ lastDisconnect: snapshot.lastDisconnect ?? null,
291
+ lastError: snapshot.lastError ?? null,
292
+ port: snapshot.port ?? null,
293
+ }),
294
+ probeAccount: async () => {
295
+ const ws = wsModule.getWs();
296
+ const isConnected = ws?.readyState === 1;
297
+ return {
298
+ ok: isConnected,
299
+ connected: isConnected,
300
+ readyState: ws?.readyState,
301
+ };
302
+ },
303
+ buildAccountSnapshot: (account, runtime, probe) => {
304
+ const status = wsModule.getConnectionStatus();
305
+ return {
306
+ accountId: account.accountId,
307
+ enabled: account.enabled,
308
+ configured: account.configured,
309
+ name: account.name,
310
+ running: runtime?.running ?? false,
311
+ connected: status.connected,
312
+ lastStartAt: runtime?.lastStartAt ?? null,
313
+ lastStopAt: runtime?.lastStopAt ?? null,
314
+ lastConnectedAt: status.lastConnectedAt,
315
+ lastDisconnect: status.lastDisconnect,
316
+ lastError: status.lastError,
317
+ port: runtime?.port ?? null,
318
+ probe,
319
+ };
320
+ },
321
+ },
322
+ gateway: {
323
+ startAccount: async (ctx) => {
324
+ const account = resolveClawControlAccount(ctx.cfg);
325
+
326
+ if (!account.configured || !account.apiToken || !account.backendUrl) {
327
+ ctx.log?.info('ClawControl not configured, skipping WebSocket connection');
328
+ return;
329
+ }
330
+
331
+ ctx.setStatus({
332
+ accountId: ctx.accountId,
333
+ running: true,
334
+ connected: false,
335
+ lastStartAt: Date.now(),
336
+ lastError: null,
337
+ });
338
+ ctx.log?.info('Starting ClawControl WebSocket connection...');
339
+
340
+ try {
341
+ wsModule.startWebSocket(
342
+ account.backendUrl,
343
+ account.apiToken,
344
+ account.deviceId || 'clawcontrol-plugin',
345
+ (status) => {
346
+ ctx.setStatus({
347
+ accountId: ctx.accountId,
348
+ running: true,
349
+ connected: status.connected,
350
+ lastConnectedAt: status.lastConnectedAt,
351
+ lastDisconnect: status.lastDisconnect,
352
+ lastError: status.lastError,
353
+ });
354
+ }
355
+ ).catch((error) => {
356
+ ctx.log?.error('ClawControl WebSocket error:', error);
357
+ });
358
+
359
+ await new Promise(resolve => setTimeout(resolve, 1000));
360
+
361
+ const ws = wsModule.getWs();
362
+ if (ws?.readyState === 1) {
363
+ ctx.setStatus({
364
+ accountId: ctx.accountId,
365
+ running: true,
366
+ connected: true,
367
+ lastConnectedAt: Date.now(),
368
+ lastError: null,
369
+ });
370
+ ctx.log?.info('ClawControl WebSocket connected');
371
+ } else {
372
+ ctx.setStatus({
373
+ accountId: ctx.accountId,
374
+ running: true,
375
+ connected: false,
376
+ lastError: 'WebSocket connection failed',
377
+ });
378
+ }
379
+
380
+ ctx.abortSignal.addEventListener('abort', () => {
381
+ wsModule.stopWebSocket();
382
+ });
383
+
384
+ await wsModule.neverResolve();
385
+ } catch (error) {
386
+ ctx.setStatus({
387
+ accountId: ctx.accountId,
388
+ running: false,
389
+ connected: false,
390
+ lastError: String(error),
391
+ });
392
+ ctx.log?.error('Failed to connect ClawControl WebSocket:', error);
393
+ }
394
+ },
395
+ stopAccount: async (ctx) => {
396
+ ctx.log?.info('Stopping ClawControl WebSocket connection...');
397
+ await wsModule.stopWebSocket();
398
+ ctx.setStatus({
399
+ accountId: ctx.accountId,
400
+ running: false,
401
+ connected: false,
402
+ lastStopAt: Date.now(),
403
+ });
404
+ },
405
+ },
406
+ };
407
+
408
+ // 导出 register 函数
409
+ export function register(api: any) {
410
+ if (api.runtime) {
411
+ chat.setChatRuntime(api.runtime);
412
+ }
413
+ api.registerChannel({ plugin: clawcontrolPlugin });
414
+
415
+ // Gateway 方法 - 供 iOS 端通过 backend 调用
416
+ api.registerGatewayMethod('clawcontrol.config.get', async ({ respond }: any) => {
417
+ const result = await cli.getCurrentModel();
418
+ respond(true, result);
419
+ });
420
+
421
+ api.registerGatewayMethod('clawcontrol.config.set', async ({ respond }: any, params: any) => {
422
+ const result = await cli.setModel(params.model);
423
+ respond(result.success || false, result);
424
+ });
425
+
426
+ api.registerGatewayMethod('clawcontrol.models.list', async ({ respond }: any) => {
427
+ const result = await cli.listModels();
428
+ respond(true, result);
429
+ });
430
+
431
+ api.registerGatewayMethod('clawcontrol.status', async ({ respond }: any) => {
432
+ const status = wsModule.getConnectionStatus();
433
+ respond(true, {
434
+ connected: status.connected,
435
+ lastConnectedAt: status.lastConnectedAt,
436
+ });
437
+ });
438
+
439
+ api.registerGatewayMethod('clawcontrol.usage', async ({ respond }: any) => {
440
+ const result = await cli.getUsage();
441
+ respond(true, result);
442
+ });
443
+
444
+ // Cron 接口
445
+ api.registerGatewayMethod('clawcontrol.cron.list', async ({ respond }: any) => {
446
+ const result = cli.listCronJobs();
447
+ respond(true, result);
448
+ });
449
+
450
+ api.registerGatewayMethod('clawcontrol.cron.add', async ({ respond }: any, params: any) => {
451
+ const result = cli.addCronJob(params.job);
452
+ respond(true, result);
453
+ });
454
+
455
+ api.registerGatewayMethod('clawcontrol.cron.update', async ({ respond }: any, params: any) => {
456
+ const result = cli.updateCronJob(params.id, params.patch);
457
+ respond(true, result);
458
+ });
459
+
460
+ api.registerGatewayMethod('clawcontrol.cron.remove', async ({ respond }: any, params: any) => {
461
+ const result = cli.removeCronJob(params.id);
462
+ respond(true, result);
463
+ });
464
+
465
+ api.registerGatewayMethod('clawcontrol.cron.run', async ({ respond }: any, params: any) => {
466
+ const result = await cli.runCronJob(params.id);
467
+ respond(true, result);
468
+ });
469
+ }
470
+
471
+ // 默认导出
472
+ const plugin = {
473
+ id: CHANNEL_ID,
474
+ name: 'ClawControl',
475
+ description: 'iOS control panel for OpenClaw via WebSocket',
476
+ version: '1.0.0',
477
+ register,
478
+ };
479
+
480
+ export default plugin;
@@ -0,0 +1,9 @@
1
+ {
2
+ "id": "clawcontrol",
3
+ "channels": ["clawcontrol"],
4
+ "configSchema": {
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "properties": {}
8
+ }
9
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@rethinking-studio/clawcontrol",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "OpenClaw plugin for ClawAI iOS app - remote control OpenClaw from your phone",
6
+ "main": "index.ts",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Rethinking-studio/clawcontrol.git"
10
+ },
11
+ "keywords": ["openclaw", "clawcontrol", "ios", "clawai"],
12
+ "license": "MIT",
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "dependencies": {
17
+ "qrcode-terminal": "^0.12.0",
18
+ "ws": "^8.18.0"
19
+ },
20
+ "openclaw": {
21
+ "extensions": ["./index.ts"],
22
+ "channel": {
23
+ "id": "clawcontrol",
24
+ "label": "ClawControl",
25
+ "selectionLabel": "ClawControl (iOS)",
26
+ "docsPath": "/channels/clawcontrol",
27
+ "blurb": "Control OpenClaw from your iOS device",
28
+ "order": 100,
29
+ "aliases": ["ios", "clawcontrol", "clawcontrol-ios"]
30
+ },
31
+ "install": {
32
+ "npmSpec": "@rethinking-studio/clawcontrol",
33
+ "localPath": "./plugin",
34
+ "defaultChoice": "local"
35
+ }
36
+ }
37
+ }
package/src/chat.ts ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Chat 消息收发 - 调用 OpenClaw dispatch 并推送事件到 Backend
3
+ */
4
+
5
+ let pluginRuntime: any = null;
6
+ let sendToBackend: ((msg: object) => void) | null = null;
7
+
8
+ export function setChatRuntime(runtime: any) {
9
+ pluginRuntime = runtime;
10
+ }
11
+
12
+ export function setChatSendFn(fn: (msg: object) => void) {
13
+ sendToBackend = fn;
14
+ }
15
+
16
+ function send(msg: object) {
17
+ sendToBackend?.(msg);
18
+ }
19
+
20
+ export async function handleChatSend(
21
+ requestId: string,
22
+ message: string,
23
+ sessionKey: string
24
+ ): Promise<void> {
25
+ if (!pluginRuntime?.channel?.reply?.dispatchReplyWithBufferedBlockDispatcher) {
26
+ send({
27
+ type: 'chat.event',
28
+ id: requestId,
29
+ state: 'error',
30
+ errorMessage: 'OpenClaw runtime not available',
31
+ });
32
+ return;
33
+ }
34
+
35
+ const loadConfig = pluginRuntime?.config?.loadConfig;
36
+ if (!loadConfig) {
37
+ send({
38
+ type: 'chat.event',
39
+ id: requestId,
40
+ state: 'error',
41
+ errorMessage: 'Config loader not available',
42
+ });
43
+ return;
44
+ }
45
+
46
+ const runId = `clawcontrol-${requestId}-${Date.now()}`;
47
+
48
+ try {
49
+ const cfg = loadConfig();
50
+ const sessionKeyResolved = sessionKey?.trim() || 'main';
51
+
52
+ // 先发 ACK
53
+ send({
54
+ type: 'chat.ack',
55
+ id: requestId,
56
+ runId,
57
+ status: 'started',
58
+ });
59
+
60
+ const dispatcherOptions = {
61
+ deliver: async (payload: { text?: string }, info: { kind: string }) => {
62
+ const text = payload.text?.trim();
63
+ if (!text) return;
64
+ const state = info.kind === 'final' ? 'final' : 'streaming';
65
+ send({
66
+ type: 'chat.event',
67
+ id: requestId,
68
+ runId,
69
+ state,
70
+ text,
71
+ });
72
+ },
73
+ onError: (err: unknown) => {
74
+ send({
75
+ type: 'chat.event',
76
+ id: requestId,
77
+ runId,
78
+ state: 'error',
79
+ errorMessage: err instanceof Error ? err.message : String(err),
80
+ });
81
+ },
82
+ };
83
+
84
+ const ctx = {
85
+ Body: message,
86
+ BodyForAgent: message,
87
+ RawBody: message,
88
+ CommandBody: message,
89
+ SessionKey: sessionKeyResolved,
90
+ Provider: 'clawcontrol',
91
+ Surface: 'clawcontrol',
92
+ OriginatingChannel: 'clawcontrol',
93
+ ChatType: 'direct',
94
+ CommandAuthorized: true,
95
+ MessageSid: runId,
96
+ SenderId: 'clawcontrol-web',
97
+ SenderName: 'ClawControl',
98
+ };
99
+
100
+ await pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
101
+ ctx,
102
+ cfg,
103
+ dispatcherOptions,
104
+ replyOptions: { disableBlockStreaming: false },
105
+ });
106
+
107
+ // 若 deliver 未发 final,补发一个
108
+ send({
109
+ type: 'chat.event',
110
+ id: requestId,
111
+ runId,
112
+ state: 'final',
113
+ });
114
+ } catch (err) {
115
+ send({
116
+ type: 'chat.event',
117
+ id: requestId,
118
+ runId,
119
+ state: 'error',
120
+ errorMessage: err instanceof Error ? err.message : String(err),
121
+ });
122
+ }
123
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,188 @@
1
+ /**
2
+ * CLI 命令封装
3
+ */
4
+
5
+ import { exec } from 'child_process';
6
+ import { promisify } from 'util';
7
+ import { randomUUID } from 'crypto';
8
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
9
+ import { join } from 'path';
10
+
11
+ const execAsync = promisify(exec);
12
+
13
+ const CRON_JOBS_FILE = join(process.env.HOME || '', '.openclaw', 'cron', 'jobs.json');
14
+ const OPENCLAW_CONFIG_FILE = join(process.env.HOME || '', '.openclaw', 'openclaw.json');
15
+
16
+ function readJsonFile(filePath: string): any {
17
+ try {
18
+ if (existsSync(filePath)) {
19
+ return JSON.parse(readFileSync(filePath, 'utf-8'));
20
+ }
21
+ return null;
22
+ } catch (e) {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ export async function listModels(): Promise<any> {
28
+ try {
29
+ const { stdout } = await execAsync('openclaw models list --json', { encoding: 'utf-8' });
30
+ return JSON.parse(stdout);
31
+ } catch (e: any) {
32
+ return { models: [], error: e.message };
33
+ }
34
+ }
35
+
36
+ export async function getCurrentModel(): Promise<any> {
37
+ try {
38
+ const { stdout } = await execAsync('openclaw models list --json', { encoding: 'utf-8' });
39
+ const data = JSON.parse(stdout);
40
+ const defaultModel = (data.models || []).find((m: any) =>
41
+ (m.tags || []).includes('default')
42
+ );
43
+ return {
44
+ currentModel: defaultModel || null,
45
+ model: defaultModel || null,
46
+ };
47
+ } catch (e: any) {
48
+ return { currentModel: null, model: null, error: e.message };
49
+ }
50
+ }
51
+
52
+ export async function getUsage(): Promise<any> {
53
+ try {
54
+ const { stdout } = await execAsync('openclaw status --usage --json', { encoding: 'utf-8' });
55
+ return JSON.parse(stdout);
56
+ } catch (e: any) {
57
+ return { error: e.message };
58
+ }
59
+ }
60
+
61
+ export async function setModel(model: string): Promise<any> {
62
+ try {
63
+ const configPatch = JSON.stringify({ primary: model });
64
+ await execAsync(`openclaw config set agents.defaults.model '${configPatch}' --json`);
65
+ return { success: true };
66
+ } catch (e: any) {
67
+ return { success: false, error: e.message };
68
+ }
69
+ }
70
+
71
+ // 直接读取 cron jobs 文件,不通过 CLI(避免死锁)
72
+ export function listCronJobs(): any {
73
+ const data = readJsonFile(CRON_JOBS_FILE);
74
+ if (data && data.jobs) {
75
+ return { jobs: data.jobs };
76
+ }
77
+ return { jobs: [] };
78
+ }
79
+
80
+ // 添加 cron job - 直接写入文件
81
+ export function addCronJob(job: {
82
+ name?: string;
83
+ message?: string;
84
+ every?: string;
85
+ cron?: string;
86
+ disabled?: boolean;
87
+ }): any {
88
+ try {
89
+ const data = readJsonFile(CRON_JOBS_FILE) || { version: 1, jobs: [] };
90
+ const newJob = {
91
+ name: job.name || 'Untitled',
92
+ description: job.name || '',
93
+ enabled: !job.disabled,
94
+ schedule: job.every ? {
95
+ kind: 'every',
96
+ everyMs: parseDuration(job.every),
97
+ anchorMs: Date.now()
98
+ } : {
99
+ kind: 'cron',
100
+ expr: job.cron || '* * * * *'
101
+ },
102
+ sessionTarget: 'main',
103
+ wakeMode: 'now',
104
+ payload: {
105
+ kind: 'systemEvent',
106
+ text: job.message || job.name || ''
107
+ },
108
+ id: generateId(),
109
+ createdAtMs: Date.now(),
110
+ updatedAtMs: Date.now(),
111
+ state: {
112
+ nextRunAtMs: Date.now()
113
+ }
114
+ };
115
+ data.jobs.push(newJob);
116
+ writeFileSync(CRON_JOBS_FILE, JSON.stringify(data, null, 2));
117
+ return { job: newJob };
118
+ } catch (e: any) {
119
+ return { error: e.message };
120
+ }
121
+ }
122
+
123
+ // 更新 cron job
124
+ export function updateCronJob(id: string, patch: { enabled?: boolean; name?: string }): any {
125
+ try {
126
+ const data = readJsonFile(CRON_JOBS_FILE) || { version: 1, jobs: [] };
127
+ const jobIndex = data.jobs.findIndex((j: any) => j.id === id);
128
+ if (jobIndex === -1) {
129
+ return { error: 'Job not found' };
130
+ }
131
+ if (patch.enabled !== undefined) {
132
+ data.jobs[jobIndex].enabled = patch.enabled;
133
+ }
134
+ if (patch.name) {
135
+ data.jobs[jobIndex].name = patch.name;
136
+ data.jobs[jobIndex].description = patch.name;
137
+ }
138
+ data.jobs[jobIndex].updatedAtMs = Date.now();
139
+ writeFileSync(CRON_JOBS_FILE, JSON.stringify(data, null, 2));
140
+ return { job: data.jobs[jobIndex] };
141
+ } catch (e: any) {
142
+ return { error: e.message };
143
+ }
144
+ }
145
+
146
+ // 删除 cron job
147
+ export function removeCronJob(id: string): any {
148
+ try {
149
+ const data = readJsonFile(CRON_JOBS_FILE) || { version: 1, jobs: [] };
150
+ const jobIndex = data.jobs.findIndex((j: any) => j.id === id);
151
+ if (jobIndex === -1) {
152
+ return { error: 'Job not found' };
153
+ }
154
+ data.jobs.splice(jobIndex, 1);
155
+ writeFileSync(CRON_JOBS_FILE, JSON.stringify(data, null, 2));
156
+ return { success: true };
157
+ } catch (e: any) {
158
+ return { error: e.message };
159
+ }
160
+ }
161
+
162
+ // 运行 cron job - 需要通过 CLI 执行
163
+ export async function runCronJob(id: string): Promise<any> {
164
+ try {
165
+ const { stdout } = await execAsync(`openclaw cron run ${id} --json`, { encoding: 'utf-8' });
166
+ return JSON.parse(stdout);
167
+ } catch (e: any) {
168
+ return { error: e.message };
169
+ }
170
+ }
171
+
172
+ // 辅助函数
173
+ function parseDuration(duration: string): number {
174
+ const match = duration.match(/^(\d+)(m|h|d)$/);
175
+ if (!match) return 3600000;
176
+ const value = parseInt(match[1]);
177
+ const unit = match[2];
178
+ switch (unit) {
179
+ case 'm': return value * 60 * 1000;
180
+ case 'h': return value * 60 * 60 * 1000;
181
+ case 'd': return value * 24 * 60 * 60 * 1000;
182
+ default: return value * 60 * 1000;
183
+ }
184
+ }
185
+
186
+ function generateId(): string {
187
+ return randomUUID();
188
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Backend 请求处理
3
+ */
4
+
5
+ import * as cli from './cli.js';
6
+ import * as chat from './chat.js';
7
+ import type WebSocket from 'ws';
8
+
9
+ let ws: WebSocket | null = null;
10
+
11
+ export function setWs(wsInstance: WebSocket) {
12
+ ws = wsInstance;
13
+ }
14
+
15
+ export async function handleBackendRequest(action: string, params: any, requestId: string) {
16
+ console.log('[ClawControl] Handling request:', action, 'id:', requestId);
17
+
18
+ // chat.send 走推送流程,不发 response
19
+ if (action === 'chat.send') {
20
+ const message = params?.message?.trim();
21
+ const sessionKey = params?.sessionKey || 'main';
22
+ if (!message) {
23
+ ws?.send(JSON.stringify({
24
+ type: 'chat.event',
25
+ id: requestId,
26
+ state: 'error',
27
+ errorMessage: 'message required',
28
+ }));
29
+ return;
30
+ }
31
+ await chat.handleChatSend(requestId, message, sessionKey);
32
+ return;
33
+ }
34
+
35
+ let result: any = {};
36
+ let success = true;
37
+ let error: string | undefined;
38
+
39
+ try {
40
+ switch (action) {
41
+ case 'getModels':
42
+ case 'clawcontrol.models.list':
43
+ result = await cli.listModels();
44
+ break;
45
+
46
+ case 'getCurrentModel':
47
+ case 'clawcontrol.config.get':
48
+ result = await cli.getCurrentModel();
49
+ break;
50
+
51
+ case 'setModel':
52
+ result = await cli.setModel(params.model);
53
+ break;
54
+
55
+ case 'getUsage':
56
+ result = await cli.getUsage();
57
+ break;
58
+
59
+ case 'cronList':
60
+ case 'clawcontrol.cron.list':
61
+ result = cli.listCronJobs();
62
+ break;
63
+
64
+ case 'cronAdd':
65
+ case 'clawcontrol.cron.add':
66
+ result = cli.addCronJob(params.job);
67
+ break;
68
+
69
+ case 'cronUpdate':
70
+ case 'clawcontrol.cron.update':
71
+ result = cli.updateCronJob(params.id, params.patch);
72
+ break;
73
+
74
+ case 'cronRemove':
75
+ case 'clawcontrol.cron.remove':
76
+ result = cli.removeCronJob(params.id);
77
+ break;
78
+
79
+ case 'cronRun':
80
+ case 'clawcontrol.cron.run':
81
+ result = await cli.runCronJob(params.id);
82
+ break;
83
+
84
+ default:
85
+ success = false;
86
+ error = `Unknown action: ${action}`;
87
+ }
88
+ } catch (e: any) {
89
+ success = false;
90
+ error = e.message;
91
+ }
92
+
93
+ // 发送响应
94
+ console.log('[ClawControl] Sending response:', { id: requestId, success, result });
95
+ ws?.send(JSON.stringify({
96
+ type: 'response',
97
+ id: requestId,
98
+ success,
99
+ data: result,
100
+ error,
101
+ }));
102
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * ClawControl Plugin - Core
3
+ */
4
+
5
+ export * from './cli.js';
6
+ export * from './websocket.js';
7
+ export * from './handlers.js';
@@ -0,0 +1,182 @@
1
+ /**
2
+ * WebSocket 连接管理
3
+ */
4
+
5
+ import WebSocket from 'ws';
6
+ import { handleBackendRequest, setWs } from './handlers.js';
7
+ import * as chat from './chat.js';
8
+
9
+ export interface ConnectionStatus {
10
+ connected: boolean;
11
+ lastConnectedAt: number | null;
12
+ lastDisconnect: { at: number; status: number; error?: string } | null;
13
+ lastError: string | null;
14
+ }
15
+
16
+ // 连接状态
17
+ let connectionStatus: ConnectionStatus = {
18
+ connected: false,
19
+ lastConnectedAt: null,
20
+ lastDisconnect: null,
21
+ lastError: null,
22
+ };
23
+
24
+ // WebSocket 客户端实例
25
+ let ws: WebSocket | null = null;
26
+
27
+ // 重连相关
28
+ let reconnectTimer: NodeJS.Timeout | null = null;
29
+ let currentBackendUrl = '';
30
+ let currentApiToken = '';
31
+ let currentDeviceId = '';
32
+
33
+ type StatusChangeCallback = (status: ConnectionStatus) => void;
34
+
35
+ // 一个永远不 resolved 的 Promise,用于让 gateway 保持 channel 运行
36
+ function neverResolve(): Promise<never> {
37
+ return new Promise(() => {});
38
+ }
39
+
40
+ export function startWebSocket(
41
+ backendUrl: string,
42
+ apiToken: string,
43
+ deviceId: string,
44
+ onStatusChange?: StatusChangeCallback
45
+ ): Promise<void> {
46
+ // 保存当前配置用于重连
47
+ currentBackendUrl = backendUrl;
48
+ currentApiToken = apiToken;
49
+ currentDeviceId = deviceId;
50
+
51
+ // 如果已有连接,先关闭
52
+ if (ws) {
53
+ ws.close();
54
+ ws = null;
55
+ }
56
+
57
+ return new Promise((resolve, reject) => {
58
+ const wsUrl = backendUrl.replace('http', 'ws') + '/ws';
59
+ ws = new WebSocket(wsUrl);
60
+
61
+ const timeout = setTimeout(() => {
62
+ reject(new Error('WebSocket connection timeout'));
63
+ }, 10000);
64
+
65
+ ws.on('open', () => {
66
+ clearTimeout(timeout);
67
+ console.log('[ClawControl] WebSocket connected');
68
+ connectionStatus = {
69
+ connected: true,
70
+ lastConnectedAt: Date.now(),
71
+ lastDisconnect: null,
72
+ lastError: null,
73
+ };
74
+ onStatusChange?.(connectionStatus);
75
+
76
+ // 设置 ws 实例供 handlers 使用
77
+ setWs(ws);
78
+ chat.setChatSendFn((msg) => ws?.send(JSON.stringify(msg)));
79
+
80
+ // 发送认证消息
81
+ ws?.send(JSON.stringify({
82
+ type: 'auth',
83
+ token: apiToken,
84
+ deviceId: deviceId || 'clawcontrol-plugin',
85
+ isPlugin: true,
86
+ }));
87
+ resolve();
88
+ });
89
+
90
+ ws.on('message', async (data) => {
91
+ try {
92
+ const msg = JSON.parse(data.toString());
93
+ console.log('[ClawControl] Received message:', msg.type, msg.action || '');
94
+
95
+ // 处理 backend 发送的请求
96
+ if (msg.type === 'request' && msg.action) {
97
+ await handleBackendRequest(msg.action, msg.params, msg.id);
98
+ }
99
+ } catch {}
100
+ });
101
+
102
+ ws.on('close', (code, reason) => {
103
+ console.log('[ClawControl] WebSocket disconnected, code:', code);
104
+ const message = typeof reason === 'string' ? reason : (reason?.toString() || `code ${code}`);
105
+ connectionStatus = {
106
+ connected: false,
107
+ lastConnectedAt: connectionStatus.lastConnectedAt,
108
+ lastDisconnect: { at: Date.now(), status: code, error: message || undefined },
109
+ lastError: null,
110
+ };
111
+ onStatusChange?.(connectionStatus);
112
+
113
+ // 清除 ws 实例
114
+ setWs(null as any);
115
+ chat.setChatSendFn(() => {});
116
+
117
+ // 尝试重连
118
+ scheduleReconnect(onStatusChange);
119
+ });
120
+
121
+ ws.on('error', (error) => {
122
+ clearTimeout(timeout);
123
+ console.log('[ClawControl] WebSocket error:', error);
124
+ connectionStatus.lastError = 'WebSocket connection error';
125
+ onStatusChange?.(connectionStatus);
126
+ reject(new Error('WebSocket error'));
127
+ });
128
+ });
129
+ }
130
+
131
+ function scheduleReconnect(onStatusChange?: StatusChangeCallback) {
132
+ if (reconnectTimer) {
133
+ clearTimeout(reconnectTimer);
134
+ }
135
+
136
+ reconnectTimer = setTimeout(() => {
137
+ if (currentBackendUrl && currentApiToken) {
138
+ console.log('[ClawControl] Attempting to reconnect...');
139
+ startWebSocket(currentBackendUrl, currentApiToken, currentDeviceId, onStatusChange)
140
+ .then(() => {
141
+ console.log('[ClawControl] Reconnected successfully');
142
+ })
143
+ .catch((err) => {
144
+ console.log('[ClawControl] Reconnect failed:', err.message);
145
+ });
146
+ }
147
+ }, 5000);
148
+ }
149
+
150
+ function stopReconnect() {
151
+ if (reconnectTimer) {
152
+ clearTimeout(reconnectTimer);
153
+ reconnectTimer = null;
154
+ }
155
+ }
156
+
157
+ export async function stopWebSocket(): Promise<void> {
158
+ stopReconnect();
159
+ if (ws) {
160
+ ws.close();
161
+ ws = null;
162
+ }
163
+ connectionStatus = {
164
+ connected: false,
165
+ lastConnectedAt: connectionStatus.lastConnectedAt,
166
+ lastDisconnect: { at: Date.now(), status: 1000, error: 'Manually stopped' },
167
+ lastError: null,
168
+ };
169
+ currentBackendUrl = '';
170
+ currentApiToken = '';
171
+ currentDeviceId = '';
172
+ }
173
+
174
+ export function getConnectionStatus() {
175
+ return connectionStatus;
176
+ }
177
+
178
+ export function getWs() {
179
+ return ws;
180
+ }
181
+
182
+ export { neverResolve };