indieclaw-agent 2.3.0 → 2.4.1

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 (2) hide show
  1. package/index.js +231 -137
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  const { WebSocketServer } = require('ws');
4
+ const WebSocket = require('ws');
4
5
  const { execSync, exec } = require('child_process');
5
6
  const fs = require('fs');
6
7
  const path = require('path');
@@ -8,6 +9,7 @@ const os = require('os');
8
9
  const crypto = require('crypto');
9
10
  const http = require('http');
10
11
  const https = require('https');
12
+ const net = require('net');
11
13
 
12
14
  // --- Version from package.json ---
13
15
  const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf-8'));
@@ -89,7 +91,7 @@ if (TLS_ENABLED) {
89
91
  }
90
92
 
91
93
  const terminals = new Map(); // id -> pty process
92
- const activeChats = new Map(); // id -> http.ClientRequest
94
+ const activeChats = new Map(); // chatId -> { runId, _ws }
93
95
  const activeSearches = new Map(); // ws -> child_process (one search per connection)
94
96
 
95
97
  // --- Deep Link & QR Code ---
@@ -143,51 +145,197 @@ try {
143
145
  // qrcode-terminal not installed, skip QR display
144
146
  }
145
147
 
146
- // --- OpenClaw Detection ---
147
- function detectOpenClaw() {
148
+ // --- OpenClaw Detection, Config & Gateway Client ---
149
+ const OPENCLAW_CONFIG_PATHS = [
150
+ path.join(os.homedir(), '.openclaw', 'openclaw.json'),
151
+ path.join(os.homedir(), '.openclaw', 'openclaw.json5'),
152
+ ];
153
+
154
+ function readOpenClawConfig() {
155
+ for (const cfgPath of OPENCLAW_CONFIG_PATHS) {
156
+ try {
157
+ if (!fs.existsSync(cfgPath)) continue;
158
+ let raw = fs.readFileSync(cfgPath, 'utf-8');
159
+ raw = raw.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
160
+ raw = raw.replace(/,\s*([\]}])/g, '$1');
161
+ const config = JSON.parse(raw);
162
+ const gw = config.gateway || {};
163
+ return {
164
+ port: gw.port || 18789,
165
+ token: gw.auth?.token || gw.auth?.password || null,
166
+ host: gw.bind || '127.0.0.1',
167
+ };
168
+ } catch {}
169
+ }
170
+ // Fallback: check env var
171
+ return {
172
+ port: parseInt(process.env.OPENCLAW_GATEWAY_PORT || '18789', 10),
173
+ token: process.env.OPENCLAW_GATEWAY_TOKEN || null,
174
+ host: '127.0.0.1',
175
+ };
176
+ }
177
+
178
+ let openClawConfig = readOpenClawConfig();
179
+
180
+ // TCP port check — fast and reliable, no auth needed
181
+ function isPortListening(port, host = '127.0.0.1') {
148
182
  return new Promise((resolve) => {
149
- // Method 1: Use `openclaw gateway status` CLI (most reliable)
150
- exec('openclaw gateway status 2>&1', { timeout: 5000 }, (err, stdout) => {
151
- const output = (stdout || '').toLowerCase();
152
- // "running" in output means gateway is active
153
- if (!err && (output.includes('running') || output.includes('active'))) {
154
- // Try to get the gateway port from config
155
- const portMatch = (stdout || '').match(/:(\d+)/);
156
- const port = portMatch ? parseInt(portMatch[1], 10) : 18789;
157
- return resolve({ available: true, models: ['openclaw'], port });
183
+ const sock = net.createConnection({ port, host }, () => {
184
+ sock.destroy();
185
+ resolve(true);
186
+ });
187
+ sock.on('error', () => resolve(false));
188
+ sock.setTimeout(2000, () => { sock.destroy(); resolve(false); });
189
+ });
190
+ }
191
+
192
+ async function detectOpenClaw() {
193
+ openClawConfig = readOpenClawConfig();
194
+ const listening = await isPortListening(openClawConfig.port);
195
+ if (listening) {
196
+ return { available: true, models: ['openclaw'], port: openClawConfig.port };
197
+ }
198
+ // Fallback: check if config file exists (installed but not running)
199
+ const installed = OPENCLAW_CONFIG_PATHS.some((p) => fs.existsSync(p));
200
+ if (installed) {
201
+ return { available: false, models: [], port: openClawConfig.port, installed: true };
202
+ }
203
+ return { available: false, models: [], port: null };
204
+ }
205
+
206
+ // --- OpenClaw Gateway WebSocket Client ---
207
+ let ocGateway = null;
208
+ let ocReady = false;
209
+ const ocPending = new Map();
210
+ const ocChatCallbacks = new Map(); // runId -> { ws, chatId }
211
+
212
+ function connectOcGateway() {
213
+ return new Promise((resolve, reject) => {
214
+ openClawConfig = readOpenClawConfig();
215
+ const url = `ws://127.0.0.1:${openClawConfig.port}`;
216
+
217
+ if (ocGateway) { try { ocGateway.close(); } catch {} }
218
+ ocGateway = null;
219
+ ocReady = false;
220
+
221
+ const ws = new WebSocket(url);
222
+ let connectReqId = null;
223
+ const timeout = setTimeout(() => { ws.close(); reject(new Error('Gateway timeout')); }, 10000);
224
+
225
+ ws.on('message', (data) => {
226
+ let msg;
227
+ try { msg = JSON.parse(data.toString()); } catch { return; }
228
+
229
+ // Step 1: Gateway sends connect.challenge
230
+ if (msg.type === 'event' && msg.event === 'connect.challenge') {
231
+ connectReqId = crypto.randomUUID();
232
+ const params = {
233
+ minProtocol: 3, maxProtocol: 3,
234
+ client: { id: 'indieclaw-agent', version: VERSION, platform: os.platform(), mode: 'operator' },
235
+ role: 'operator',
236
+ scopes: ['operator.read', 'operator.write'],
237
+ };
238
+ if (openClawConfig.token) params.auth = { token: openClawConfig.token };
239
+ ws.send(JSON.stringify({ type: 'req', id: connectReqId, method: 'connect', params }));
240
+ return;
158
241
  }
159
242
 
160
- // Method 2: Check if openclaw binary exists at all
161
- exec('command -v openclaw 2>/dev/null', { timeout: 2000 }, (err2, binPath) => {
162
- if (err2 || !binPath?.trim()) {
163
- // Method 3: Check if openclaw-gateway process is running
164
- exec('pgrep -f "openclaw.gateway\\|openclaw-gateway" 2>/dev/null', { timeout: 2000 }, (err3, pid) => {
165
- if (!err3 && pid?.trim()) {
166
- return resolve({ available: true, models: ['openclaw'], port: 18789 });
167
- }
168
- return resolve({ available: false, models: [], port: null });
169
- });
170
- return;
243
+ // Step 2: Handle hello-ok response
244
+ if (msg.type === 'res' && msg.id === connectReqId) {
245
+ clearTimeout(timeout);
246
+ if (msg.ok) {
247
+ ocGateway = ws;
248
+ ocReady = true;
249
+ resolve(ws);
250
+ } else {
251
+ ws.close();
252
+ reject(new Error(msg.error?.message || 'Gateway auth failed'));
171
253
  }
254
+ return;
255
+ }
172
256
 
173
- // Binary exists but gateway might not be running — try health check
174
- exec('openclaw gateway health --url ws://127.0.0.1:18789 2>&1', { timeout: 5000 }, (err4, healthOut) => {
175
- const hOutput = (healthOut || '').toLowerCase();
176
- if (!err4 && (hOutput.includes('ok') || hOutput.includes('healthy') || hOutput.includes('reachable'))) {
177
- return resolve({ available: true, models: ['openclaw'], port: 18789 });
178
- }
179
- // Binary installed but gateway not running
180
- return resolve({ available: false, models: [], port: null });
181
- });
182
- });
257
+ // Handle other req/res
258
+ if (msg.type === 'res' && msg.id) {
259
+ const pending = ocPending.get(msg.id);
260
+ if (pending) {
261
+ ocPending.delete(msg.id);
262
+ clearTimeout(pending.timeout);
263
+ if (msg.ok) pending.resolve(msg.payload);
264
+ else pending.reject(new Error(msg.error?.message || 'Request failed'));
265
+ }
266
+ }
267
+
268
+ // Handle chat streaming events
269
+ if (msg.type === 'event' && msg.event === 'chat') {
270
+ const p = msg.payload;
271
+ const cb = ocChatCallbacks.get(p.runId);
272
+ if (!cb) return;
273
+
274
+ if (p.state === 'delta') {
275
+ // Extract text from delta — try common field paths
276
+ const text = typeof p.message === 'string' ? p.message
277
+ : p.message?.content || p.message?.text || '';
278
+ if (text) send(cb.ws, { type: 'chat.stream', id: cb.chatId, content: text });
279
+ } else if (p.state === 'final') {
280
+ const text = typeof p.message === 'string' ? p.message
281
+ : p.message?.content || p.message?.text || '';
282
+ if (text) send(cb.ws, { type: 'chat.stream', id: cb.chatId, content: text });
283
+ send(cb.ws, { type: 'chat.done', id: cb.chatId });
284
+ ocChatCallbacks.delete(p.runId);
285
+ } else if (p.state === 'error') {
286
+ send(cb.ws, { type: 'chat.done', id: cb.chatId, error: p.errorMessage || 'OpenClaw error' });
287
+ ocChatCallbacks.delete(p.runId);
288
+ } else if (p.state === 'aborted') {
289
+ send(cb.ws, { type: 'chat.done', id: cb.chatId });
290
+ ocChatCallbacks.delete(p.runId);
291
+ }
292
+ }
183
293
  });
294
+
295
+ ws.on('error', (err) => { clearTimeout(timeout); ocReady = false; reject(err); });
296
+ ws.on('close', () => {
297
+ ocReady = false;
298
+ ocGateway = null;
299
+ // Notify all pending chat streams that gateway disconnected
300
+ for (const [runId, cb] of ocChatCallbacks) {
301
+ send(cb.ws, { type: 'chat.done', id: cb.chatId, error: 'OpenClaw gateway disconnected' });
302
+ ocChatCallbacks.delete(runId);
303
+ }
304
+ // Reject all pending requests
305
+ for (const [id, p] of ocPending) {
306
+ clearTimeout(p.timeout);
307
+ p.reject(new Error('Gateway disconnected'));
308
+ ocPending.delete(id);
309
+ }
310
+ });
311
+ });
312
+ }
313
+
314
+ async function getOcGateway() {
315
+ if (ocGateway && ocGateway.readyState === WebSocket.OPEN && ocReady) return ocGateway;
316
+ return connectOcGateway();
317
+ }
318
+
319
+ function ocRequest(method, params) {
320
+ return new Promise(async (resolve, reject) => {
321
+ try {
322
+ const ws = await getOcGateway();
323
+ const id = crypto.randomUUID();
324
+ const t = setTimeout(() => { ocPending.delete(id); reject(new Error('Timeout')); }, 30000);
325
+ ocPending.set(id, { resolve, reject, timeout: t });
326
+ ws.send(JSON.stringify({ type: 'req', id, method, params }));
327
+ } catch (err) { reject(err); }
184
328
  });
185
329
  }
186
330
 
187
331
  // Run detection on startup
188
332
  detectOpenClaw().then((oc) => {
189
333
  if (oc.available) {
190
- console.log(` [OpenClaw] Detected (gateway running on port ${oc.port})`);
334
+ console.log(` [OpenClaw] Detected (gateway on port ${oc.port})`);
335
+ // Pre-connect to gateway
336
+ connectOcGateway().catch(() => {});
337
+ } else if (oc.installed) {
338
+ console.log(` [OpenClaw] Installed but gateway not running (port ${oc.port})`);
191
339
  } else {
192
340
  console.log(' [OpenClaw] Not detected');
193
341
  }
@@ -263,10 +411,13 @@ wss.on('connection', (ws) => {
263
411
  }
264
412
  }
265
413
  // Clean up any active chat streams owned by this connection
266
- for (const [id, req] of activeChats) {
267
- if (req._ws === ws) {
268
- req.destroy();
269
- activeChats.delete(id);
414
+ for (const [chatId, chat] of activeChats) {
415
+ if (chat._ws === ws) {
416
+ if (chat.runId) {
417
+ ocRequest('chat.abort', { runId: chat.runId }).catch(() => {});
418
+ ocChatCallbacks.delete(chat.runId);
419
+ }
420
+ activeChats.delete(chatId);
270
421
  }
271
422
  }
272
423
  // Kill any active search process for this connection
@@ -338,7 +489,7 @@ async function handleMessage(ws, msg) {
338
489
  case 'terminal.stop':
339
490
  return handleTerminalStop(ws, msg);
340
491
  case 'chat.send':
341
- return handleChatSend(ws, msg);
492
+ return await handleChatSend(ws, msg);
342
493
  case 'chat.stop':
343
494
  return handleChatStop(ws, msg);
344
495
  case 'push.register':
@@ -951,104 +1102,45 @@ function handleTerminalStop(ws, { id }) {
951
1102
  reply(ws, id, { stopped: true });
952
1103
  }
953
1104
 
954
- // --- Chat (OpenClaw Proxy) ---
955
- function handleChatSend(ws, { id, messages, openclawToken, openclawPort, openclawHost }) {
956
- const port = openclawPort || 18789;
957
- const host = openclawHost || '127.0.0.1';
958
-
959
- const body = JSON.stringify({
960
- model: 'openclaw:main',
961
- messages,
962
- stream: true,
963
- });
964
-
965
- const req = http.request(
966
- {
967
- hostname: host,
968
- port,
969
- path: '/v1/chat/completions',
970
- method: 'POST',
971
- headers: {
972
- 'Content-Type': 'application/json',
973
- Authorization: `Bearer ${openclawToken}`,
974
- 'x-openclaw-agent-id': 'main',
975
- },
976
- },
977
- (res) => {
978
- if (res.statusCode !== 200) {
979
- let errorBody = '';
980
- res.on('data', (chunk) => (errorBody += chunk));
981
- res.on('end', () => {
982
- send(ws, { type: 'chat.done', id, error: `OpenClaw error ${res.statusCode}: ${errorBody}` });
983
- activeChats.delete(id);
984
- });
985
- return;
986
- }
1105
+ // --- Chat (OpenClaw Gateway Proxy) ---
1106
+ async function handleChatSend(ws, { id, messages }) {
1107
+ try {
1108
+ // Connect to OpenClaw gateway (auto-reconnects if needed)
1109
+ await getOcGateway();
987
1110
 
988
- let buffer = '';
989
- let sentDone = false;
990
-
991
- res.on('data', (chunk) => {
992
- buffer += chunk.toString();
993
- const lines = buffer.split('\n');
994
- buffer = lines.pop() || '';
995
-
996
- for (const line of lines) {
997
- const trimmed = line.trim();
998
- if (!trimmed || trimmed.startsWith(':')) continue;
999
- if (trimmed === 'data: [DONE]') {
1000
- if (!sentDone) {
1001
- sentDone = true;
1002
- send(ws, { type: 'chat.done', id });
1003
- activeChats.delete(id);
1004
- }
1005
- return;
1006
- }
1007
- if (trimmed.startsWith('data: ')) {
1008
- try {
1009
- const json = JSON.parse(trimmed.slice(6));
1010
- const content = json.choices?.[0]?.delta?.content;
1011
- if (content) {
1012
- send(ws, { type: 'chat.stream', id, content });
1013
- }
1014
- } catch {}
1015
- }
1016
- }
1017
- });
1111
+ // Extract the last user message from the conversation
1112
+ const lastUserMsg = [...messages].reverse().find(m => m.role === 'user');
1113
+ if (!lastUserMsg) {
1114
+ return send(ws, { type: 'chat.done', id, error: 'No user message found' });
1115
+ }
1018
1116
 
1019
- res.on('end', () => {
1020
- if (!sentDone) {
1021
- sentDone = true;
1022
- send(ws, { type: 'chat.done', id });
1023
- activeChats.delete(id);
1024
- }
1025
- });
1117
+ // Build a session key — unique per chat conversation
1118
+ const sessionKey = `agent:indieclaw:mobile:${id}`;
1119
+ const idempotencyKey = crypto.randomUUID();
1026
1120
 
1027
- res.on('error', (err) => {
1028
- if (!sentDone) {
1029
- sentDone = true;
1030
- send(ws, { type: 'chat.done', id, error: err.message });
1031
- activeChats.delete(id);
1032
- }
1033
- });
1034
- }
1035
- );
1121
+ // Send chat request to gateway via WebSocket protocol
1122
+ const result = await ocRequest('chat.send', {
1123
+ sessionKey,
1124
+ message: { role: 'user', content: lastUserMsg.content },
1125
+ idempotencyKey,
1126
+ });
1036
1127
 
1037
- req.on('error', (err) => {
1038
- send(ws, { type: 'chat.done', id, error: `Connection failed: ${err.message}` });
1039
- activeChats.delete(id);
1040
- });
1128
+ // Register for streaming events using the runId from the response
1129
+ const runId = result?.runId || result?.id || id;
1130
+ ocChatCallbacks.set(runId, { ws, chatId: id });
1131
+ activeChats.set(id, { runId, _ws: ws });
1041
1132
 
1042
- req._ws = ws;
1043
- activeChats.set(id, req);
1044
- req.write(body);
1045
- req.end();
1133
+ } catch (err) {
1134
+ send(ws, { type: 'chat.done', id, error: `OpenClaw: ${err.message}` });
1135
+ }
1046
1136
  }
1047
1137
 
1048
1138
  function handleChatStop(ws, { id, chatId }) {
1049
- const req = activeChats.get(chatId);
1050
- if (req) {
1051
- req.destroy();
1139
+ const chat = activeChats.get(chatId);
1140
+ if (chat && chat.runId) {
1141
+ // Abort the chat via OpenClaw gateway
1142
+ ocRequest('chat.abort', { runId: chat.runId }).catch(() => {});
1143
+ ocChatCallbacks.delete(chat.runId);
1052
1144
  activeChats.delete(chatId);
1053
1145
  }
1054
1146
  reply(ws, id, { stopped: true });
@@ -1272,19 +1364,21 @@ function handleAgentLogs(ws, { id, lines = 200 }) {
1272
1364
  }
1273
1365
 
1274
1366
  // --- Graceful Shutdown ---
1275
- process.on('SIGINT', () => {
1276
- console.log('\n[agent] Shutting down...');
1367
+ function gracefulShutdown() {
1277
1368
  for (const [, term] of terminals) term.kill();
1278
- for (const [, req] of activeChats) req.destroy();
1369
+ activeChats.clear();
1370
+ ocChatCallbacks.clear();
1371
+ if (ocGateway) { try { ocGateway.close(); } catch {} }
1279
1372
  wss.close();
1280
1373
  if (server) server.close();
1281
1374
  process.exit(0);
1375
+ }
1376
+
1377
+ process.on('SIGINT', () => {
1378
+ console.log('\n[agent] Shutting down...');
1379
+ gracefulShutdown();
1282
1380
  });
1283
1381
 
1284
1382
  process.on('SIGTERM', () => {
1285
- for (const [, term] of terminals) term.kill();
1286
- for (const [, req] of activeChats) req.destroy();
1287
- wss.close();
1288
- if (server) server.close();
1289
- process.exit(0);
1383
+ gracefulShutdown();
1290
1384
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "indieclaw-agent",
3
- "version": "2.3.0",
3
+ "version": "2.4.1",
4
4
  "description": "Manage your server from your phone. Agent for the IndieClaw mobile app.",
5
5
  "main": "index.js",
6
6
  "bin": {