@astroanywhere/agent 0.1.35 → 0.1.37

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 (44) hide show
  1. package/dist/cli.js +8 -11
  2. package/dist/cli.js.map +1 -1
  3. package/dist/commands/start.d.ts.map +1 -1
  4. package/dist/commands/start.js +71 -51
  5. package/dist/commands/start.js.map +1 -1
  6. package/dist/lib/display.d.ts +24 -0
  7. package/dist/lib/display.d.ts.map +1 -0
  8. package/dist/lib/display.js +202 -0
  9. package/dist/lib/display.js.map +1 -0
  10. package/dist/lib/openclaw-bridge.d.ts +73 -0
  11. package/dist/lib/openclaw-bridge.d.ts.map +1 -0
  12. package/dist/lib/openclaw-bridge.js +457 -0
  13. package/dist/lib/openclaw-bridge.js.map +1 -0
  14. package/dist/lib/providers.d.ts.map +1 -1
  15. package/dist/lib/providers.js +46 -53
  16. package/dist/lib/providers.js.map +1 -1
  17. package/dist/lib/ssh-installer.d.ts +19 -10
  18. package/dist/lib/ssh-installer.d.ts.map +1 -1
  19. package/dist/lib/ssh-installer.js +18 -12
  20. package/dist/lib/ssh-installer.js.map +1 -1
  21. package/dist/lib/task-executor.d.ts +8 -1
  22. package/dist/lib/task-executor.d.ts.map +1 -1
  23. package/dist/lib/task-executor.js +77 -4
  24. package/dist/lib/task-executor.js.map +1 -1
  25. package/dist/lib/websocket-client.d.ts +5 -0
  26. package/dist/lib/websocket-client.d.ts.map +1 -1
  27. package/dist/lib/websocket-client.js +159 -0
  28. package/dist/lib/websocket-client.js.map +1 -1
  29. package/dist/mcp/tools.d.ts +1 -1
  30. package/dist/providers/claude-sdk-adapter.d.ts +4 -1
  31. package/dist/providers/claude-sdk-adapter.d.ts.map +1 -1
  32. package/dist/providers/claude-sdk-adapter.js +16 -5
  33. package/dist/providers/claude-sdk-adapter.js.map +1 -1
  34. package/dist/providers/openclaw-adapter.d.ts +46 -29
  35. package/dist/providers/openclaw-adapter.d.ts.map +1 -1
  36. package/dist/providers/openclaw-adapter.js +603 -215
  37. package/dist/providers/openclaw-adapter.js.map +1 -1
  38. package/dist/providers/opencode-adapter.d.ts +29 -0
  39. package/dist/providers/opencode-adapter.d.ts.map +1 -1
  40. package/dist/providers/opencode-adapter.js +145 -38
  41. package/dist/providers/opencode-adapter.js.map +1 -1
  42. package/dist/types.d.ts +83 -1
  43. package/dist/types.d.ts.map +1 -1
  44. package/package.json +1 -1
@@ -1,61 +1,66 @@
1
1
  /**
2
- * OpenClaw provider adapter
2
+ * OpenClaw provider adapter — Gateway WebSocket mode
3
3
  *
4
- * Executes tasks using the OpenClaw CLI in RPC/JSON streaming mode.
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.
5
6
  *
6
- * CLI invocation:
7
- * openclaw agent --mode rpc --json --prompt "<task>"
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'
8
11
  *
9
- * Output: JSONL streaming to stdout with event types:
10
- * session.start, content.text, tool_use.start, tool_use.end,
11
- * file.change, message.start, message.end, session.end
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
12
17
  */
13
- import { spawn } from 'node:child_process';
14
18
  import { existsSync, readFileSync } from 'node:fs';
15
19
  import { join } from 'node:path';
16
20
  import { homedir } from 'node:os';
