@questionbase/deskfree 0.3.0-alpha.2 → 0.3.0-alpha.21

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