@astroanywhere/agent 0.2.3 → 0.2.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 (49) hide show
  1. package/README.md +19 -15
  2. package/dist/cli.js +6 -0
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/setup.d.ts.map +1 -1
  5. package/dist/commands/setup.js +158 -77
  6. package/dist/commands/setup.js.map +1 -1
  7. package/dist/commands/start.d.ts.map +1 -1
  8. package/dist/commands/start.js +4 -0
  9. package/dist/commands/start.js.map +1 -1
  10. package/dist/lib/display.d.ts +32 -1
  11. package/dist/lib/display.d.ts.map +1 -1
  12. package/dist/lib/display.js +140 -6
  13. package/dist/lib/display.js.map +1 -1
  14. package/dist/lib/openclaw-bridge.d.ts +39 -7
  15. package/dist/lib/openclaw-bridge.d.ts.map +1 -1
  16. package/dist/lib/openclaw-bridge.js +256 -40
  17. package/dist/lib/openclaw-bridge.js.map +1 -1
  18. package/dist/lib/openclaw-gateway.d.ts +52 -0
  19. package/dist/lib/openclaw-gateway.d.ts.map +1 -0
  20. package/dist/lib/openclaw-gateway.js +116 -0
  21. package/dist/lib/openclaw-gateway.js.map +1 -0
  22. package/dist/lib/providers.d.ts.map +1 -1
  23. package/dist/lib/providers.js +6 -37
  24. package/dist/lib/providers.js.map +1 -1
  25. package/dist/lib/ssh-discovery.d.ts +3 -1
  26. package/dist/lib/ssh-discovery.d.ts.map +1 -1
  27. package/dist/lib/ssh-discovery.js +34 -24
  28. package/dist/lib/ssh-discovery.js.map +1 -1
  29. package/dist/lib/ssh-installer.d.ts +26 -0
  30. package/dist/lib/ssh-installer.d.ts.map +1 -1
  31. package/dist/lib/ssh-installer.js +126 -4
  32. package/dist/lib/ssh-installer.js.map +1 -1
  33. package/dist/lib/task-executor.d.ts +7 -0
  34. package/dist/lib/task-executor.d.ts.map +1 -1
  35. package/dist/lib/task-executor.js +16 -2
  36. package/dist/lib/task-executor.js.map +1 -1
  37. package/dist/lib/websocket-client.d.ts +3 -0
  38. package/dist/lib/websocket-client.d.ts.map +1 -1
  39. package/dist/lib/websocket-client.js +6 -0
  40. package/dist/lib/websocket-client.js.map +1 -1
  41. package/dist/providers/index.d.ts +3 -1
  42. package/dist/providers/index.d.ts.map +1 -1
  43. package/dist/providers/index.js +9 -3
  44. package/dist/providers/index.js.map +1 -1
  45. package/dist/providers/openclaw-adapter.d.ts +21 -29
  46. package/dist/providers/openclaw-adapter.d.ts.map +1 -1
  47. package/dist/providers/openclaw-adapter.js +147 -385
  48. package/dist/providers/openclaw-adapter.js.map +1 -1
  49. package/package.json +1 -1
@@ -1,31 +1,20 @@
1
1
  /**
2
- * OpenClaw provider adapter — Gateway WebSocket mode
2
+ * OpenClaw provider adapter — Thin wrapper delegating to OpenClawBridge
3
3
  *
4
- * Connects to the local OpenClaw gateway via WebSocket and dispatches tasks
5
- * using `chat.send`. Each task gets its own session key for isolation.
4
+ * When a bridge is injected via setBridge(), task execution goes through
5
+ * the bridge's shared WebSocket connection (session-multiplexed).
6
+ * Falls back to standalone connections when no bridge is available.
6
7
  *
7
- * Gateway discovery:
8
- * 1. Read ~/.openclaw/openclaw.json for gateway port + auth token
9
- * 2. Probe ws://127.0.0.1:{port} for connect.challenge
10
- * 3. Handshake with client.id='gateway-client', mode='backend'
11
- *
12
- * Execution flow:
13
- * chat.send({ sessionKey, message, idempotencyKey })
14
- * → gateway streams `agent` + `chat` events over WebSocket
15
- * → adapter translates to TaskOutputStream calls
16
- * → returns TaskResult on session completion
8
+ * The HTTP llm-task path (runLlmTask) remains in the adapter since it
9
+ * uses a separate HTTP POST, not the WebSocket.
17
10
  */