17
- import { getProvider } from '../lib/providers.js';
21
+ import { randomUUID } from 'node:crypto';
22
+ import WebSocket from 'ws';
23
+ // ---------------------------------------------------------------------------
24
+ // Constants
25
+ // ---------------------------------------------------------------------------
26
+ const PROTOCOL_VERSION = 3;
27
+ const CONNECT_TIMEOUT_MS = 10_000;
28
+ /** TTL for preserved sessions (30 minutes) */
29
+ const SESSION_TTL_MS = 30 * 60 * 1000;
18
30
  export class OpenClawAdapter {
19
31
  type = 'openclaw';
20
32
  name = 'OpenClaw';
21
33
  activeTasks = 0;
22
- maxTasks = 1;
34
+ maxTasks = 10;
23
35
  lastError;
24
- openclawPath = null;
25
- configModel = null;
36
+ gatewayConfig = null;
37
+ lastAvailableCheck = null;
38
+ /** Preserved sessions for multi-turn resume, keyed by taskId */
39
+ preservedSessions = new Map();
26
40
  async isAvailable() {
27
- const provider = await getProvider('openclaw');
28
- if (provider?.available) {
29
- this.openclawPath = provider.path;
30
- this.configModel = this.readConfigModel();
31
- return true;
32
- }
33
- return false;
34
- }
35
- /**
36
- * Read the default model from ~/.openclaw/config.json
37
- */
38
- readConfigModel() {
41
+ const config = this.readGatewayConfig();
42
+ if (!config)
43
+ return false;
44
+ this.gatewayConfig = config;
45
+ // Probe the gateway with a quick connect
39
46
  try {
40
- const configPath = join(homedir(), '.openclaw', 'config.json');
41
- if (!existsSync(configPath))
42
- return null;
43
- const content = readFileSync(configPath, 'utf-8');
44
- const config = JSON.parse(content);
45
- return config.model ?? null;
47
+ const ok = await this.probeGateway(config);
48
+ this.lastAvailableCheck = { available: ok, at: Date.now() };
49
+ return ok;
46
50
  }
47
51
  catch {
48
- return null;
52
+ this.lastAvailableCheck = { available: false, at: Date.now() };
53
+ return false;
49
54
  }
50
55
  }
51
56
  async execute(task, stream, signal) {
52
- if (!this.openclawPath) {
57
+ if (!this.gatewayConfig) {
53
58
  const available = await this.isAvailable();
54
59
  if (!available) {
55
60
  return {
56
61
  taskId: task.id,
57
62
  status: 'failed',
58
- error: 'OpenClaw not available',
63
+ error: 'OpenClaw gateway not available',
59
64
  startedAt: new Date().toISOString(),
60
65
  completedAt: new Date().toISOString(),
61
66
  };
@@ -64,12 +69,23 @@ export class OpenClawAdapter {
64
69
  this.activeTasks++;
65
70
  const startedAt = new Date().toISOString();
66
71
  try {
67
- stream.status('running', 0, 'Starting OpenClaw');
68
- const result = await this.runOpenClaw(task, stream, signal);
72
+ stream.status('running', 0, 'Connecting to OpenClaw gateway');
73
+ const sessionKey = `astro:task:${task.id}`;
74
+ const result = await this.runViaGateway(task, stream, signal);
75
+ const isCancelled = signal.aborted || result.error === 'Task cancelled';
76
+ // Preserve session for multi-turn resume (unless cancelled/failed)
77
+ if (!isCancelled && !result.error) {
78
+ this.cleanupExpiredSessions();
79
+ this.preservedSessions.set(task.id, {
80
+ sessionKey,
81
+ taskId: task.id,
82
+ workingDirectory: task.workingDirectory,
83
+ createdAt: Date.now(),
84
+ });
85
+ }
69
86
  return {
70
87
  taskId: task.id,
71
- status: result.exitCode === 0 ? 'completed' : 'failed',
72
- exitCode: result.exitCode,
88
+ status: isCancelled ? 'cancelled' : result.error ? 'failed' : 'completed',
73
89
  output: result.output,
74
90
  error: result.error,
75
91
  startedAt,
@@ -103,226 +119,598 @@ export class OpenClawAdapter {
103
119
  }
104
120
  }
105
121
  async getStatus() {
106
- const available = await this.isAvailable();
107
- const provider = await getProvider('openclaw');
122
+ // Use cached availability if checked within the last 30 seconds to avoid
123
+ // opening a new WebSocket probe on every status poll
124
+ let available;
125
+ if (this.lastAvailableCheck && Date.now() - this.lastAvailableCheck.at < 30_000) {
126
+ available = this.lastAvailableCheck.available;
127
+ }
128
+ else {
129
+ available = await this.isAvailable();
130
+ }
108
131
  return {
109
132
  available,
110
- version: provider?.version ?? null,
133
+ version: null,
111
134
  activeTasks: this.activeTasks,
112
135
  maxTasks: this.maxTasks,
113
136
  lastError: this.lastError,
114
137
  };
115
138
  }
116
- runOpenClaw(task, stream, signal) {
117
- return new Promise((resolve, reject) => {
118
- // OpenClaw CLI: agent --mode rpc --json
119
- // --mode rpc: Non-interactive RPC mode (no TUI)
120
- // --json: JSONL streaming output to stdout
121
- const model = task.model || this.configModel;
122
- // Combine systemPrompt with prompt when provided (e.g., interactive plan sessions)
123
- const effectivePrompt = task.systemPrompt
124
- ? `${task.systemPrompt}\n\n---\n\n${task.prompt}`
125
- : task.prompt;
126
- const args = [
127
- 'agent',
128
- '--mode', 'rpc',
129
- '--json',
130
- ...(model ? ['--model', model] : []),
131
- '--prompt', effectivePrompt,
132
- ];
133
- const env = {
134
- ...process.env,
135
- ...task.environment,
136
- };
137
- // Validate working directory exists before spawning
138
- if (task.workingDirectory && !existsSync(task.workingDirectory)) {
139
- reject(new Error(`Working directory does not exist: ${task.workingDirectory}. ` +
140
- `Ensure the directory exists on this machine before dispatching.`));
141
- return;
139
+ // ─── Multi-Turn Resume ─────────────────────────────────────────
140
+ /**
141
+ * Resume a completed session by sending another chat.send to the same sessionKey.
142
+ * The OpenClaw gateway preserves session history per sessionKey.
143
+ */
144
+ async resumeTask(taskId, message, _workingDirectory, sessionId, stream, signal) {
145
+ if (!this.gatewayConfig) {
146
+ // Attempt availability check as fallback (mirrors execute() pattern)
147
+ const available = await this.isAvailable();
148
+ if (!available || !this.gatewayConfig) {
149
+ return { success: false, output: '', error: 'OpenClaw gateway not available' };
142
150
  }
143
- let proc;
144
- try {
145
- proc = spawn(this.openclawPath, args, {
146
- cwd: task.workingDirectory || undefined,
147
- env,
148
- stdio: ['pipe', 'pipe', 'pipe'],
149
- });
151
+ }
152
+ // Use the preserved session key, or construct one from the original taskId (keyed by taskId)
153
+ const session = this.preservedSessions.get(taskId);
154
+ const sessionKey = session?.sessionKey || `astro:task:${sessionId}`;
155
+ this.activeTasks++;
156
+ let ws;
157
+ try {
158
+ ws = await this.connectToGateway(this.gatewayConfig);
159
+ stream.status('running', 5, 'Resuming OpenClaw session');
160
+ // sendChatMessage() registers ws error/close handlers before sending,
161
+ // so it owns cleanup (calls ws.close() in its finish() helper)
162
+ const result = await this.sendChatMessage(ws, sessionKey, message, stream, signal);
163
+ ws = undefined;
164
+ // Update preserved session timestamp
165
+ if (session) {
166
+ session.createdAt = Date.now();
150
167
  }
151
- catch (error) {
152
- reject(error);
153
- return;
168
+ return {
169
+ success: !result.error,
170
+ output: result.output,
171
+ error: result.error,
172
+ };
173
+ }
174
+ catch (error) {
175
+ ws?.close();
176
+ const errorMsg = error instanceof Error ? error.message : String(error);
177
+ this.lastError = errorMsg;
178
+ return { success: false, output: '', error: errorMsg };
179
+ }
180
+ finally {
181
+ this.activeTasks--;
182
+ }
183
+ }
184
+ /**
185
+ * Mid-execution message injection is not supported for OpenClaw gateway.
186
+ * The gateway processes one chat.send at a time per session.
187
+ */
188
+ async injectMessage(_taskId, _content, _interrupt) {
189
+ return false;
190
+ }
191
+ /**
192
+ * Get preserved session context for a task (used by task executor for resume routing).
193
+ */
194
+ getTaskContext(taskId) {
195
+ this.cleanupExpiredSessions();
196
+ const session = this.preservedSessions.get(taskId);
197
+ if (!session || Date.now() - session.createdAt > SESSION_TTL_MS) {
198
+ this.preservedSessions.delete(taskId);
199
+ return null;
200
+ }
201
+ return {
202
+ sessionId: session.sessionKey,
203
+ workingDirectory: session.workingDirectory || '',
204
+ };
205
+ }
206
+ cleanupExpiredSessions() {
207
+ const now = Date.now();
208
+ for (const [key, session] of this.preservedSessions) {
209
+ if (now - session.createdAt > SESSION_TTL_MS) {
210
+ this.preservedSessions.delete(key);
154
211
  }
155
- let stdout = '';
156
- let stderr = '';
157
- let lastMetrics;
158
- const artifacts = [];
159
- const abortHandler = () => {
160
- proc.kill('SIGTERM');
161
- setTimeout(() => {
162
- if (!proc.killed) {
163
- proc.kill('SIGKILL');
164
- }
165
- }, 5000);
212
+ }
213
+ }
214
+ // ─── Gateway Config Discovery ────────────────────────────────────
215
+ readGatewayConfig() {
216
+ try {
217
+ const configPath = join(homedir(), '.openclaw', 'openclaw.json');
218
+ if (!existsSync(configPath))
219
+ return null;
220
+ const raw = JSON.parse(readFileSync(configPath, 'utf-8'));
221
+ const port = raw?.gateway?.port;
222
+ if (!port)
223
+ return null;
224
+ const token = raw?.gateway?.auth?.token || '';
225
+ const bind = raw?.gateway?.bind || '127.0.0.1';
226
+ const host = bind === 'loopback' || bind === '127.0.0.1' ? '127.0.0.1' : bind;
227
+ return {
228
+ port,
229
+ token,
230
+ url: `ws://${host}:${port}`,
166
231
  };
167
- signal.addEventListener('abort', abortHandler);
168
- // Line buffer for incomplete JSONL lines
169
- let lineBuf = '';
170
- proc.stdout?.on('data', (data) => {
171
- const text = data.toString();
172
- stdout += text;
173
- lineBuf += text;
174
- const lines = lineBuf.split('\n');
175
- lineBuf = lines.pop() || '';
176
- for (const line of lines) {
177
- if (line.trim()) {
178
- const metrics = this.handleStreamLine(line, stream, artifacts);
179
- if (metrics) {
180
- lastMetrics = metrics;
181
- }
232
+ }
233
+ catch {
234
+ return null;
235
+ }
236
+ }
237
+ // ─── Gateway Probe ───────────────────────────────────────────────
238
+ probeGateway(config) {
239
+ return new Promise((resolve) => {
240
+ let resolved = false;
241
+ const done = (val) => { if (!resolved) {
242
+ resolved = true;
243
+ resolve(val);
244
+ } };
245
+ let ws;
246
+ const timeout = setTimeout(() => {
247
+ ws?.removeAllListeners();
248
+ ws?.close();
249
+ done(false);
250
+ }, 5000);
251
+ ws = new WebSocket(config.url);
252
+ ws.on('message', (data) => {
253
+ try {
254
+ const frame = JSON.parse(String(data));
255
+ if (frame.type === 'event' && frame.event === 'connect.challenge') {
256
+ clearTimeout(timeout);
257
+ ws.removeAllListeners();
258
+ ws.close();
259
+ done(true);
182
260
  }
183
261
  }
262
+ catch {
263
+ // ignore
264
+ }
184
265
  });
185
- proc.stderr?.on('data', (data) => {
186
- const text = data.toString();
187
- stderr += text;
188
- stream.stderr(text);
266
+ ws.on('error', () => {
267
+ clearTimeout(timeout);
268
+ ws?.removeAllListeners();
269
+ done(false);
189
270
  });
190
- proc.on('error', (error) => {
191
- signal.removeEventListener('abort', abortHandler);
192
- reject(error);
271
+ ws.on('close', () => {
272
+ clearTimeout(timeout);
273
+ done(false);
193
274
  });
194
- proc.on('close', (code) => {
195
- // Flush remaining buffer
196
- if (lineBuf.trim()) {
197
- const metrics = this.handleStreamLine(lineBuf, stream, artifacts);
198
- if (metrics) {
199
- lastMetrics = metrics;
275
+ });
276
+ }
277
+ // ─── Gateway Connection ──────────────────────────────────────────
278
+ connectToGateway(config) {
279
+ return new Promise((resolve, reject) => {
280
+ let ws;
281
+ const timeout = setTimeout(() => {
282
+ ws?.removeAllListeners();
283
+ ws?.close();
284
+ reject(new Error('Gateway connection timeout'));
285
+ }, CONNECT_TIMEOUT_MS);
286
+ ws = new WebSocket(config.url);
287
+ const handshakeHandler = (data) => {
288
+ try {
289
+ const frame = JSON.parse(String(data));
290
+ // Step 1: Receive challenge, send connect
291
+ if (frame.type === 'event' && frame.event === 'connect.challenge') {
292
+ ws.send(JSON.stringify({
293
+ type: 'req',
294
+ id: 'connect-1',
295
+ method: 'connect',
296
+ params: {
297
+ minProtocol: PROTOCOL_VERSION,
298
+ maxProtocol: PROTOCOL_VERSION,
299
+ client: {
300
+ id: 'gateway-client',
301
+ version: 'dev',
302
+ platform: process.platform,
303
+ mode: 'backend',
304
+ },
305
+ caps: ['tool-events'],
306
+ auth: { token: config.token },
307
+ role: 'operator',
308
+ scopes: ['operator.read', 'operator.write'],
309
+ },
310
+ }));
200
311
  }
312
+ // Step 2: Receive connect response
313
+ if (frame.type === 'res' && frame.id === 'connect-1') {
314
+ clearTimeout(timeout);
315
+ ws.removeListener('message', handshakeHandler);
316
+ ws.removeListener('error', errorHandler);
317
+ if (frame.ok) {
318
+ resolve(ws);
319
+ }
320
+ else {
321
+ ws.close();
322
+ reject(new Error(`Gateway handshake failed: ${frame.error?.message || 'unknown'}`));
323
+ }
324
+ }
325
+ }
326
+ catch {
327
+ // ignore parse errors during handshake
201
328
  }
329
+ };
330
+ const errorHandler = (err) => {
331
+ clearTimeout(timeout);
332
+ ws?.removeListener('message', handshakeHandler);
333
+ reject(err);
334
+ };
335
+ ws.on('message', handshakeHandler);
336
+ ws.on('error', errorHandler);
337
+ });
338
+ }
339
+ // ─── Task Execution via Gateway ──────────────────────────────────
340
+ async runViaGateway(task, stream, signal) {
341
+ const ws = await this.connectToGateway(this.gatewayConfig);
342
+ stream.status('running', 5, 'Connected to gateway');
343
+ return new Promise((resolve) => {
344
+ const sessionKey = `astro:task:${task.id}`;
345
+ const idempotencyKey = randomUUID();
346
+ const artifacts = [];
347
+ let outputText = '';
348
+ let lastMetrics;
349
+ let runId;
350
+ let finished = false;
351
+ let lifecycleEnded = false;
352
+ let chatFinalReceived = false;
353
+ let gracePeriodTimeout;
354
+ const finish = (error) => {
355
+ if (finished)
356
+ return;
357
+ finished = true;
202
358
  signal.removeEventListener('abort', abortHandler);
359
+ if (taskTimeout)
360
+ clearTimeout(taskTimeout);
361
+ if (gracePeriodTimeout)
362
+ clearTimeout(gracePeriodTimeout);
363
+ ws.removeAllListeners();
364
+ ws.close();
203
365
  resolve({
204
- exitCode: code ?? 1,
205
- output: stdout,
206
- error: stderr || undefined,
366
+ output: outputText,
367
+ error,
207
368
  artifacts: artifacts.length > 0 ? artifacts : undefined,
208
369
  metrics: lastMetrics,
209
370
  });
210
- });
371
+ };
372
+ /** Finish when both lifecycle.end and chat.final have been seen, or
373
+ * after a short grace period if only lifecycle.end arrived. */
374
+ const tryFinishAfterLifecycle = () => {
375
+ if (chatFinalReceived) {
376
+ finish();
377
+ }
378
+ else {
379
+ // Grace period: if chat.final doesn't arrive within 500ms, finish anyway
380
+ gracePeriodTimeout = setTimeout(() => { if (!finished)
381
+ finish(); }, 500);
382
+ }
383
+ };
384
+ // Handle abort
385
+ const abortHandler = () => {
386
+ if (runId) {
387
+ // Try to abort the chat
388
+ try {
389
+ ws.send(JSON.stringify({
390
+ type: 'req',
391
+ id: 'abort-1',
392
+ method: 'chat.abort',
393
+ params: { sessionKey },
394
+ }));
395
+ }
396
+ catch {
397
+ // ignore
398
+ }
399
+ }
400
+ finish('Task cancelled');
401
+ };
402
+ signal.addEventListener('abort', abortHandler);
403
+ // Handle timeout
404
+ let taskTimeout;
211
405
  if (task.timeout) {
212
- setTimeout(() => {
213
- if (!proc.killed) {
214
- proc.kill('SIGTERM');
215
- setTimeout(() => {
216
- if (!proc.killed) {
217
- proc.kill('SIGKILL');
406
+ taskTimeout = setTimeout(() => {
407
+ finish('Task timed out');
408
+ }, task.timeout);
409
+ }
410
+ // Handle incoming events
411
+ ws.on('message', (data) => {
412
+ if (finished)
413
+ return;
414
+ let frame;
415
+ try {
416
+ frame = JSON.parse(String(data));
417
+ }
418
+ catch {
419
+ return;
420
+ }
421
+ // Handle chat.send response
422
+ if (frame.type === 'res' && frame.id === 'chat-send-1') {
423
+ if (frame.ok) {
424
+ runId = frame.payload?.runId;
425
+ stream.status('running', 10, 'Task dispatched to agent');
426
+ }
427
+ else {
428
+ finish(`Gateway rejected task: ${frame.error?.message || 'unknown'}`);
429
+ }
430
+ return;
431
+ }
432
+ // Handle agent events
433
+ if (frame.type === 'event' && frame.event === 'agent') {
434
+ const p = frame.payload || {};
435
+ // Filter to our session — gateway prepends 'agent:main:' to sessionKey
436
+ if (p.sessionKey !== `agent:main:${sessionKey}` && p.sessionKey !== sessionKey) {
437
+ return;
438
+ }
439
+ const streamType = p.stream;
440
+ const eventData = p.data;
441
+ if (streamType === 'lifecycle') {
442
+ const phase = eventData?.phase;
443
+ if (phase === 'start') {
444
+ stream.sessionInit(p.sessionKey || sessionKey, eventData?.model || undefined);
445
+ }
446
+ else if (phase === 'end') {
447
+ // Extract usage metrics from lifecycle.end if available
448
+ const usage = eventData?.usage;
449
+ const cost = (eventData?.total_cost_usd ?? eventData?.cost_usd);
450
+ const numTurns = eventData?.num_turns;
451
+ const durationMs = eventData?.duration_ms;
452
+ const model = eventData?.model;
453
+ if (usage || cost !== undefined) {
454
+ lastMetrics = {
455
+ inputTokens: usage?.input_tokens,
456
+ outputTokens: usage?.output_tokens,
457
+ totalCost: cost,
458
+ numTurns,
459
+ durationMs,
460
+ model,
461
+ };
218
462
  }
219
- }, 5000);
463
+ lifecycleEnded = true;
464
+ tryFinishAfterLifecycle();
465
+ }
220
466
  }
221
- }, task.timeout);
467
+ else if (streamType === 'assistant') {
468
+ const delta = eventData?.delta || eventData?.text;
469
+ if (delta) {
470
+ outputText += delta;
471
+ stream.text(delta);
472
+ }
473
+ }
474
+ else if (streamType === 'tool_use') {
475
+ const toolName = eventData?.name || eventData?.toolName || 'unknown';
476
+ const toolInput = eventData?.input || eventData?.toolInput || {};
477
+ stream.toolUse(toolName, toolInput);
478
+ }
479
+ else if (streamType === 'tool_result') {
480
+ const toolName = eventData?.name || eventData?.toolName || 'unknown';
481
+ const result = eventData?.result || eventData?.output || '';
482
+ const success = eventData?.success !== false;
483
+ stream.toolResult(toolName, result, success);
484
+ }
485
+ else if (streamType === 'file_change') {
486
+ const filePath = eventData?.path || eventData?.file;
487
+ const rawAction = eventData?.type || eventData?.action || 'modified';
488
+ const action = (['created', 'modified', 'deleted'].includes(rawAction) ? rawAction : 'modified');
489
+ if (filePath) {
490
+ artifacts.push({ type: 'file', name: filePath, path: filePath, metadata: { action } });
491
+ stream.fileChange(filePath, action);
492
+ }
493
+ }
494
+ return;
495
+ }
496
+ // Handle chat events (for final state + model info)
497
+ if (frame.type === 'event' && frame.event === 'chat') {
498
+ const p = frame.payload || {};
499
+ // Filter to our session — gateway prepends 'agent:main:' to sessionKey
500
+ if (p.sessionKey !== `agent:main:${sessionKey}` && p.sessionKey !== sessionKey) {
501
+ return;
502
+ }
503
+ const state = p.state;
504
+ if (state === 'final') {
505
+ chatFinalReceived = true;
506
+ // Extract final message content
507
+ const message = p.message;
508
+ if (message) {
509
+ const content = message.content;
510
+ if (content) {
511
+ for (const block of content) {
512
+ if (block.type === 'text' && block.text) {
513
+ // Only add if not already captured via agent delta events
514
+ if (!outputText.includes(block.text)) {
515
+ outputText += block.text;
516
+ }
517
+ }
518
+ }
519
+ }
520
+ }
521
+ // Extract model/usage from chat.final if not yet captured
522
+ if (!lastMetrics) {
523
+ const usage = p.usage;
524
+ const cost = (p.total_cost_usd ?? p.cost_usd);
525
+ const model = (p.model ?? message?.model);
526
+ if (usage || cost !== undefined || model) {
527
+ lastMetrics = {
528
+ inputTokens: usage?.input_tokens,
529
+ outputTokens: usage?.output_tokens,
530
+ totalCost: cost,
531
+ model,
532
+ };
533
+ }
534
+ }
535
+ // If lifecycle already ended, finish immediately
536
+ if (lifecycleEnded)
537
+ finish();
538
+ }
539
+ return;
540
+ }
541
+ // Handle tick/health/presence (ignore)
542
+ if (frame.type === 'event') {
543
+ const ignoredEvents = ['tick', 'health', 'presence', 'heartbeat'];
544
+ if (frame.event && ignoredEvents.includes(frame.event))
545
+ return;
546
+ }
547
+ });
548
+ ws.on('close', () => {
549
+ if (!finished) {
550
+ finish('Gateway connection closed unexpectedly');
551
+ }
552
+ });
553
+ ws.on('error', (err) => {
554
+ if (!finished) {
555
+ finish(`Gateway WebSocket error: ${err.message}`);
556
+ }
557
+ });
558
+ // Build the prompt
559
+ const effectivePrompt = task.systemPrompt
560
+ ? `${task.systemPrompt}\n\n---\n\n${task.prompt}`
561
+ : task.prompt;
562
+ // Send chat.send
563
+ try {
564
+ ws.send(JSON.stringify({
565
+ type: 'req',
566
+ id: 'chat-send-1',
567
+ method: 'chat.send',
568
+ params: {
569
+ sessionKey,
570
+ message: effectivePrompt,
571
+ idempotencyKey,
572
+ },
573
+ }));
574
+ }
575
+ catch (err) {
576
+ finish(`Failed to send chat.send: ${err instanceof Error ? err.message : String(err)}`);
222
577
  }
578
+ // Note: signal listener and timeout cleanup is handled in finish()
223
579
  });
224
580
  }
581
+ // ─── Reusable Chat Message Sender (for resume) ────────────────────
225
582
  /**
226
- * Handle a single JSONL line from OpenClaw's RPC output.
227
- *
228
- * OpenClaw JSONL event types:
229
- * - session.start → sessionInit
230
- * - content.text → text output
231
- * - tool_use.start → toolUse
232
- * - tool_use.end → toolResult
233
- * - file.change → fileChange
234
- * - message.start → status update (agent thinking)
235
- * - message.end → status update (turn complete)
236
- * - session.end → metrics extraction
237
- *
238
- * Returns metrics if a session.end event is processed.
583
+ * Send a chat message to an already-connected gateway WebSocket.
584
+ * Used by resumeTask() to continue a conversation on the same sessionKey.
239
585
  */
240
- handleStreamLine(line, stream, artifacts) {
241
- try {
242
- const event = JSON.parse(line);
243
- const type = event.type;
244
- switch (type) {
245
- case 'session.start': {
246
- const sessionId = event.session_id;
247
- const model = event.model;
248
- if (sessionId) {
249
- stream.sessionInit(sessionId, model);
250
- }
251
- break;
586
+ sendChatMessage(ws, sessionKey, message, stream, signal) {
587
+ return new Promise((resolve) => {
588
+ const idempotencyKey = randomUUID();
589
+ let outputText = '';
590
+ let finished = false;
591
+ let lifecycleEnded = false;
592
+ let chatFinalReceived = false;
593
+ let gracePeriodTimeout;
594
+ const finish = (error) => {
595
+ if (finished)
596
+ return;
597
+ finished = true;
598
+ signal.removeEventListener('abort', abortHandler);
599
+ if (gracePeriodTimeout)
600
+ clearTimeout(gracePeriodTimeout);
601
+ ws.removeAllListeners();
602
+ ws.close();
603
+ resolve({ output: outputText, error });
604
+ };
605
+ const tryFinishAfterLifecycle = () => {
606
+ if (chatFinalReceived) {
607
+ finish();
252
608
  }
253
- case 'message.start': {
254
- stream.status('running', undefined, 'Agent thinking...');
255
- break;
609
+ else {
610
+ gracePeriodTimeout = setTimeout(() => { if (!finished)
611
+ finish(); }, 500);
256
612
  }
257
- case 'content.text': {
258
- const text = event.text;
259
- if (text) {
260
- stream.text(text);
261
- }
262
- break;
613
+ };
614
+ const abortHandler = () => {
615
+ try {
616
+ ws.send(JSON.stringify({
617
+ type: 'req',
618
+ id: 'abort-resume',
619
+ method: 'chat.abort',
620
+ params: { sessionKey },
621
+ }));
263
622
  }
264
- case 'tool_use.start': {
265
- const toolName = event.tool_name || 'unknown';
266
- const toolInput = event.tool_input ?? {};
267
- stream.toolUse(toolName, toolInput);
268
- break;
623
+ catch { /* ignore */ }
624
+ finish('Task cancelled');
625
+ };
626
+ signal.addEventListener('abort', abortHandler);
627
+ ws.on('message', (data) => {
628
+ if (finished)
629
+ return;
630
+ let frame;
631
+ try {
632
+ frame = JSON.parse(String(data));
269
633
  }
270
- case 'tool_use.end': {
271
- const toolName = event.tool_name || 'unknown';
272
- const result = event.result;
273
- const success = event.success !== false;
274
- stream.toolResult(toolName, result, success);
275
- break;
634
+ catch {
635
+ return;
276
636
  }
277
- case 'file.change': {
278
- const path = event.path;
279
- const action = event.action || 'modified';
280
- const linesAdded = event.lines_added;
281
- const linesRemoved = event.lines_removed;
282
- if (path) {
283
- stream.fileChange(path, action, linesAdded, linesRemoved);
284
- if (!artifacts.some((a) => a.path === path)) {
285
- artifacts.push({ type: 'file', name: path, path });
286
- }
637
+ if (frame.type === 'res' && frame.id === 'chat-resume-1') {
638
+ if (!frame.ok) {
639
+ finish(`Gateway rejected resume: ${frame.error?.message || 'unknown'}`);
287
640
  }
288
- break;
289
- }
290
- case 'message.end': {
291
- stream.status('running', undefined, 'Turn complete');
292
- break;
641
+ return;
293
642
  }
294
- case 'session.end': {
295
- const cost = event.cost;
296
- const inputTokens = event.input_tokens;
297
- const outputTokens = event.output_tokens;
298
- const turns = event.turns;
299
- const model = event.model;
300
- const durationMs = event.duration_ms;
301
- if (cost !== undefined) {
302
- stream.status('running', 100, `Completed (${turns ?? 0} turns, $${cost.toFixed(4)})`);
643
+ if (frame.type === 'event' && frame.event === 'agent') {
644
+ const p = frame.payload || {};
645
+ if (p.sessionKey !== `agent:main:${sessionKey}` && p.sessionKey !== sessionKey)
646
+ return;
647
+ const streamType = p.stream;
648
+ const eventData = p.data;
649
+ if (streamType === 'lifecycle') {
650
+ const phase = eventData?.phase;
651
+ if (phase === 'end') {
652
+ lifecycleEnded = true;
653
+ tryFinishAfterLifecycle();
654
+ }
303
655
  }
304
- else {
305
- stream.status('running', 100, 'Completed');
656
+ else if (streamType === 'assistant') {
657
+ const delta = eventData?.delta || eventData?.text;
658
+ if (delta) {
659
+ outputText += delta;
660
+ stream.text(delta);
661
+ }
662
+ }
663
+ else if (streamType === 'tool_use') {
664
+ const toolName = eventData?.name || 'unknown';
665
+ stream.toolUse(toolName, eventData?.input || {});
666
+ }
667
+ else if (streamType === 'tool_result') {
668
+ const toolName = eventData?.name || 'unknown';
669
+ stream.toolResult(toolName, eventData?.result || '', eventData?.success !== false);
670
+ }
671
+ else if (streamType === 'file_change') {
672
+ const filePath = eventData?.path || eventData?.file;
673
+ if (filePath) {
674
+ const rawAction = eventData?.type || 'modified';
675
+ const action = (['created', 'modified', 'deleted'].includes(rawAction) ? rawAction : 'modified');
676
+ stream.fileChange(filePath, action);
677
+ }
306
678
  }
307
- return {
308
- totalCost: cost,
309
- inputTokens,
310
- outputTokens,
311
- numTurns: turns,
312
- model,
313
- durationMs,
314
- };
679
+ return;
315
680
  }
316
- default:
317
- // Unknown event type skip silently
318
- break;
681
+ if (frame.type === 'event' && frame.event === 'chat') {
682
+ const p = frame.payload || {};
683
+ if (p.sessionKey !== `agent:main:${sessionKey}` && p.sessionKey !== sessionKey)
684
+ return;
685
+ if (p.state === 'final') {
686
+ chatFinalReceived = true;
687
+ if (lifecycleEnded)
688
+ finish();
689
+ }
690
+ return;
691
+ }
692
+ });
693
+ ws.on('close', () => { if (!finished)
694
+ finish('Gateway connection closed'); });
695
+ ws.on('error', (err) => { if (!finished)
696
+ finish(`Gateway error: ${err.message}`); });
697
+ // Send the resume message
698
+ try {
699
+ ws.send(JSON.stringify({
700
+ type: 'req',
701
+ id: 'chat-resume-1',
702
+ method: 'chat.send',
703
+ params: {
704
+ sessionKey,
705
+ message,
706
+ idempotencyKey,
707
+ },
708
+ }));
319
709
  }
320
- }
321
- catch {
322
- // Not valid JSON — send as raw stdout
323
- stream.stdout(line + '\n');
324
- }
325
- return undefined;
710
+ catch (err) {
711
+ finish(`Failed to send chat.send: ${err instanceof Error ? err.message : String(err)}`);
712
+ }
713
+ });
326
714
  }
327
715
  }
328
716
  //# sourceMappingURL=openclaw-adapter.js.map