@questionbase/deskfree 0.3.0-alpha.3 → 0.3.0-alpha.30

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 (60) hide show
  1. package/README.md +44 -17
  2. package/dist/index.d.ts +768 -6
  3. package/dist/index.js +9545 -25
  4. package/dist/index.js.map +1 -1
  5. package/package.json +8 -9
  6. package/skills/deskfree/SKILL.md +168 -247
  7. package/skills/deskfree/references/tools.md +136 -0
  8. package/dist/channel.d.ts +0 -3
  9. package/dist/channel.d.ts.map +0 -1
  10. package/dist/channel.js +0 -505
  11. package/dist/channel.js.map +0 -1
  12. package/dist/client.d.ts +0 -143
  13. package/dist/client.d.ts.map +0 -1
  14. package/dist/client.js +0 -246
  15. package/dist/client.js.map +0 -1
  16. package/dist/context.d.ts +0 -15
  17. package/dist/context.d.ts.map +0 -1
  18. package/dist/context.js +0 -31
  19. package/dist/context.js.map +0 -1
  20. package/dist/deliver.d.ts +0 -22
  21. package/dist/deliver.d.ts.map +0 -1
  22. package/dist/deliver.js +0 -350
  23. package/dist/deliver.js.map +0 -1
  24. package/dist/gateway.d.ts +0 -13
  25. package/dist/gateway.d.ts.map +0 -1
  26. package/dist/gateway.js +0 -727
  27. package/dist/gateway.js.map +0 -1
  28. package/dist/index.d.ts.map +0 -1
  29. package/dist/llm-definitions.d.ts +0 -117
  30. package/dist/llm-definitions.d.ts.map +0 -1
  31. package/dist/llm-definitions.js +0 -121
  32. package/dist/llm-definitions.js.map +0 -1
  33. package/dist/offline-queue.d.ts +0 -45
  34. package/dist/offline-queue.d.ts.map +0 -1
  35. package/dist/offline-queue.js +0 -109
  36. package/dist/offline-queue.js.map +0 -1
  37. package/dist/paths.d.ts +0 -10
  38. package/dist/paths.d.ts.map +0 -1
  39. package/dist/paths.js +0 -29
  40. package/dist/paths.js.map +0 -1
  41. package/dist/runtime.d.ts +0 -17
  42. package/dist/runtime.d.ts.map +0 -1
  43. package/dist/runtime.js +0 -24
  44. package/dist/runtime.js.map +0 -1
  45. package/dist/tools.d.ts +0 -23
  46. package/dist/tools.d.ts.map +0 -1
  47. package/dist/tools.js +0 -437
  48. package/dist/tools.js.map +0 -1
  49. package/dist/types.d.ts +0 -479
  50. package/dist/types.d.ts.map +0 -1
  51. package/dist/types.js +0 -2
  52. package/dist/types.js.map +0 -1
  53. package/dist/version.d.ts +0 -2
  54. package/dist/version.d.ts.map +0 -1
  55. package/dist/version.js +0 -4
  56. package/dist/version.js.map +0 -1
  57. package/dist/workspace.d.ts +0 -18
  58. package/dist/workspace.d.ts.map +0 -1
  59. package/dist/workspace.js +0 -83
  60. package/dist/workspace.js.map +0 -1