18
- import { existsSync, readFileSync } from 'node:fs';
19
- import { join } from 'node:path';
20
- import { homedir } from 'node:os';
21
11
  import { randomUUID } from 'node:crypto';
22
- import WebSocket from 'ws';
23
12
  import { SUMMARY_PROMPT, SUMMARY_TIMEOUT_MS, parseSummaryResponse, createNoopStream } from './base-adapter.js';
13
+ import { readGatewayConfig, probeGateway, parseGatewayFrame, makeSessionKey, matchesSessionKey, PROTOCOL_VERSION, CONNECT_TIMEOUT_MS, } from '../lib/openclaw-gateway.js';
14
+ import WebSocket from 'ws';
24
15
  // ---------------------------------------------------------------------------
25
16
  // Constants
26
17
  // ---------------------------------------------------------------------------
27
- const PROTOCOL_VERSION = 3;
28
- const CONNECT_TIMEOUT_MS = 10_000;
29
18
  /** TTL for preserved sessions (10 minutes) */
30
19
  const SESSION_TTL_MS = 10 * 60 * 1000;
31
20
  export class OpenClawAdapter {
@@ -36,16 +25,31 @@ export class OpenClawAdapter {
36
25
  lastError;
37
26
  gatewayConfig = null;
38
27
  lastAvailableCheck = null;
28
+ bridge = null;
39
29
  /** Preserved sessions for multi-turn resume, keyed by taskId */
40
30
  preservedSessions = new Map();
31
+ // ─── Bridge Injection ───────────────────────────────────────────
32
+ /**
33
+ * Inject the shared bridge for task execution.
34
+ * Called by task-executor when the bridge becomes available.
35
+ */
36
+ setBridge(bridge) {
37
+ this.bridge = bridge;
38
+ }
39
+ // ─── Availability ───────────────────────────────────────────────
41
40
  async isAvailable() {
42
- const config = this.readGatewayConfig();
41
+ // If bridge is connected, we're available
42
+ if (this.bridge?.isConnected) {
43
+ this.lastAvailableCheck = { available: true, at: Date.now() };
44
+ return true;
45
+ }
46
+ const config = readGatewayConfig();
43
47
  if (!config)
44
48
  return false;
45
49
  this.gatewayConfig = config;
46
50
  // Probe the gateway with a quick connect
47
51
  try {
48
- const ok = await this.probeGateway(config);
52
+ const ok = await probeGateway(config.url);
49
53
  this.lastAvailableCheck = { available: ok, at: Date.now() };
50
54
  return ok;
51
55
  }
@@ -54,8 +58,10 @@ export class OpenClawAdapter {
54
58
  return false;
55
59
  }
56
60
  }
61
+ // ─── Task Execution ─────────────────────────────────────────────
57
62
  async execute(task, stream, signal) {
58
- if (!this.gatewayConfig) {
63
+ // Ensure we have config or bridge
64
+ if (!this.bridge?.isConnected && !this.gatewayConfig) {
59
65
  const available = await this.isAvailable();
60
66
  if (!available) {
61
67
  return {
@@ -85,13 +91,33 @@ export class OpenClawAdapter {
85
91
  };
86
92
  }
87
93
  catch (err) {
88
- // Fall through to runViaGateway() agent mode
94
+ // Fall through to agent mode
89
95
  console.warn('[openclaw] llm-task failed, falling back to agent mode:', err);
90
96
  }
91
97
  }
92
98
  stream.status('running', 0, 'Connecting to OpenClaw gateway');
93
- const sessionKey = `astro:task:${task.id}`;
94
- const result = await this.runViaGateway(task, stream, signal);
99
+ const sessionKey = makeSessionKey(task.id);
100
+ // Build the prompt
101
+ let effectivePrompt = task.systemPrompt
102
+ ? `${task.systemPrompt}\n\n---\n\n${task.prompt}`
103
+ : task.prompt;
104
+ // Prepend conversation history if available
105
+ if (task.messages && task.messages.length > 0) {
106
+ const conversationContext = task.messages
107
+ .map(m => `${m.role === 'user' ? 'Human' : 'Assistant'}: ${m.content}`)
108
+ .join('\n\n');
109
+ effectivePrompt = `${conversationContext}\n\nHuman: ${effectivePrompt}`;
110
+ }
111
+ let result;
112
+ if (this.bridge?.isConnected) {
113
+ // Bridge-backed execution (shared WebSocket)
114
+ stream.status('running', 5, 'Connected to gateway');
115
+ result = await this.bridge.executeTask(sessionKey, effectivePrompt, stream, signal, task.timeout);
116
+ }
117
+ else {
118
+ // Fallback: standalone connection
119
+ result = await this.runViaStandaloneConnection(sessionKey, effectivePrompt, stream, signal, task.timeout);
120
+ }
95
121
  const isCancelled = signal.aborted || result.error === 'Task cancelled';
96
122
  // Preserve session for multi-turn resume (unless cancelled/failed)
97
123
  if (!isCancelled && !result.error) {
@@ -159,8 +185,7 @@ export class OpenClawAdapter {
159
185
  }
160
186
  }
161
187
  async getStatus() {
162
- // Use cached availability if checked within the last 30 seconds to avoid
163
- // opening a new WebSocket probe on every status poll
188
+ // Use cached availability if checked within the last 30 seconds
164
189
  let available;
165
190
  if (this.lastAvailableCheck && Date.now() - this.lastAvailableCheck.at < 30_000) {
166
191
  available = this.lastAvailableCheck.available;
@@ -179,30 +204,29 @@ export class OpenClawAdapter {
179
204
  // ─── Multi-Turn Resume ─────────────────────────────────────────
180
205
  /**
181
206
  * Resume a completed session by sending another chat.send to the same sessionKey.
182
- * The OpenClaw gateway preserves session history per sessionKey.
183
207
  */
184
208
  async resumeTask(taskId, message, _workingDirectory, sessionId, stream, signal) {
185
- if (!this.gatewayConfig) {
186
- // Attempt availability check as fallback (mirrors execute() pattern)
187
- const available = await this.isAvailable();
188
- if (!available || !this.gatewayConfig) {
189
- return { success: false, output: '', error: 'OpenClaw gateway not available' };
190
- }
191
- }
192
- // Use the preserved session key, or resolve from the provider sessionId.
193
- // The sessionId may already be a valid session key (e.g. 'astro:task:...' or
194
- // 'agent:main:astro:task:...'), so don't blindly wrap it in 'astro:task:'.
209
+ // Use the preserved session key, or resolve from the provider sessionId
195
210
  const session = this.preservedSessions.get(taskId);
196
211
  const sessionKey = session?.sessionKey || this.resolveSessionKey(sessionId);
197
212
  this.activeTasks++;
198
- let ws;
199
213
  try {
200
- ws = await this.connectToGateway(this.gatewayConfig);
201
- stream.status('running', 5, 'Resuming OpenClaw session');
202
- // sendChatMessage() registers ws error/close handlers before sending,
203
- // so it owns cleanup (calls ws.close() in its finish() helper)
204
- const result = await this.sendChatMessage(ws, sessionKey, message, stream, signal);
205
- ws = undefined;
214
+ let result;
215
+ if (this.bridge?.isConnected) {
216
+ stream.status('running', 5, 'Resuming OpenClaw session');
217
+ result = await this.bridge.sendChatMessage(sessionKey, message, stream, signal);
218
+ }
219
+ else {
220
+ // Fallback: standalone connection for resume
221
+ if (!this.gatewayConfig) {
222
+ const available = await this.isAvailable();
223
+ if (!available || !this.gatewayConfig) {
224
+ return { success: false, output: '', error: 'OpenClaw gateway not available' };
225
+ }
226
+ }
227
+ stream.status('running', 5, 'Resuming OpenClaw session');
228
+ result = await this.runViaStandaloneConnection(sessionKey, message, stream, signal);
229
+ }
206
230
  // Update preserved session timestamp
207
231
  if (session) {
208
232
  session.createdAt = Date.now();
@@ -214,7 +238,6 @@ export class OpenClawAdapter {
214
238
  };
215
239
  }
216
240
  catch (error) {
217
- ws?.close();
218
241
  const errorMsg = error instanceof Error ? error.message : String(error);
219
242
  this.lastError = errorMsg;
220
243
  return { success: false, output: '', error: errorMsg };
@@ -225,7 +248,6 @@ export class OpenClawAdapter {
225
248
  }
226
249
  /**
227
250
  * Mid-execution message injection is not supported for OpenClaw gateway.
228
- * The gateway processes one chat.send at a time per session.
229
251
  */
230
252
  async injectMessage(_taskId, _content, _interrupt) {
231
253
  return false;
@@ -256,7 +278,6 @@ export class OpenClawAdapter {
256
278
  // ─── Summary Generation ──────────────────────────────────────
257
279
  /**
258
280
  * Generate a structured execution summary by resuming the completed OpenClaw session.
259
- * Sends the summary prompt to the same session key via chat.send.
260
281
  */
261
282
  async generateSummary(taskId, workingDirectory) {
262
283
  const session = this.preservedSessions.get(taskId);
@@ -264,9 +285,9 @@ export class OpenClawAdapter {
264
285
  console.log(`[openclaw] No session to resume for summary (task ${taskId})`);
265
286
  return undefined;
266
287
  }
267
- if (!this.gatewayConfig) {
288
+ if (!this.bridge?.isConnected && !this.gatewayConfig) {
268
289
  const available = await this.isAvailable();
269
- if (!available || !this.gatewayConfig) {
290
+ if (!available) {
270
291
  console.warn(`[openclaw] Gateway not available for summary generation (task ${taskId})`);
271
292
  return undefined;
272
293
  }
@@ -287,10 +308,6 @@ export class OpenClawAdapter {
287
308
  }
288
309
  /**
289
310
  * Resolve a provider session ID to a valid OpenClaw session key.
290
- * The sessionId from the frontend may be:
291
- * - 'astro:task:{taskId}' (direct)
292
- * - 'agent:main:astro:task:{taskId}' (gateway-prefixed)
293
- * Avoid double-wrapping by checking for existing prefixes.
294
311
  */
295
312
  resolveSessionKey(sessionId) {
296
313
  if (sessionId.startsWith('astro:task:'))
@@ -300,137 +317,19 @@ export class OpenClawAdapter {
300
317
  return stripped;
301
318
  return `astro:task:${sessionId}`;
302
319
  }
303
- // ─── Gateway Config Discovery ────────────────────────────────────
304
- readGatewayConfig() {
305
- try {
306
- const configPath = join(homedir(), '.openclaw', 'openclaw.json');
307
- if (!existsSync(configPath))
308
- return null;
309
- const raw = JSON.parse(readFileSync(configPath, 'utf-8'));
310
- const port = raw?.gateway?.port;
311
- if (!port)
312
- return null;
313
- const token = raw?.gateway?.auth?.token || '';
314
- const bind = raw?.gateway?.bind || '127.0.0.1';
315
- const host = bind === 'loopback' || bind === '127.0.0.1' ? '127.0.0.1' : bind;
316
- return {
317
- port,
318
- token,
319
- url: `ws://${host}:${port}`,
320
- };
321
- }
322
- catch {
323
- return null;
320
+ // ─── Standalone Connection (Fallback) ──────────────────────────
321
+ /**
322
+ * Execute via a standalone WebSocket connection when no bridge is available.
323
+ * This preserves backward compatibility for cases where the bridge isn't wired.
324
+ */
325
+ async runViaStandaloneConnection(sessionKey, message, stream, signal, timeout) {
326
+ const config = this.gatewayConfig;
327
+ if (!config) {
328
+ throw new Error('Gateway config not available');
324
329
  }
325
- }
326
- // ─── Gateway Probe ───────────────────────────────────────────────
327
- probeGateway(config) {
328
- return new Promise((resolve) => {
329
- let resolved = false;
330
- const done = (val) => { if (!resolved) {
331
- resolved = true;
332
- resolve(val);
333
- } };
334
- let ws;
335
- const timeout = setTimeout(() => {
336
- ws?.removeAllListeners();
337
- ws?.close();
338
- done(false);
339
- }, 5000);
340
- ws = new WebSocket(config.url);
341
- ws.on('message', (data) => {
342
- try {
343
- const frame = JSON.parse(String(data));
344
- if (frame.type === 'event' && frame.event === 'connect.challenge') {
345
- clearTimeout(timeout);
346
- ws.removeAllListeners();
347
- ws.close();
348
- done(true);
349
- }
350
- }
351
- catch {
352
- // ignore
353
- }
354
- });
355
- ws.on('error', () => {
356
- clearTimeout(timeout);
357
- ws?.removeAllListeners();
358
- done(false);
359
- });
360
- ws.on('close', () => {
361
- clearTimeout(timeout);
362
- done(false);
363
- });
364
- });
365
- }
366
- // ─── Gateway Connection ──────────────────────────────────────────
367
- connectToGateway(config) {
368
- return new Promise((resolve, reject) => {
369
- let ws;
370
- const timeout = setTimeout(() => {
371
- ws?.removeAllListeners();
372
- ws?.close();
373
- reject(new Error('Gateway connection timeout'));
374
- }, CONNECT_TIMEOUT_MS);
375
- ws = new WebSocket(config.url);
376
- const handshakeHandler = (data) => {
377
- try {
378
- const frame = JSON.parse(String(data));
379
- // Step 1: Receive challenge, send connect
380
- if (frame.type === 'event' && frame.event === 'connect.challenge') {
381
- ws.send(JSON.stringify({
382
- type: 'req',
383
- id: 'connect-1',
384
- method: 'connect',
385
- params: {
386
- minProtocol: PROTOCOL_VERSION,
387
- maxProtocol: PROTOCOL_VERSION,
388
- client: {
389
- id: 'gateway-client',
390
- version: 'dev',
391
- platform: process.platform,
392
- mode: 'backend',
393
- },
394
- caps: ['tool-events'],
395
- auth: { token: config.token },
396
- role: 'operator',
397
- scopes: ['operator.read', 'operator.write'],
398
- },
399
- }));
400
- }
401
- // Step 2: Receive connect response
402
- if (frame.type === 'res' && frame.id === 'connect-1') {
403
- clearTimeout(timeout);
404
- ws.removeListener('message', handshakeHandler);
405
- ws.removeListener('error', errorHandler);
406
- if (frame.ok) {
407
- resolve(ws);
408
- }
409
- else {
410
- ws.close();
411
- reject(new Error(`Gateway handshake failed: ${frame.error?.message || 'unknown'}`));
412
- }
413
- }
414
- }
415
- catch {
416
- // ignore parse errors during handshake
417
- }
418
- };
419
- const errorHandler = (err) => {
420
- clearTimeout(timeout);
421
- ws?.removeListener('message', handshakeHandler);
422
- reject(err);
423
- };
424
- ws.on('message', handshakeHandler);
425
- ws.on('error', errorHandler);
426
- });
427
- }
428
- // ─── Task Execution via Gateway ──────────────────────────────────
429
- async runViaGateway(task, stream, signal) {
430
- const ws = await this.connectToGateway(this.gatewayConfig);
330
+ const ws = await this.connectToGateway(config);
431
331
  stream.status('running', 5, 'Connected to gateway');
432
332
  return new Promise((resolve) => {
433
- const sessionKey = `astro:task:${task.id}`;
434
333
  const idempotencyKey = randomUUID();
435
334
  const artifacts = [];
436
335
  let outputText = '';
@@ -458,22 +357,17 @@ export class OpenClawAdapter {
458
357
  metrics: lastMetrics,
459
358
  });
460
359
  };
461
- /** Finish when both lifecycle.end and chat.final have been seen, or
462
- * after a short grace period if only lifecycle.end arrived. */
463
360
  const tryFinishAfterLifecycle = () => {
464
361
  if (chatFinalReceived) {
465
362
  finish();
466
363
  }
467
364
  else {
468
- // Grace period: if chat.final doesn't arrive within 500ms, finish anyway
469
365
  gracePeriodTimeout = setTimeout(() => { if (!finished)
470
366
  finish(); }, 500);
471
367
  }
472
368
  };
473
- // Handle abort
474
369
  const abortHandler = () => {
475
370
  if (runId) {
476
- // Try to abort the chat
477
371
  try {
478
372
  ws.send(JSON.stringify({
479
373
  type: 'req',
@@ -482,32 +376,21 @@ export class OpenClawAdapter {
482
376
  params: { sessionKey },
483
377
  }));
484
378
  }
485
- catch {
486
- // ignore
487
- }
379
+ catch { /* ignore */ }
488
380
  }
489
381
  finish('Task cancelled');
490
382
  };
491
383
  signal.addEventListener('abort', abortHandler);
492
- // Handle timeout
493
384
  let taskTimeout;
494
- if (task.timeout) {
495
- taskTimeout = setTimeout(() => {
496
- finish('Task timed out');
497
- }, task.timeout);
385
+ if (timeout) {
386
+ taskTimeout = setTimeout(() => { finish('Task timed out'); }, timeout);
498
387
  }
499
- // Handle incoming events
500
388
  ws.on('message', (data) => {
501
389
  if (finished)
502
390
  return;
503
- let frame;
504
- try {
505
- frame = JSON.parse(String(data));
506
- }
507
- catch {
391
+ const frame = parseGatewayFrame(data);
392
+ if (!frame)
508
393
  return;
509
- }
510
- // Handle chat.send response
511
394
  if (frame.type === 'res' && frame.id === 'chat-send-1') {
512
395
  if (frame.ok) {
513
396
  runId = frame.payload?.runId;
@@ -518,13 +401,10 @@ export class OpenClawAdapter {
518
401
  }
519
402
  return;
520
403
  }
521
- // Handle agent events
522
404
  if (frame.type === 'event' && frame.event === 'agent') {
523
405
  const p = frame.payload || {};
524
- // Filter to our session — gateway prepends 'agent:main:' to sessionKey
525
- if (p.sessionKey !== `agent:main:${sessionKey}` && p.sessionKey !== sessionKey) {
406
+ if (!matchesSessionKey(p.sessionKey, sessionKey))
526
407
  return;
527
- }
528
408
  const streamType = p.stream;
529
409
  const eventData = p.data;
530
410
  if (streamType === 'lifecycle') {
@@ -533,7 +413,6 @@ export class OpenClawAdapter {
533
413
  stream.sessionInit(p.sessionKey || sessionKey, eventData?.model || undefined);
534
414
  }
535
415
  else if (phase === 'end') {
536
- // Extract usage metrics from lifecycle.end if available
537
416
  const usage = eventData?.usage;
538
417
  const cost = (eventData?.total_cost_usd ?? eventData?.cost_usd);
539
418
  const numTurns = eventData?.num_turns;
@@ -582,36 +461,27 @@ export class OpenClawAdapter {
582
461
  }
583
462
  return;
584
463
  }
585
- // Handle chat events (for final state + model info)
586
464
  if (frame.type === 'event' && frame.event === 'chat') {
587
465
  const p = frame.payload || {};
588
- // Filter to our session — gateway prepends 'agent:main:' to sessionKey
589
- if (p.sessionKey !== `agent:main:${sessionKey}` && p.sessionKey !== sessionKey) {
466
+ if (!matchesSessionKey(p.sessionKey, sessionKey))
590
467
  return;
591
- }
592
- const state = p.state;
593
- if (state === 'final') {
468
+ if (p.state === 'final') {
594
469
  chatFinalReceived = true;
595
- // Extract final message content
596
- const message = p.message;
597
- if (message) {
598
- const content = message.content;
470
+ const chatMessage = p.message;
471
+ if (chatMessage) {
472
+ const content = chatMessage.content;
599
473
  if (content) {
600
474
  for (const block of content) {
601
- if (block.type === 'text' && block.text) {
602
- // Only add if not already captured via agent delta events
603
- if (!outputText.includes(block.text)) {
604
- outputText += block.text;
605
- }
475
+ if (block.type === 'text' && block.text && !outputText.includes(block.text)) {
476
+ outputText += block.text;
606
477
  }
607
478
  }
608
479
  }
609
480
  }
610
- // Extract model/usage from chat.final if not yet captured
611
481
  if (!lastMetrics) {
612
482
  const usage = p.usage;
613
483
  const cost = (p.total_cost_usd ?? p.cost_usd);
614
- const model = (p.model ?? message?.model);
484
+ const model = (p.model ?? p.message?.model);
615
485
  if (usage || cost !== undefined || model) {
616
486
  lastMetrics = {
617
487
  inputTokens: usage?.input_tokens,
@@ -621,202 +491,94 @@ export class OpenClawAdapter {
621
491
  };
622
492
  }
623
493
  }
624
- // If lifecycle already ended, finish immediately
625
494
  if (lifecycleEnded)
626
495
  finish();
627
496
  }
628
497
  return;
629
498
  }
630
- // Handle tick/health/presence (ignore)
631
- if (frame.type === 'event') {
632
- const ignoredEvents = ['tick', 'health', 'presence', 'heartbeat'];
633
- if (frame.event && ignoredEvents.includes(frame.event))
634
- return;
635
- }
636
- });
637
- ws.on('close', () => {
638
- if (!finished) {
639
- finish('Gateway connection closed unexpectedly');
640
- }
641
- });
642
- ws.on('error', (err) => {
643
- if (!finished) {
644
- finish(`Gateway WebSocket error: ${err.message}`);
645
- }
646
499
  });
647
- // Build the prompt
648
- let effectivePrompt = task.systemPrompt
649
- ? `${task.systemPrompt}\n\n---\n\n${task.prompt}`
650
- : task.prompt;
651
- // Prepend conversation history if available (fallback for multi-turn when
652
- // session resume isn't used or preservedSessions lookup failed)
653
- if (task.messages && task.messages.length > 0) {
654
- const conversationContext = task.messages
655
- .map(m => `${m.role === 'user' ? 'Human' : 'Assistant'}: ${m.content}`)
656
- .join('\n\n');
657
- effectivePrompt = `${conversationContext}\n\nHuman: ${effectivePrompt}`;
658
- }
659
- // Send chat.send
500
+ ws.on('close', () => { if (!finished)
501
+ finish('Gateway connection closed unexpectedly'); });
502
+ ws.on('error', (err) => { if (!finished)
503
+ finish(`Gateway WebSocket error: ${err.message}`); });
660
504
  try {
661
505
  ws.send(JSON.stringify({
662
506
  type: 'req',
663
507
  id: 'chat-send-1',
664
508
  method: 'chat.send',
665
- params: {
666
- sessionKey,
667
- message: effectivePrompt,
668
- idempotencyKey,
669
- },
509
+ params: { sessionKey, message, idempotencyKey },
670
510
  }));
671
511
  }
672
512
  catch (err) {
673
513
  finish(`Failed to send chat.send: ${err instanceof Error ? err.message : String(err)}`);
674
514
  }
675
- // Note: signal listener and timeout cleanup is handled in finish()
676
515
  });
677
516
  }
678
- // ─── Reusable Chat Message Sender (for resume) ────────────────────
679
517
  /**
680
- * Send a chat message to an already-connected gateway WebSocket.
681
- * Used by resumeTask() to continue a conversation on the same sessionKey.
518
+ * Connect to gateway with full handshake (standalone fallback).
682
519
  */
683
- sendChatMessage(ws, sessionKey, message, stream, signal) {
684
- return new Promise((resolve) => {
685
- const idempotencyKey = randomUUID();
686
- let outputText = '';
687
- let finished = false;
688
- let lifecycleEnded = false;
689
- let chatFinalReceived = false;
690
- let gracePeriodTimeout;
691
- const finish = (error) => {
692
- if (finished)
693
- return;
694
- finished = true;
695
- signal.removeEventListener('abort', abortHandler);
696
- if (gracePeriodTimeout)
697
- clearTimeout(gracePeriodTimeout);
520
+ connectToGateway(config) {
521
+ return new Promise((resolve, reject) => {
522
+ const ws = new WebSocket(config.url);
523
+ const timeout = setTimeout(() => {
698
524
  ws.removeAllListeners();
699
525
  ws.close();
700
- resolve({ output: outputText, error });
701
- };
702
- const tryFinishAfterLifecycle = () => {
703
- if (chatFinalReceived) {
704
- finish();
705
- }
706
- else {
707
- gracePeriodTimeout = setTimeout(() => { if (!finished)
708
- finish(); }, 500);
709
- }
710
- };
711
- const abortHandler = () => {
712
- try {
526
+ reject(new Error('Gateway connection timeout'));
527
+ }, CONNECT_TIMEOUT_MS);
528
+ const handshakeHandler = (data) => {
529
+ const frame = parseGatewayFrame(data);
530
+ if (!frame)
531
+ return;
532
+ if (frame.type === 'event' && frame.event === 'connect.challenge') {
713
533
  ws.send(JSON.stringify({
714
534
  type: 'req',
715
- id: 'abort-resume',
716
- method: 'chat.abort',
717
- params: { sessionKey },
535
+ id: 'connect-1',
536
+ method: 'connect',
537
+ params: {
538
+ minProtocol: PROTOCOL_VERSION,
539
+ maxProtocol: PROTOCOL_VERSION,
540
+ client: {
541
+ id: 'gateway-client',
542
+ version: 'dev',
543
+ platform: process.platform,
544
+ mode: 'backend',
545
+ },
546
+ caps: ['tool-events'],
547
+ auth: { token: config.token },
548
+ role: 'operator',
549
+ scopes: ['operator.read', 'operator.write'],
550
+ },
718
551
  }));
719
552
  }
720
- catch { /* ignore */ }
721
- finish('Task cancelled');
722
- };
723
- signal.addEventListener('abort', abortHandler);
724
- ws.on('message', (data) => {
725
- if (finished)
726
- return;
727
- let frame;
728
- try {
729
- frame = JSON.parse(String(data));
730
- }
731
- catch {
732
- return;
733
- }
734
- if (frame.type === 'res' && frame.id === 'chat-resume-1') {
735
- if (!frame.ok) {
736
- finish(`Gateway rejected resume: ${frame.error?.message || 'unknown'}`);
737
- }
738
- return;
739
- }
740
- if (frame.type === 'event' && frame.event === 'agent') {
741
- const p = frame.payload || {};
742
- if (p.sessionKey !== `agent:main:${sessionKey}` && p.sessionKey !== sessionKey)
743
- return;
744
- const streamType = p.stream;
745
- const eventData = p.data;
746
- if (streamType === 'lifecycle') {
747
- const phase = eventData?.phase;
748
- if (phase === 'end') {
749
- lifecycleEnded = true;
750
- tryFinishAfterLifecycle();
751
- }
752
- }
753
- else if (streamType === 'assistant') {
754
- const delta = eventData?.delta || eventData?.text;
755
- if (delta) {
756
- outputText += delta;
757
- stream.text(delta);
758
- }
759
- }
760
- else if (streamType === 'tool_use') {
761
- const toolName = eventData?.name || 'unknown';
762
- stream.toolUse(toolName, eventData?.input || {});
763
- }
764
- else if (streamType === 'tool_result') {
765
- const toolName = eventData?.name || 'unknown';
766
- stream.toolResult(toolName, eventData?.result || '', eventData?.success !== false);
767
- }
768
- else if (streamType === 'file_change') {
769
- const filePath = eventData?.path || eventData?.file;
770
- if (filePath) {
771
- const rawAction = eventData?.type || 'modified';
772
- const action = (['created', 'modified', 'deleted'].includes(rawAction) ? rawAction : 'modified');
773
- stream.fileChange(filePath, action);
774
- }
553
+ if (frame.type === 'res' && frame.id === 'connect-1') {
554
+ clearTimeout(timeout);
555
+ ws.removeListener('message', handshakeHandler);
556
+ ws.removeListener('error', errorHandler);
557
+ if (frame.ok) {
558
+ resolve(ws);
775
559
  }
776
- return;
777
- }
778
- if (frame.type === 'event' && frame.event === 'chat') {
779
- const p = frame.payload || {};
780
- if (p.sessionKey !== `agent:main:${sessionKey}` && p.sessionKey !== sessionKey)
781
- return;
782
- if (p.state === 'final') {
783
- chatFinalReceived = true;
784
- if (lifecycleEnded)
785
- finish();
560
+ else {
561
+ ws.close();
562
+ reject(new Error(`Gateway handshake failed: ${frame.error?.message || 'unknown'}`));
786
563
  }
787
- return;
788
564
  }
789
- });
790
- ws.on('close', () => { if (!finished)
791
- finish('Gateway connection closed'); });
792
- ws.on('error', (err) => { if (!finished)
793
- finish(`Gateway error: ${err.message}`); });
794
- // Send the resume message
795
- try {
796
- ws.send(JSON.stringify({
797
- type: 'req',
798
- id: 'chat-resume-1',
799
- method: 'chat.send',
800
- params: {
801
- sessionKey,
802
- message,
803
- idempotencyKey,
804
- },
805
- }));
806
- }
807
- catch (err) {
808
- finish(`Failed to send chat.send: ${err instanceof Error ? err.message : String(err)}`);
809
- }
565
+ };
566
+ const errorHandler = (err) => {
567
+ clearTimeout(timeout);
568
+ ws?.removeListener('message', handshakeHandler);
569
+ reject(err);
570
+ };
571
+ ws.on('message', handshakeHandler);
572
+ ws.on('error', errorHandler);
810
573
  });
811
574
  }
812
575
  // ─── LLM Task (Structured JSON via HTTP) ──────────────────────────
813
576
  /**
814
577
  * Use the Gateway's HTTP `POST /tools/invoke` endpoint for structured JSON
815
- * plan generation. The `llm-task` tool supports JSON Schema validation,
816
- * guaranteeing well-formed output without prompt engineering.
578
+ * plan generation.
817
579
  */
818
580
  async runLlmTask(task, stream, signal) {
819
- const config = this.gatewayConfig;
581
+ const config = this.gatewayConfig || readGatewayConfig();
820
582
  if (!config) {
821
583
  throw new Error('Gateway config not available');
822
584
  }