@questionbase/deskfree 0.3.0-alpha.8 → 0.4.0-alpha.1

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