package/dist/gateway.js DELETED
@@ -1,727 +0,0 @@
1
- // Use ESM imports for Node.js built-in modules
2
- import { DeskFreeClient } from './client';
3
- import { deliverMessageToAgent } from './deliver';
4
- import { resolvePluginStorePath } from './paths';
5
- import { getDeskFreeRuntime } from './runtime';
6
- import { PLUGIN_VERSION } from './version';
7
- import { resolveWorkspacePath } from './workspace';
8
- import { spawn } from 'node:child_process';
9
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
10
- import { dirname } from 'node:path';
11
- import WebSocket from 'ws';
12
- // ── Active Task Tracking ──────────────────────────────────────────────
13
- // Tracks the currently active task so outbound messages can be
14
- // automatically threaded into it without explicit taskId parameters.
15
- let activeTaskId = null;
16
- export function setActiveTaskId(taskId) {
17
- activeTaskId = taskId;
18
- }
19
- export function getActiveTaskId() {
20
- return activeTaskId;
21
- }
22
- // ── Workspace S3 Sync Helpers ─────────────────────────────────────────
23
- /**
24
- * Helper function to get AWS credentials from DeskFree and run an S3 command.
25
- * Replaces the previous API-based workspace sync approach with direct AWS CLI usage.
26
- */
27
- async function runS3CommandWithCredentials(client, buildCommand, workspacePath, log) {
28
- try {
29
- // Get short-lived credentials + S3 location from DeskFree
30
- const creds = await client.workspaceCredentials();
31
- const command = buildCommand(creds.s3Uri);
32
- // Set up environment with temporary credentials
33
- const env = {
34
- ...process.env,
35
- AWS_ACCESS_KEY_ID: creds.accessKeyId,
36
- AWS_SECRET_ACCESS_KEY: creds.secretAccessKey,
37
- AWS_SESSION_TOKEN: creds.sessionToken,
38
- AWS_DEFAULT_REGION: creds.region,
39
- };
40
- return new Promise((resolve, reject) => {
41
- const child = spawn('aws', command, {
42
- env,
43
- cwd: workspacePath,
44
- stdio: 'pipe',
45
- });
46
- let stdout = '';
47
- let stderr = '';
48
- child.stdout?.on('data', (data) => {
49
- stdout += data.toString();
50
- });
51
- child.stderr?.on('data', (data) => {
52
- stderr += data.toString();
53
- });
54
- child.on('close', (code) => {
55
- if (code === 0) {
56
- log.info(`S3 command succeeded: ${command.join(' ')}`);
57
- if (stdout.trim()) {
58
- log.debug(`S3 stdout: ${stdout.trim()}`);
59
- }
60
- resolve(true);
61
- }
62
- else {
63
- log.warn(`S3 command failed (exit ${code}): ${command.join(' ')}`);
64
- if (stderr.trim()) {
65
- log.warn(`S3 stderr: ${stderr.trim()}`);
66
- }
67
- reject(new Error(`AWS CLI command failed with exit code ${code}: ${stderr}`));
68
- }
69
- });
70
- child.on('error', (err) => {
71
- log.warn(`S3 command spawn error: ${err.message}`);
72
- reject(err);
73
- });
74
- });
75
- }
76
- catch (err) {
77
- const message = err instanceof Error ? err.message : String(err);
78
- log.warn(`Failed to get S3 credentials or run command: ${message}`);
79
- return false;
80
- }
81
- }
82
- const PING_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes (API GW idle timeout = 10 min)
83
- const POLL_FALLBACK_INTERVAL_MS = 30 * 1000; // 30s fallback when WS is down
84
- const WS_CONNECTION_TIMEOUT_MS = 30 * 1000; // 30s timeout for initial connection
85
- const WS_PONG_TIMEOUT_MS = 10 * 1000; // 10s timeout waiting for pong response
86
- const BACKOFF_INITIAL_MS = 2000;
87
- const BACKOFF_MAX_MS = 30000;
88
- const BACKOFF_FACTOR = 1.8;
89
- const HEALTH_LOG_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
90
- const MAX_CONSECUTIVE_POLL_FAILURES = 5; // Switch to longer backoff after this many
91
- // Dedup set to prevent re-delivering messages due to cursor precision issues
92
- // (Postgres microsecond timestamps vs JS millisecond Date precision)
93
- const deliveredMessageIds = new Set();
94
- // Module-level health state persists across reconnects
95
- const healthState = new Map();
96
- function initializeHealth(accountId) {
97
- if (!healthState.has(accountId)) {
98
- healthState.set(accountId, {
99
- connectionStartTime: Date.now(),
100
- totalReconnects: 0,
101
- lastReconnectAt: null,
102
- avgReconnectInterval: 0,
103
- totalMessagesDelivered: 0,
104
- lastMessageAt: null,
105
- currentMode: 'websocket',
106
- });
107
- }
108
- }
109
- function updateHealthMode(accountId, mode) {
110
- const health = healthState.get(accountId);
111
- if (health) {
112
- health.currentMode = mode;
113
- }
114
- }
115
- function recordReconnect(accountId) {
116
- const health = healthState.get(accountId);
117
- if (health) {
118
- const now = Date.now();
119
- if (health.lastReconnectAt) {
120
- const interval = now - health.lastReconnectAt;
121
- health.avgReconnectInterval =
122
- (health.avgReconnectInterval * health.totalReconnects + interval) /
123
- (health.totalReconnects + 1);
124
- }
125
- health.totalReconnects++;
126
- health.lastReconnectAt = now;
127
- }
128
- }
129
- function recordMessageDelivery(accountId, count) {
130
- const health = healthState.get(accountId);
131
- if (health) {
132
- health.totalMessagesDelivered += count;
133
- health.lastMessageAt = Date.now();
134
- }
135
- }
136
- function formatDuration(ms) {
137
- const seconds = Math.floor(ms / 1000);
138
- const minutes = Math.floor(seconds / 60);
139
- const hours = Math.floor(minutes / 60);
140
- if (hours > 0) {
141
- return `${hours}h${minutes % 60}m`;
142
- }
143
- else if (minutes > 0) {
144
- return `${minutes}m${seconds % 60}s`;
145
- }
146
- else {
147
- return `${seconds}s`;
148
- }
149
- }
150
- function logHealthSummary(accountId, log) {
151
- const health = healthState.get(accountId);
152
- if (!health)
153
- return;
154
- const uptime = Date.now() - health.connectionStartTime;
155
- log.info(`DeskFree health: uptime=${formatDuration(uptime)}, ` +
156
- `reconnects=${health.totalReconnects}, ` +
157
- `messages=${health.totalMessagesDelivered}, ` +
158
- `mode=${health.currentMode}`);
159
- }
160
- // Serialize poll operations per account to prevent duplicate message delivery.
161
- // Each account gets its own promise-chain mutex so polls don't block across accounts.
162
- const pollChains = new Map();
163
- function enqueuePoll(client, ctx, getCursor, setCursor, log, account) {
164
- const accountId = ctx.accountId;
165
- const prev = pollChains.get(accountId) ?? Promise.resolve();
166
- const next = prev
167
- .then(async () => {
168
- const newCursor = await pollAndDeliver(client, ctx, getCursor(), log, account);
169
- if (newCursor)
170
- setCursor(newCursor);
171
- })
172
- .catch((err) => {
173
- const message = err instanceof Error ? err.message : String(err);
174
- log.error(`Poll error: ${message}`);
175
- });
176
- pollChains.set(accountId, next);
177
- }
178
- function nextBackoff(state) {
179
- const delay = Math.min(BACKOFF_INITIAL_MS * Math.pow(BACKOFF_FACTOR, state.attempt), BACKOFF_MAX_MS);
180
- const jitter = delay * 0.25 * Math.random();
181
- state.attempt++;
182
- return delay + jitter;
183
- }
184
- function resetBackoff(state) {
185
- state.attempt = 0;
186
- }
187
- // Clean up abort listener to prevent memory leak
188
- function sleepWithAbort(ms, signal) {
189
- return new Promise((resolve) => {
190
- const onAbort = () => {
191
- clearTimeout(timer);
192
- resolve();
193
- };
194
- const timer = setTimeout(() => {
195
- signal.removeEventListener('abort', onAbort);
196
- resolve();
197
- }, ms);
198
- signal.addEventListener('abort', onAbort, { once: true });
199
- });
200
- }
201
- /**
202
- * Persistent cursor management.
203
- * Stores cursor on disk so we can resume from the right position after restart.
204
- */
205
- function loadCursor(ctx) {
206
- try {
207
- const cursorPath = resolvePluginStorePath(`cursors/${ctx.accountId}/cursor`);
208
- return readFileSync(cursorPath, 'utf-8').trim() || null;
209
- }
210
- catch {
211
- return null;
212
- }
213
- }
214
- function saveCursor(ctx, cursor, log) {
215
- try {
216
- const filePath = resolvePluginStorePath(`cursors/${ctx.accountId}/cursor`);
217
- const dir = dirname(filePath);
218
- mkdirSync(dir, { recursive: true });
219
- writeFileSync(filePath, cursor, 'utf-8');
220
- }
221
- catch (err) {
222
- // Non-fatal — we'll just re-process some messages on restart
223
- const message = err instanceof Error ? err.message : String(err);
224
- log?.warn(`Failed to persist cursor: ${message}`);
225
- }
226
- }
227
- /**
228
- * Main entry point for the DeskFree channel gateway.
229
- * Called by OpenClaw's channel manager via gateway.startAccount().
230
- *
231
- * Maintains a WebSocket connection for real-time notifications
232
- * and polls the messages endpoint for actual data.
233
- * Falls back to interval-based polling if WebSocket is unavailable.
234
- */
235
- export async function startDeskFreeConnection(ctx) {
236
- const account = ctx.account;
237
- const client = new DeskFreeClient(account.botToken, account.apiUrl);
238
- const log = ctx.log ?? getDeskFreeRuntime().logging.createLogger('deskfree');
239
- let cursor = loadCursor(ctx);
240
- const backoff = { attempt: 0 };
241
- let totalReconnects = 0;
242
- // Initialize health tracking for this account
243
- initializeHealth(ctx.accountId);
244
- log.info(`Starting DeskFree connection for account ${ctx.accountId}` +
245
- (cursor ? ` (resuming from cursor ${cursor})` : ' (fresh start)'));
246
- // Start health logging timer
247
- const healthInterval = setInterval(() => {
248
- if (!ctx.abortSignal.aborted) {
249
- logHealthSummary(ctx.accountId, log);
250
- }
251
- }, HEALTH_LOG_INTERVAL_MS);
252
- // Clean up health interval on shutdown
253
- ctx.abortSignal.addEventListener('abort', () => {
254
- clearInterval(healthInterval);
255
- }, { once: true });
256
- // Outer reconnection loop — runs until OpenClaw shuts down
257
- while (!ctx.abortSignal.aborted) {
258
- try {
259
- const { ticket, wsUrl } = await client.getWsTicket();
260
- resetBackoff(backoff);
261
- if (totalReconnects > 0) {
262
- log.info(`Got WS ticket, reconnecting to ${wsUrl}... (reconnect #${totalReconnects})`);
263
- recordReconnect(ctx.accountId);
264
- }
265
- else {
266
- log.info(`Got WS ticket, connecting to ${wsUrl}...`);
267
- }
268
- updateHealthMode(ctx.accountId, 'websocket');
269
- cursor = await runWebSocketConnection({
270
- ticket,
271
- wsUrl,
272
- client,
273
- ctx,
274
- cursor,
275
- log,
276
- account,
277
- });
278
- // Connection closed cleanly — will reconnect
279
- totalReconnects++;
280
- }
281
- catch (err) {
282
- totalReconnects++;
283
- const message = err instanceof Error ? err.message : String(err);
284
- // If ticket fetch fails, fall back to polling
285
- if (message.includes('API error') ||
286
- message.includes('authentication failed') ||
287
- message.includes('server error')) {
288
- log.warn(`Ticket fetch failed (attempt #${totalReconnects}): ${message}. Falling back to polling.`);
289
- recordReconnect(ctx.accountId);
290
- updateHealthMode(ctx.accountId, 'polling');
291
- cursor = await runPollingFallback({ client, ctx, cursor, log });
292
- }
293
- else {
294
- log.warn(`Connection error (attempt #${totalReconnects}): ${message}`);
295
- recordReconnect(ctx.accountId);
296
- }
297
- if (ctx.abortSignal.aborted)
298
- break;
299
- const delay = nextBackoff(backoff);
300
- log.info(`Reconnecting in ${Math.round(delay)}ms (attempt #${totalReconnects + 1})...`);
301
- await sleepWithAbort(delay, ctx.abortSignal);
302
- }
303
- }
304
- log.info(`DeskFree connection loop exited after ${totalReconnects} reconnect(s).`);
305
- }
306
- /**
307
- * Runs a single WebSocket connection session.
308
- * Returns the latest cursor when the connection closes.
309
- */
310
- async function runWebSocketConnection(opts) {
311
- const { ticket, wsUrl, client, ctx, log, account } = opts;
312
- let cursor = opts.cursor;
313
- return new Promise((resolve, reject) => {
314
- const ws = new WebSocket(`${wsUrl}?ticket=${ticket}`);
315
- let pingInterval;
316
- let connectionTimer;
317
- let pongTimer;
318
- let isConnected = false;
319
- // Centralized cleanup to prevent timer leaks
320
- const cleanup = () => {
321
- if (pingInterval !== undefined) {
322
- clearInterval(pingInterval);
323
- pingInterval = undefined;
324
- }
325
- if (connectionTimer !== undefined) {
326
- clearTimeout(connectionTimer);
327
- connectionTimer = undefined;
328
- }
329
- if (pongTimer !== undefined) {
330
- clearTimeout(pongTimer);
331
- pongTimer = undefined;
332
- }
333
- };
334
- // Connection timeout - if no 'open' event within timeout, fail
335
- connectionTimer = setTimeout(() => {
336
- if (!isConnected) {
337
- cleanup();
338
- try {
339
- ws.close();
340
- }
341
- catch {
342
- // Ignore close errors during timeout cleanup
343
- }
344
- reject(new Error(`WebSocket connection timeout after ${WS_CONNECTION_TIMEOUT_MS}ms`));
345
- }
346
- }, WS_CONNECTION_TIMEOUT_MS);
347
- ws.on('open', async () => {
348
- isConnected = true;
349
- if (connectionTimer !== undefined) {
350
- clearTimeout(connectionTimer);
351
- connectionTimer = undefined;
352
- }
353
- ctx.setStatus({ running: true, lastStartAt: Date.now() });
354
- log.info('WebSocket connected.');
355
- // Keepalive ping every 5 minutes with pong timeout handling
356
- pingInterval = setInterval(() => {
357
- if (ws.readyState === WebSocket.OPEN) {
358
- try {
359
- ws.send(JSON.stringify({ action: 'ping' }));
360
- // Start pong timeout
361
- pongTimer = setTimeout(() => {
362
- log.warn('Pong timeout - closing WebSocket connection');
363
- try {
364
- ws.close(1002, 'pong timeout');
365
- }
366
- catch (closeErr) {
367
- const closeMsg = closeErr instanceof Error
368
- ? closeErr.message
369
- : String(closeErr);
370
- log.warn(`Error closing WebSocket on pong timeout: ${closeMsg}`);
371
- }
372
- }, WS_PONG_TIMEOUT_MS);
373
- }
374
- catch (err) {
375
- const message = err instanceof Error ? err.message : String(err);
376
- log.warn(`Failed to send ping: ${message}`);
377
- }
378
- }
379
- }, PING_INTERVAL_MS);
380
- // Report plugin + OpenClaw versions on connect
381
- const openclawVersion = getDeskFreeRuntime().version;
382
- client
383
- .statusUpdate({
384
- status: 'idle',
385
- activeSubAgents: [],
386
- pluginVersion: PLUGIN_VERSION,
387
- openclawVersion,
388
- })
389
- .catch((err) => {
390
- const msg = err instanceof Error ? err.message : String(err);
391
- log.warn(`Failed to send statusUpdate: ${msg}`);
392
- });
393
- // Upload local workspace files to S3 on startup (backup local → S3)
394
- try {
395
- const cfg = getDeskFreeRuntime().config.loadConfig();
396
- const workspacePath = resolveWorkspacePath(cfg);
397
- if (workspacePath) {
398
- const success = await runS3CommandWithCredentials(client, (s3Uri) => ['s3', 'sync', '.', s3Uri, '--only-show-errors'], workspacePath, log);
399
- if (success) {
400
- log.info(`Workspace sync: uploaded files from ${workspacePath} to S3`);
401
- }
402
- else {
403
- log.warn('Workspace sync: failed to upload files to S3');
404
- }
405
- }
406
- else {
407
- log.debug('Workspace sync: no workspace path configured (agents.defaults.workspace)');
408
- }
409
- }
410
- catch (err) {
411
- const msg = err instanceof Error ? err.message : String(err);
412
- log.warn(`Workspace sync failed: ${msg}`);
413
- }
414
- // NOTE: DeskFree directives are now injected via before_agent_start hook
415
- // (see index.ts) instead of writing to AGENTS.md/HEARTBEAT.md files.
416
- // Enqueue initial catch-up poll through mutex
417
- enqueuePoll(client, ctx, () => cursor, (c) => {
418
- cursor = c ?? cursor;
419
- }, log, account);
420
- });
421
- ws.on('message', async (data) => {
422
- try {
423
- const raw = data.toString();
424
- if (!raw || raw.length > 65536) {
425
- log.warn(`Ignoring oversized or empty WS message (${raw?.length ?? 0} bytes)`);
426
- return;
427
- }
428
- const msg = JSON.parse(raw);
429
- if (!msg || typeof msg.action !== 'string') {
430
- log.warn('Ignoring WS message with missing or invalid action field');
431
- return;
432
- }
433
- if (msg.action === 'notify') {
434
- const notifyMsg = msg;
435
- // Handle workspace file changes from human edits
436
- if (notifyMsg.hint === 'workspace.fileChanged') {
437
- const paths = notifyMsg.paths ?? [];
438
- log.info(`Workspace file(s) changed by human: ${paths.join(', ') || '(all)'}`);
439
- // Download changed files using aws s3 cp
440
- try {
441
- const cfg = getDeskFreeRuntime().config.loadConfig();
442
- const workspacePath = resolveWorkspacePath(cfg);
443
- if (workspacePath && paths.length > 0) {
444
- for (const filePath of paths) {
445
- try {
446
- // Use aws s3 cp to download the specific changed file
447
- const success = await runS3CommandWithCredentials(client, (s3Uri) => [
448
- 's3',
449
- 'cp',
450
- `${s3Uri}/${filePath}`,
451
- filePath,
452
- '--only-show-errors',
453
- ], workspacePath, log);
454
- if (success) {
455
- log.info(`Updated local file: ${filePath}`);
456
- }
457
- else {
458
- log.warn(`Failed to download workspace file: ${filePath}`);
459
- }
460
- }
461
- catch (err) {
462
- const errMsg = err instanceof Error ? err.message : String(err);
463
- log.warn(`Failed to download workspace file ${filePath}: ${errMsg}`);
464
- }
465
- }
466
- }
467
- else if (!workspacePath) {
468
- log.warn('Cannot sync workspace files: workspace path not configured');
469
- }
470
- }
471
- catch (err) {
472
- const errMsg = err instanceof Error ? err.message : String(err);
473
- log.warn(`Workspace file change handling failed: ${errMsg}`);
474
- }
475
- }
476
- // Handle workspace upload requests (upload local → S3)
477
- if (notifyMsg.hint === 'workspace.uploadRequested') {
478
- log.info('Workspace upload requested');
479
- try {
480
- const cfg = getDeskFreeRuntime().config.loadConfig();
481
- const workspacePath = resolveWorkspacePath(cfg);
482
- if (workspacePath) {
483
- const success = await runS3CommandWithCredentials(client, (s3Uri) => ['s3', 'sync', '.', s3Uri, '--only-show-errors'], workspacePath, log);
484
- if (success) {
485
- log.info(`Workspace upload: synced ${workspacePath} to S3`);
486
- }
487
- else {
488
- log.warn('Workspace upload: failed to sync to S3');
489
- }
490
- }
491
- else {
492
- log.warn('Cannot upload workspace: workspace path not configured');
493
- }
494
- }
495
- catch (err) {
496
- const errMsg = err instanceof Error ? err.message : String(err);
497
- log.warn(`Workspace upload failed: ${errMsg}`);
498
- }
499
- }
500
- // Enqueue notification-triggered poll through mutex
501
- enqueuePoll(client, ctx, () => cursor, (c) => {
502
- cursor = c ?? cursor;
503
- }, log);
504
- }
505
- else if (msg.action === 'pong') {
506
- // Clear pong timeout - connection is healthy
507
- if (pongTimer !== undefined) {
508
- clearTimeout(pongTimer);
509
- pongTimer = undefined;
510
- }
511
- log.debug('Received pong - connection healthy');
512
- }
513
- // Ignore other message types
514
- }
515
- catch (err) {
516
- const message = err instanceof Error ? err.message : String(err);
517
- log.warn(`Error processing WS message: ${message}`);
518
- }
519
- });
520
- ws.on('close', (code, reason) => {
521
- cleanup();
522
- isConnected = false;
523
- ctx.setStatus({ running: false, lastStopAt: Date.now() });
524
- // Classify close codes for better logging and error handling
525
- if (code === 1000) {
526
- log.info(`WebSocket closed normally: ${code} ${reason.toString()}`);
527
- }
528
- else if (code === 1001) {
529
- log.info(`WebSocket closed - endpoint going away: ${code} ${reason.toString()}`);
530
- }
531
- else if (code >= 1002 && code <= 1011) {
532
- log.warn(`WebSocket closed with error: ${code} ${reason.toString()}`);
533
- ctx.setStatus({
534
- running: false,
535
- lastStopAt: Date.now(),
536
- lastError: `WebSocket closed with code ${code}: ${reason.toString()}`,
537
- });
538
- }
539
- else if (code >= 4000) {
540
- log.warn(`WebSocket closed with application error: ${code} ${reason.toString()}`);
541
- ctx.setStatus({
542
- running: false,
543
- lastStopAt: Date.now(),
544
- lastError: `Application error ${code}: ${reason.toString()}`,
545
- });
546
- }
547
- else {
548
- log.info(`WebSocket closed: ${code} ${reason.toString()}`);
549
- }
550
- resolve(cursor);
551
- });
552
- ws.on('error', (err) => {
553
- cleanup();
554
- isConnected = false;
555
- const errorMessage = err instanceof Error ? err.message : String(err);
556
- log.error(`WebSocket error: ${errorMessage}`);
557
- ctx.setStatus({
558
- running: false,
559
- lastError: errorMessage,
560
- lastStopAt: Date.now(),
561
- });
562
- reject(err);
563
- });
564
- // Clean shutdown - register abort handler before potential errors
565
- ctx.abortSignal.addEventListener('abort', () => {
566
- log.info('Shutdown requested - closing WebSocket');
567
- cleanup();
568
- try {
569
- if (ws.readyState === WebSocket.OPEN ||
570
- ws.readyState === WebSocket.CONNECTING) {
571
- ws.close(1000, 'shutdown');
572
- }
573
- }
574
- catch (err) {
575
- const message = err instanceof Error ? err.message : String(err);
576
- log.warn(`Error closing WebSocket during shutdown: ${message}`);
577
- }
578
- }, { once: true });
579
- });
580
- }
581
- /**
582
- * Fallback polling loop when WebSocket is unavailable.
583
- * Polls every 30s until aborted or until we should retry WebSocket.
584
- */
585
- async function runPollingFallback(opts) {
586
- const { client, ctx, log } = opts;
587
- let cursor = opts.cursor;
588
- let iterations = 0;
589
- const maxIterations = 10; // After 10 polls (~5 min), try WebSocket again
590
- ctx.setStatus({ running: true, lastStartAt: Date.now() });
591
- log.info('Running in polling fallback mode.');
592
- let consecutiveFailures = 0;
593
- while (!ctx.abortSignal.aborted && iterations < maxIterations) {
594
- const newCursor = await pollAndDeliver(client, ctx, cursor, log);
595
- if (newCursor) {
596
- cursor = newCursor;
597
- consecutiveFailures = 0;
598
- }
599
- else if (newCursor === null && cursor) {
600
- // null return with existing cursor means poll succeeded but no new messages - fine
601
- consecutiveFailures = 0;
602
- }
603
- else {
604
- consecutiveFailures++;
605
- if (consecutiveFailures >= MAX_CONSECUTIVE_POLL_FAILURES) {
606
- log.warn(`${consecutiveFailures} consecutive poll failures, breaking out to retry WebSocket`);
607
- break;
608
- }
609
- }
610
- iterations++;
611
- // Add jitter to poll interval to avoid thundering herd
612
- const jitter = Math.random() * POLL_FALLBACK_INTERVAL_MS * 0.2;
613
- await sleepWithAbort(POLL_FALLBACK_INTERVAL_MS + jitter, ctx.abortSignal);
614
- }
615
- ctx.setStatus({ running: false, lastStopAt: Date.now() });
616
- return cursor;
617
- }
618
- /**
619
- * Poll the messages endpoint and deliver new messages to the OpenClaw agent.
620
- * Returns the new cursor, or null if no messages.
621
- *
622
- * On first run (cursor is null), we skip delivering old messages and just
623
- * seed the cursor so we only receive messages from this point forward.
624
- */
625
- async function pollAndDeliver(client, ctx, cursor, log, account) {
626
- try {
627
- // SEED is a sentinel value indicating we've completed the first-run
628
- // flow but had no real cursor (empty inbox). Don't send it to the API
629
- // since it's not a valid datetime cursor.
630
- const SEED = 'SEED';
631
- const isFirstRun = !cursor || cursor === SEED;
632
- const apiCursor = cursor && cursor !== SEED ? cursor : undefined;
633
- const response = await client.listMessages({
634
- ...(apiCursor ? { cursor: apiCursor } : {}),
635
- });
636
- // On first run, don't deliver old messages — just seed the cursor
637
- // and send a welcome message so the user knows the connection is live.
638
- if (isFirstRun) {
639
- if (response.cursor) {
640
- log.info(`First run: skipping ${response.items.length} existing message(s), seeding cursor.`);
641
- saveCursor(ctx, response.cursor, log);
642
- }
643
- else {
644
- log.info('First run: no messages yet (empty inbox).');
645
- }
646
- log.info('Connected to DeskFree. Ready to receive messages and tasks.');
647
- // Send welcome message to trigger the first agent session.
648
- // This ensures workspace files get scaffolded on fresh instances.
649
- try {
650
- const botName = account?.botName;
651
- const humanName = account?.humanName;
652
- const welcomeContent = botName && humanName
653
- ? `Hi! I'm ${humanName}. Your name is ${botName}. Welcome to DeskFree — read your BOOTSTRAP.md to get started, then check for any tasks.`
654
- : "DeskFree plugin installed! Read your BOOTSTRAP.md if you haven't already, then check for any tasks.";
655
- const welcomeMessage = {
656
- messageId: `welcome-${Date.now()}`,
657
- botId: '',
658
- humanId: 'system',
659
- authorType: 'user',
660
- content: welcomeContent,
661
- createdAt: new Date().toISOString(),
662
- userName: humanName ?? 'System',
663
- };
664
- await deliverMessageToAgent(ctx, welcomeMessage, client);
665
- log.info('Sent welcome message to agent.');
666
- }
667
- catch (err) {
668
- const msg = err instanceof Error ? err.message : String(err);
669
- log.warn(`Failed to send welcome message: ${msg}`);
670
- }
671
- // Return the API cursor, or SEED as sentinel when API returns null
672
- // (empty inbox) so subsequent polls don't re-trigger first-run flow.
673
- return response.cursor ?? SEED;
674
- }
675
- if (response.items.length === 0)
676
- return null;
677
- // Filter out already-delivered messages (handles cursor precision issues
678
- // where Postgres microsecond timestamps round-trip through JS millisecond Date)
679
- const newItems = response.items.filter((m) => !deliveredMessageIds.has(m.messageId));
680
- if (newItems.length === 0) {
681
- log.debug(`Poll returned ${response.items.length} item(s), all already delivered.`);
682
- // Still advance cursor even if all items were duplicates
683
- if (response.cursor) {
684
- saveCursor(ctx, response.cursor, log);
685
- }
686
- return response.cursor;
687
- }
688
- log.info(`Received ${newItems.length} new message(s) (${response.items.length - newItems.length} duplicate(s) skipped).`);
689
- // Save cursor after each successful delivery so a mid-batch
690
- // failure doesn't cause the entire batch to be re-delivered on retry.
691
- let deliveredCount = 0;
692
- for (const message of newItems) {
693
- // Skip bot's own messages to prevent echo loops
694
- if (message.authorType === 'bot') {
695
- log.debug(`Skipping bot message ${message.messageId}`);
696
- deliveredMessageIds.add(message.messageId);
697
- continue;
698
- }
699
- await deliverMessageToAgent(ctx, message, client);
700
- deliveredMessageIds.add(message.messageId);
701
- deliveredCount++;
702
- }
703
- // Cap the dedup set to prevent unbounded memory growth
704
- if (deliveredMessageIds.size > 1000) {
705
- const entries = Array.from(deliveredMessageIds);
706
- deliveredMessageIds.clear();
707
- for (const id of entries.slice(-500)) {
708
- deliveredMessageIds.add(id);
709
- }
710
- }
711
- // Record message delivery in health stats
712
- if (deliveredCount > 0) {
713
- recordMessageDelivery(ctx.accountId, deliveredCount);
714
- }
715
- // Persist cursor
716
- if (response.cursor) {
717
- saveCursor(ctx, response.cursor, log);
718
- }
719
- return response.cursor;
720
- }
721
- catch (err) {
722
- const message = err instanceof Error ? err.message : String(err);
723
- log.warn(`Poll failed: ${message}`);
724
- return null;
725
- }
726
- }
727
- //# sourceMappingURL=gateway.js.map