@minionry/minion 0.7.36 → 0.7.39

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 (120) hide show
  1. package/bin/commands/minions.js +19 -0
  2. package/bin/minion.js +33 -4
  3. package/dist/server/cli/headless/claude-invoker-process.js +23 -7
  4. package/dist/server/cli/headless/claude-invoker-stream.js +8 -3
  5. package/dist/server/cli/headless/haiku-assessments.js +2 -1
  6. package/dist/server/cli/headless/mcp-config.js +2 -1
  7. package/dist/server/engines/claude/claude-command.js +1 -0
  8. package/dist/server/engines/factory.js +2 -0
  9. package/dist/server/engines/hermes/HermesEngine.js +112 -219
  10. package/dist/server/engines/hermes/hermes-gateway-registry.js +467 -0
  11. package/dist/server/index.js +10 -4
  12. package/dist/server/mcp/bouncer-integration.js +3 -2
  13. package/dist/server/mcp/classifier/ClaudeBouncerClassifier.js +35 -8
  14. package/dist/server/mcp/classifier/shadow-eval-scheduler.js +42 -0
  15. package/dist/server/mcp/classifier/telemetry.js +0 -0
  16. package/dist/server/mcp/permission-channels.js +176 -0
  17. package/dist/server/mcp/security-analysis.js +9 -1
  18. package/dist/server/mcp/security-audit.js +22 -2
  19. package/dist/server/mcp/security-patterns.js +73 -1
  20. package/dist/server/mcp/server.js +80 -50
  21. package/dist/server/routes/internal.js +6 -1
  22. package/dist/server/routes/notifications.js +6 -18
  23. package/dist/server/server-setup.js +24 -48
  24. package/dist/server/services/analytics.js +4 -2
  25. package/dist/server/services/browser/host-mock.js +6 -1
  26. package/dist/server/services/browser/host.js +27 -3
  27. package/dist/server/services/browser/reasoning-agent.js +3 -1
  28. package/dist/server/services/chain/chain-engine.js +494 -0
  29. package/dist/server/services/chain/chain-store.js +542 -0
  30. package/dist/server/services/git/haiku.js +2 -2
  31. package/dist/server/services/plan/agents/check-injection.md +43 -2
  32. package/dist/server/services/plan/agents/review-code.md +62 -3
  33. package/dist/server/services/plan/agents/review-quality.md +52 -2
  34. package/dist/server/services/plan/board-export.js +4 -0
  35. package/dist/server/services/plan/composer-prompt.js +27 -8
  36. package/dist/server/services/plan/composer.js +10 -4
  37. package/dist/server/services/plan/config-installer.js +4 -83
  38. package/dist/server/services/plan/executor.js +155 -73
  39. package/dist/server/services/plan/issue-effort.js +9 -21
  40. package/dist/server/services/plan/issue-prompt-builder.js +72 -9
  41. package/dist/server/services/plan/parser-core.js +6 -1
  42. package/dist/server/services/plan/quality-delta.js +6 -24
  43. package/dist/server/services/plan/readiness-planner.js +12 -4
  44. package/dist/server/services/plan/record-file.js +52 -0
  45. package/dist/server/services/plan/review-approval.js +64 -0
  46. package/dist/server/services/plan/review-gate.js +186 -77
  47. package/dist/server/services/plan/review-outcome.js +146 -0
  48. package/dist/server/services/plan/review-report.js +28 -1
  49. package/dist/server/services/plan/review-target.js +57 -0
  50. package/dist/server/services/plan/state-reconciler.js +3 -3
  51. package/dist/server/services/plan/template-diff.js +2 -0
  52. package/dist/server/services/plan/template-instantiator.js +4 -0
  53. package/dist/server/services/plan/watcher.js +11 -1
  54. package/dist/server/services/platform-reconnect.js +7 -0
  55. package/dist/server/services/platform-token-lifecycle.js +2 -1
  56. package/dist/server/services/platform.js +293 -50
  57. package/dist/server/services/relay-http-client.js +107 -0
  58. package/dist/server/services/schedule/agent-schedule-store.js +69 -8
  59. package/dist/server/services/schedule/agent-scheduler.js +48 -7
  60. package/dist/server/services/schedule/daily-timing.js +52 -0
  61. package/dist/server/services/schedule/prompt-file.js +55 -0
  62. package/dist/server/services/schedule/run-bootstrap.js +20 -5
  63. package/dist/server/services/schedule/schedule-store.js +54 -4
  64. package/dist/server/services/schedule/schedule-wire.js +17 -0
  65. package/dist/server/services/schedule/scheduler.js +43 -9
  66. package/dist/server/services/sdk/agent-schedule.js +294 -0
  67. package/dist/server/services/sdk/agents.js +95 -13
  68. package/dist/server/services/sdk/app-tools.js +44 -7
  69. package/dist/server/services/sdk/atomic-write.js +75 -0
  70. package/dist/server/services/sdk/browser.js +5 -1
  71. package/dist/server/services/sdk/composer.js +2 -0
  72. package/dist/server/services/sdk/file-change-notify.js +60 -13
  73. package/dist/server/services/sdk/files-app.js +12 -7
  74. package/dist/server/services/sdk/files-search.js +14 -0
  75. package/dist/server/services/sdk/files-transfer.js +1 -1
  76. package/dist/server/services/sdk/files-watch.js +721 -0
  77. package/dist/server/services/sdk/files-workspace.js +37 -11
  78. package/dist/server/services/sdk/inference-claude.js +4 -2
  79. package/dist/server/services/sdk/inference.js +16 -2
  80. package/dist/server/services/sdk/pm-chain.js +0 -0
  81. package/dist/server/services/sdk/pm-schedule.js +72 -15
  82. package/dist/server/services/sdk/pm.js +22 -0
  83. package/dist/server/services/sdk/rate-limits.js +32 -0
  84. package/dist/server/services/sdk/registry.js +40 -2
  85. package/dist/server/services/sdk/terminal.js +1 -1
  86. package/dist/server/services/sentry.js +5 -1
  87. package/dist/server/services/terminal/pty-manager.js +26 -11
  88. package/dist/server/services/terminal/pty-utils.js +12 -1
  89. package/dist/server/services/terminal/terminal-modes.js +87 -0
  90. package/dist/server/services/timeline/assembler.js +2 -2
  91. package/dist/server/services/timeline/index.js +1 -1
  92. package/dist/server/services/websocket/agent-schedule-handlers.js +62 -4
  93. package/dist/server/services/websocket/ask-user-question-bridge.js +2 -2
  94. package/dist/server/services/websocket/browser-agent-runtime.js +3 -1
  95. package/dist/server/services/websocket/browser-handlers.js +5 -2
  96. package/dist/server/services/websocket/browser-viewer-lifecycle.js +8 -0
  97. package/dist/server/services/websocket/file-explorer-handlers.js +17 -0
  98. package/dist/server/services/websocket/file-transfer-http.js +12 -6
  99. package/dist/server/services/websocket/handler.js +185 -33
  100. package/dist/server/services/websocket/msg-id-tracker.js +94 -19
  101. package/dist/server/services/websocket/plan-execution-handlers.js +33 -5
  102. package/dist/server/services/websocket/plan-handlers.js +10 -5
  103. package/dist/server/services/websocket/plan-helpers.js +23 -0
  104. package/dist/server/services/websocket/plan-issue-handlers.js +161 -4
  105. package/dist/server/services/websocket/plan-sprint-handlers.js +3 -2
  106. package/dist/server/services/websocket/schedule-handlers.js +8 -2
  107. package/dist/server/services/websocket/sdk-agent-host.js +5 -1
  108. package/dist/server/services/websocket/sdk-agent-schedule-host.js +30 -0
  109. package/dist/server/services/websocket/sdk-browser-host.js +11 -4
  110. package/dist/server/services/websocket/sdk-handlers.js +6 -1
  111. package/dist/server/services/websocket/sdk-pm-chain-host.js +20 -0
  112. package/dist/server/services/websocket/sdk-pm-execution-host.js +21 -14
  113. package/dist/server/services/websocket/sdk-pm-schedule-host.js +9 -0
  114. package/dist/server/services/websocket/sdk-terminal-host.js +7 -0
  115. package/dist/server/services/websocket/settings-handlers.js +6 -18
  116. package/dist/server/services/websocket/tab-broadcast.js +5 -1
  117. package/dist/server/services/websocket/terminal-handlers.js +51 -6
  118. package/dist/server/services/websocket/types.js +6 -5
  119. package/dist/server/services/websocket/viewer-interest.js +30 -0
  120. package/package.json +6 -6
@@ -1,5 +1,6 @@
1
1
  // Copyright (c) 2025-present Minionry, Inc.
2
2
  // SPDX-License-Identifier: LicenseRef-Minionry-Software
3
+ import { randomBytes } from 'node:crypto';
3
4
  import { arch, hostname, type } from 'node:os';
4
5
  import { basename } from 'node:path';
5
6
  import { AnalyticsEvents, trackEvent } from './analytics.js';
@@ -75,13 +76,40 @@ const CONNECT_TIMEOUT_MS = 10_000;
75
76
  const HANDSHAKE_ERROR_CLOSE_GRACE_MS = 500;
76
77
  const HEARTBEAT_TIMEOUT_CLOSE_CODE = 4006;
77
78
  const DIAL_TIMEOUT_CLOSE_CODE = 4011;
79
+ const HANDOFF_DIAL_TIMEOUT_MS = 10_000;
80
+ const MIGRATE_OLD_CLOSE_DELAY_MS = 2_000;
81
+ const SOCKET_SCOPED_TYPES = new Set(['paired', 'relayHandoff', 'ping', 'pong']);
82
+ function handoffDeadlineMs(delayMs, handoffBy, now) {
83
+ const budget = delayMs + HANDOFF_DIAL_TIMEOUT_MS;
84
+ const untilHandoffBy = handoffBy - now;
85
+ return untilHandoffBy > delayMs ? Math.min(untilHandoffBy, budget) : budget;
86
+ }
87
+ function retireSocket(ws) {
88
+ try {
89
+ ws.onopen = null;
90
+ ws.onmessage = null;
91
+ ws.onclose = null;
92
+ ws.onerror = null;
93
+ }
94
+ catch { }
95
+ try {
96
+ if (ws.readyState === WebSocketImpl.CONNECTING || ws.readyState === WebSocketImpl.OPEN) {
97
+ ws.close();
98
+ }
99
+ }
100
+ catch { }
101
+ }
78
102
  const CREDENTIALS_POLL_MS = 15_000;
79
103
  const OUTBOUND_QUEUE_LIMIT = 100;
104
+ const USAGE_RETRY_QUEUE_LIMIT = 1_000;
80
105
  const MAX_OUTBOUND_FRAME_BYTES = 16 * 1024 * 1024;
81
106
  const NEVER_QUEUED_TYPES = new Set(['pong', 'terminalOutput', 'sdkGrantState', 'shuttingDown']);
82
107
  export class PlatformConnection {
83
108
  ws = null;
84
109
  socketEpoch = 0;
110
+ epochSeq = 0;
111
+ handoff = null;
112
+ leaving = new Map();
85
113
  reconnectTimeout = null;
86
114
  reconnectPolicy = new ReconnectPolicy();
87
115
  isIntentionallyClosed = false;
@@ -99,9 +127,13 @@ export class PlatformConnection {
99
127
  spaceVisibility;
100
128
  e2ee;
101
129
  startedAt;
130
+ epochNonce;
102
131
  tokens;
103
132
  outboundQueue = [];
104
133
  outboundDropped = 0;
134
+ usageRetryQueue = new Map();
135
+ usageDropped = 0;
136
+ usageKeySeq = 0;
105
137
  browserFrameRefusalWarned = false;
106
138
  browserFrameRefusals = 0;
107
139
  constructor(workingDirectory, callbacks = {}, platformUrl) {
@@ -109,6 +141,7 @@ export class PlatformConnection {
109
141
  this.platformUrl = platformUrl || DEFAULT_PLATFORM_URL;
110
142
  this.callbacks = callbacks;
111
143
  this.startedAt = new Date().toISOString();
144
+ this.epochNonce = randomBytes(16).toString('base64url');
112
145
  this.tokens = new TokenLifecycle({
113
146
  platformUrl: this.platformUrl,
114
147
  send: (msg) => this.send(msg),
@@ -163,8 +196,9 @@ export class PlatformConnection {
163
196
  }
164
197
  this.stopCredentialsWatch();
165
198
  this.presentedToken = authToken;
199
+ this.dropHandoff();
166
200
  this.detachSocket();
167
- const epoch = ++this.socketEpoch;
201
+ const epoch = this.nextLiveEpoch();
168
202
  const wsUrl = this.buildConnectionUrl();
169
203
  let ws;
170
204
  try {
@@ -197,6 +231,7 @@ export class PlatformConnection {
197
231
  ...E2EE_CAPABILITIES,
198
232
  fileTransfer: FILE_TRANSFER_CAPABILITY,
199
233
  maxUploadBytes: getMaxUploadBytes(),
234
+ relayHandoff: true,
200
235
  }),
201
236
  startedAt: this.startedAt,
202
237
  protocolVersion: String(PROTOCOL_VERSION),
@@ -217,18 +252,7 @@ export class PlatformConnection {
217
252
  }, CONNECT_TIMEOUT_MS);
218
253
  }
219
254
  attachSocketHandlers(ws, authToken, connectionTimeout, epoch) {
220
- let closeSettled = false;
221
- const settleClose = (event) => {
222
- if (closeSettled)
223
- return;
224
- closeSettled = true;
225
- clearTimeout(connectionTimeout);
226
- if (epoch !== this.socketEpoch) {
227
- console.log(`[Platform] Ignoring close on a retired socket (code ${event.code})`);
228
- return;
229
- }
230
- this.handleSocketClose(event);
231
- };
255
+ this.bindLiveHandlers(ws, epoch, () => clearTimeout(connectionTimeout));
232
256
  ws.onopen = () => {
233
257
  if (epoch !== this.socketEpoch) {
234
258
  try {
@@ -238,11 +262,36 @@ export class PlatformConnection {
238
262
  return;
239
263
  }
240
264
  clearTimeout(connectionTimeout);
241
- ws.send(JSON.stringify({ type: 'auth', token: authToken }));
265
+ ws.send(JSON.stringify(this.authFrame(authToken)));
242
266
  this.tokens.maybeRefresh();
243
267
  this.tokens.startPeriodicCheck();
244
268
  trackEvent(AnalyticsEvents.PLATFORM_CONNECTED);
245
269
  };
270
+ const wsWithEmitter = ws;
271
+ if (typeof wsWithEmitter.on === 'function') {
272
+ wsWithEmitter.on('unexpected-response', (...args) => {
273
+ if (epoch !== this.socketEpoch)
274
+ return;
275
+ const response = args[1];
276
+ if (response?.statusCode === 426) {
277
+ this.protocolUpgradeRequired = true;
278
+ }
279
+ });
280
+ }
281
+ }
282
+ bindLiveHandlers(ws, epoch, onSettle = () => { }) {
283
+ let closeSettled = false;
284
+ const settleClose = (event) => {
285
+ if (closeSettled)
286
+ return;
287
+ closeSettled = true;
288
+ onSettle();
289
+ if (epoch !== this.socketEpoch) {
290
+ console.log(`[Platform] Ignoring close on a retired socket (code ${event.code})`);
291
+ return;
292
+ }
293
+ this.handleSocketClose(event);
294
+ };
246
295
  ws.onmessage = (event) => {
247
296
  if (epoch !== this.socketEpoch)
248
297
  return;
@@ -255,46 +304,36 @@ export class PlatformConnection {
255
304
  };
256
305
  ws.onclose = (event) => settleClose(event);
257
306
  ws.onerror = () => {
258
- clearTimeout(connectionTimeout);
307
+ onSettle();
259
308
  setTimeout(() => {
260
309
  settleClose({ code: 1012, reason: 'handshake failed' });
261
310
  }, HANDSHAKE_ERROR_CLOSE_GRACE_MS);
262
311
  };
263
- const wsWithEmitter = ws;
264
- if (typeof wsWithEmitter.on === 'function') {
265
- wsWithEmitter.on('unexpected-response', (...args) => {
266
- if (epoch !== this.socketEpoch)
267
- return;
268
- const response = args[1];
269
- if (response?.statusCode === 426) {
270
- this.protocolUpgradeRequired = true;
271
- }
272
- });
273
- }
312
+ }
313
+ authFrame(token, migrateFrom) {
314
+ return {
315
+ type: 'auth',
316
+ token,
317
+ startedAt: this.startedAt,
318
+ epochNonce: this.epochNonce,
319
+ ...(migrateFrom ? { migrateFrom } : {}),
320
+ };
321
+ }
322
+ nextLiveEpoch() {
323
+ this.socketEpoch = ++this.epochSeq;
324
+ return this.socketEpoch;
274
325
  }
275
326
  detachSocket() {
276
327
  const ws = this.ws;
277
328
  if (!ws)
278
329
  return;
279
330
  this.ws = null;
280
- try {
281
- ws.onopen = null;
282
- ws.onmessage = null;
283
- ws.onclose = null;
284
- ws.onerror = null;
285
- }
286
- catch { }
287
- try {
288
- if (ws.readyState === WebSocketImpl.CONNECTING || ws.readyState === WebSocketImpl.OPEN) {
289
- ws.close();
290
- }
291
- }
292
- catch { }
331
+ retireSocket(ws);
293
332
  }
294
333
  forceClose(ws, epoch, code, reason) {
295
334
  if (epoch !== this.socketEpoch)
296
335
  return;
297
- this.socketEpoch++;
336
+ this.nextLiveEpoch();
298
337
  this.ws = null;
299
338
  try {
300
339
  ws.onopen = null;
@@ -320,9 +359,24 @@ export class PlatformConnection {
320
359
  this.reconnectPolicy.noteDisconnected();
321
360
  if (this.isIntentionallyClosed)
322
361
  return;
323
- const disposition = classifyClose(event.code, event.reason, {
362
+ const handoff = this.handoff;
363
+ if (handoff?.ws) {
364
+ handoff.fromClosed = event;
365
+ console.log(`[Platform] Socket closed mid-handoff (code ${event.code}) — waiting on the new one`);
366
+ return;
367
+ }
368
+ if (handoff)
369
+ this.clearHandoff(handoff);
370
+ this.recoverFromClose(event, handoff !== null);
371
+ }
372
+ recoverFromClose(event, migratingAway) {
373
+ let disposition = classifyClose(event.code, event.reason, {
324
374
  protocolUpgradeRequired: this.protocolUpgradeRequired,
325
375
  });
376
+ if (disposition === 'superseded' && migratingAway) {
377
+ console.log('[Platform] Ignoring 4015 on the socket this process is migrating away from — reconnecting');
378
+ disposition = 'reconnect';
379
+ }
326
380
  switch (disposition) {
327
381
  case 'protocol_too_old':
328
382
  this.handleProtocolUpgradeRequired();
@@ -330,11 +384,18 @@ export class PlatformConnection {
330
384
  case 'superseded':
331
385
  this.handleSuperseded(event);
332
386
  return;
387
+ case 'handoff':
388
+ console.log(`Relay handed this socket off (code ${event.code}${event.reason ? `: ${event.reason}` : ''}), reconnecting...`);
389
+ this.callbacks.onDisconnected?.();
390
+ trackEvent(AnalyticsEvents.PLATFORM_DISCONNECTED);
391
+ this.scheduleReconnect(this.reconnectPolicy.handoffDelayMs());
392
+ return;
333
393
  case 'auth_failed':
334
394
  console.error('\n❌ Authentication failed. Your device token may be invalid or expired.');
335
395
  console.error(' Run `minion login --force` to re-authenticate — this server reconnects as soon as you do.\n');
336
396
  this.tokens.notifyAuthExpired();
337
397
  this.callbacks.onError?.('Authentication failed - run `minion login --force`');
398
+ this.callbacks.onDisconnected?.();
338
399
  this.awaitCredentials(this.presentedToken);
339
400
  return;
340
401
  default:
@@ -367,8 +428,151 @@ export class PlatformConnection {
367
428
  this.connect();
368
429
  }, PROTOCOL_UPGRADE_RECONNECT_DELAY_MS);
369
430
  }
370
- handleMessage(message) {
431
+ beginHandoff(message) {
432
+ const { v, delayMs, handoffBy } = message;
433
+ if (v !== 1 || typeof delayMs !== 'number' || typeof handoffBy !== 'number')
434
+ return;
435
+ if (!Number.isFinite(delayMs) || !Number.isFinite(handoffBy))
436
+ return;
437
+ const from = this.ws;
438
+ if (this.handoff || !from || !this.isConnected || !this.connectionId)
439
+ return;
440
+ const delay = Math.max(0, delayMs);
441
+ const handoff = { from, migrateFrom: this.connectionId, ws: null, fromClosed: null, timers: [] };
442
+ this.handoff = handoff;
443
+ const reason = typeof message.reason === 'string' ? message.reason : 'unspecified';
444
+ console.log(`[Platform] Relay handoff (${reason}) — moving to a new socket in ${delay}ms`);
445
+ handoff.timers.push(setTimeout(() => this.dialHandoff(handoff), delay), setTimeout(() => this.abandonHandoff(handoff, 'handoffBy passed'), handoffDeadlineMs(delay, handoffBy, Date.now())));
446
+ }
447
+ dialHandoff(handoff) {
448
+ if (this.handoff !== handoff)
449
+ return;
450
+ const token = getCredentials()?.token;
451
+ if (!token) {
452
+ this.abandonHandoff(handoff, 'not logged in');
453
+ return;
454
+ }
455
+ let ws;
456
+ try {
457
+ ws = new WebSocketImpl(this.buildConnectionUrl());
458
+ }
459
+ catch (err) {
460
+ this.abandonHandoff(handoff, err instanceof Error ? err.message : String(err));
461
+ return;
462
+ }
463
+ handoff.ws = ws;
464
+ ws.onopen = () => {
465
+ if (this.handoff !== handoff)
466
+ return;
467
+ ws.send(JSON.stringify(this.authFrame(token, handoff.migrateFrom)));
468
+ };
469
+ ws.onmessage = (event) => {
470
+ if (this.handoff !== handoff)
471
+ return;
472
+ try {
473
+ const message = JSON.parse(event.data.toString());
474
+ if (message.type === 'paired')
475
+ this.completeHandoff(handoff, ws, message);
476
+ else
477
+ this.handleMessage(message, false);
478
+ }
479
+ catch (err) {
480
+ console.error('Failed to parse platform message:', err);
481
+ }
482
+ };
483
+ ws.onclose = (event) => this.abandonHandoff(handoff, `new socket closed with code ${event.code}`);
484
+ ws.onerror = () => this.abandonHandoff(handoff, 'new socket failed');
485
+ }
486
+ completeHandoff(handoff, ws, paired) {
487
+ this.clearHandoff(handoff);
488
+ const old = handoff.from;
489
+ if (old.readyState === WebSocketImpl.OPEN) {
490
+ try {
491
+ old.send(JSON.stringify({ type: 'handoffMark' }));
492
+ }
493
+ catch { }
494
+ }
495
+ this.ws = ws;
496
+ ws.onopen = null;
497
+ this.bindLiveHandlers(ws, this.nextLiveEpoch());
498
+ this.retireLeaving(old);
499
+ if (handoff.fromClosed) {
500
+ this.tokens.maybeRefresh();
501
+ this.tokens.startPeriodicCheck();
502
+ }
503
+ console.log('[Platform] Relay handoff complete — moved to the new socket');
504
+ this.handleMessage(paired);
505
+ }
506
+ retireLeaving(old) {
507
+ if (old.readyState !== WebSocketImpl.OPEN && old.readyState !== WebSocketImpl.CONNECTING) {
508
+ retireSocket(old);
509
+ return;
510
+ }
511
+ old.onopen = null;
512
+ old.onerror = null;
513
+ old.onmessage = (event) => {
514
+ try {
515
+ this.handleMessage(JSON.parse(event.data.toString()), false);
516
+ }
517
+ catch (err) {
518
+ console.error('Failed to parse platform message:', err);
519
+ }
520
+ };
521
+ old.onclose = (event) => {
522
+ console.log(`[Platform] Previous socket closed after the handoff (code ${event.code})`);
523
+ this.dropLeaving(old);
524
+ };
525
+ this.leaving.set(old, setTimeout(() => {
526
+ this.dropLeaving(old);
527
+ retireSocket(old);
528
+ }, MIGRATE_OLD_CLOSE_DELAY_MS));
529
+ }
530
+ dropLeaving(old) {
531
+ const timer = this.leaving.get(old);
532
+ if (timer === undefined)
533
+ return;
534
+ clearTimeout(timer);
535
+ this.leaving.delete(old);
536
+ old.onmessage = null;
537
+ old.onclose = null;
538
+ }
539
+ abandonHandoff(handoff, why) {
540
+ if (this.handoff !== handoff)
541
+ return;
542
+ this.clearHandoff(handoff);
543
+ if (handoff.ws)
544
+ retireSocket(handoff.ws);
545
+ const closed = handoff.fromClosed;
546
+ console.warn(`[Platform] Relay handoff abandoned (${why}) — ${closed ? 'reconnecting' : 'staying on the current socket'}`);
547
+ if (closed && !this.isIntentionallyClosed)
548
+ this.recoverFromClose(closed, true);
549
+ }
550
+ clearHandoff(handoff) {
551
+ for (const timer of handoff.timers)
552
+ clearTimeout(timer);
553
+ if (this.handoff === handoff)
554
+ this.handoff = null;
555
+ }
556
+ dropHandoff() {
557
+ const handoff = this.handoff;
558
+ if (!handoff)
559
+ return;
560
+ this.clearHandoff(handoff);
561
+ if (handoff.ws)
562
+ retireSocket(handoff.ws);
563
+ }
564
+ settleBeforeEnvelope(message, fromLive) {
565
+ if (!fromLive)
566
+ return SOCKET_SCOPED_TYPES.has(message.type);
371
567
  this.heartbeat.noteInbound();
568
+ if (message.type !== 'relayHandoff')
569
+ return false;
570
+ this.beginHandoff(message);
571
+ return true;
572
+ }
573
+ handleMessage(message, fromLive = true) {
574
+ if (this.settleBeforeEnvelope(message, fromLive))
575
+ return;
372
576
  if (isV1Envelope(message)) {
373
577
  if (message.enc !== false) {
374
578
  this.e2ee.handleSealedInbound(message, (legacy) => {
@@ -390,6 +594,7 @@ export class PlatformConnection {
390
594
  replaceFileShareRegistry(message.fileShares, this.workingDirectory);
391
595
  this.heartbeat.start();
392
596
  this.callbacks.onConnected?.(message.connectionId);
597
+ this.flushOutboundQueue();
393
598
  break;
394
599
  }
395
600
  case 'web_connected':
@@ -488,12 +693,12 @@ export class PlatformConnection {
488
693
  this.credentialsWatch = null;
489
694
  }
490
695
  }
491
- scheduleReconnect() {
696
+ scheduleReconnect(delayMs) {
492
697
  if (this.reconnectTimeout)
493
698
  return;
494
699
  if (this.superseded)
495
700
  return;
496
- const delay = this.reconnectPolicy.nextDelayMs();
701
+ const delay = delayMs ?? this.reconnectPolicy.nextDelayMs();
497
702
  this.reconnectTimeout = setTimeout(() => {
498
703
  this.reconnectTimeout = null;
499
704
  this.connect();
@@ -502,6 +707,10 @@ export class PlatformConnection {
502
707
  send(message) {
503
708
  if (this.refuseCleartextBrowserFrame(message))
504
709
  return;
710
+ if (message?.type === 'reportUsage' && !this.isConnectedToPlatform()) {
711
+ this.holdUsageReport(message);
712
+ return;
713
+ }
505
714
  if (!this.ws || this.ws.readyState !== WebSocketImpl.OPEN) {
506
715
  this.queueWhileDisconnected(message);
507
716
  return;
@@ -601,17 +810,44 @@ export class PlatformConnection {
601
810
  }
602
811
  this.outboundQueue.push(message);
603
812
  }
813
+ holdUsageReport(message) {
814
+ const reportId = message.data?.reportId;
815
+ const key = typeof reportId === 'string' && reportId !== '' ? reportId : `unkeyed-${++this.usageKeySeq}`;
816
+ if (this.usageRetryQueue.has(key))
817
+ return;
818
+ if (this.usageRetryQueue.size >= USAGE_RETRY_QUEUE_LIMIT) {
819
+ const oldest = this.usageRetryQueue.keys().next().value;
820
+ this.usageRetryQueue.delete(oldest);
821
+ this.usageDropped++;
822
+ }
823
+ this.usageRetryQueue.set(key, message);
824
+ }
604
825
  flushOutboundQueue() {
605
826
  if (this.outboundDropped > 0) {
606
827
  console.log(`[Platform] ${this.outboundDropped} queued outbound frame${this.outboundDropped === 1 ? '' : 's'} dropped over the ${OUTBOUND_QUEUE_LIMIT}-frame cap`);
607
828
  this.outboundDropped = 0;
608
829
  }
609
- if (this.outboundQueue.length === 0)
830
+ if (this.outboundQueue.length > 0) {
831
+ const queued = this.outboundQueue;
832
+ this.outboundQueue = [];
833
+ console.log(`[Platform] Flushing ${queued.length} outbound message${queued.length === 1 ? '' : 's'} queued during the outage`);
834
+ for (const message of queued) {
835
+ this.send(message);
836
+ }
837
+ }
838
+ this.flushUsageRetryQueue();
839
+ }
840
+ flushUsageRetryQueue() {
841
+ if (this.usageDropped > 0) {
842
+ console.log(`[Platform] ${this.usageDropped} held usage report${this.usageDropped === 1 ? '' : 's'} dropped over the ${USAGE_RETRY_QUEUE_LIMIT}-report cap`);
843
+ this.usageDropped = 0;
844
+ }
845
+ if (this.usageRetryQueue.size === 0 || !this.isConnectedToPlatform())
610
846
  return;
611
- const queued = this.outboundQueue;
612
- this.outboundQueue = [];
613
- console.log(`[Platform] Flushing ${queued.length} outbound message${queued.length === 1 ? '' : 's'} queued during the outage`);
614
- for (const message of queued) {
847
+ const held = [...this.usageRetryQueue.values()];
848
+ this.usageRetryQueue.clear();
849
+ console.log(`[Platform] Sending ${held.length} usage report${held.length === 1 ? '' : 's'} held while unpaired`);
850
+ for (const message of held) {
615
851
  this.send(message);
616
852
  }
617
853
  }
@@ -643,12 +879,19 @@ export class PlatformConnection {
643
879
  this.stopCredentialsWatch();
644
880
  this.outboundQueue = [];
645
881
  this.outboundDropped = 0;
882
+ this.usageRetryQueue.clear();
883
+ this.usageDropped = 0;
646
884
  if (this.reconnectTimeout) {
647
885
  clearTimeout(this.reconnectTimeout);
648
886
  this.reconnectTimeout = null;
649
887
  }
650
- this.socketEpoch++;
888
+ this.nextLiveEpoch();
889
+ this.dropHandoff();
651
890
  this.detachSocket();
891
+ for (const old of [...this.leaving.keys()]) {
892
+ this.dropLeaving(old);
893
+ retireSocket(old);
894
+ }
652
895
  this.isConnected = false;
653
896
  this.connectionId = null;
654
897
  this.envelopeNegotiated = false;
@@ -0,0 +1,107 @@
1
+ // Copyright (c) 2025-present Minionry, Inc.
2
+ // SPDX-License-Identifier: LicenseRef-Minionry-Software
3
+ import { ReconnectPolicy } from './platform-reconnect.js';
4
+ export const RELAY_HTTP_RETRY_BUDGET_MS = 60_000;
5
+ const RETRYABLE_STATUSES = new Set([502, 503, 504]);
6
+ const RETRYABLE_ERROR_CODES = new Set(['ECONNRESET', 'ECONNREFUSED', 'UND_ERR_SOCKET']);
7
+ export class RelayHttpClient {
8
+ options;
9
+ constructor(options = {}) {
10
+ this.options = options;
11
+ }
12
+ async fetch(input, init, request = {}) {
13
+ const now = this.options.now ?? Date.now;
14
+ const startedAt = now();
15
+ const budgetMs = request.budgetMs ?? this.options.budgetMs ?? RELAY_HTTP_RETRY_BUDGET_MS;
16
+ const backoff = new ReconnectPolicy({
17
+ initialMs: this.options.initialDelayMs ?? 500,
18
+ maxMs: this.options.maxDelayMs ?? 8_000,
19
+ random: this.options.random,
20
+ });
21
+ const replayable = isReplayableBody(init?.body);
22
+ for (;;) {
23
+ const attempt = await this.attempt(input, init);
24
+ const waitMs = replayable ? retryWaitMs(attempt, backoff, now()) : null;
25
+ if (waitMs === null || now() - startedAt + waitMs > budgetMs)
26
+ return settle(attempt);
27
+ if (attempt.ok)
28
+ await discardBody(attempt.response);
29
+ await sleep(waitMs, init?.signal ?? undefined);
30
+ }
31
+ }
32
+ async attempt(input, init) {
33
+ const fetchImpl = this.options.fetchImpl ?? globalThis.fetch;
34
+ try {
35
+ return { ok: true, response: await fetchImpl(input, init) };
36
+ }
37
+ catch (error) {
38
+ return { ok: false, error };
39
+ }
40
+ }
41
+ }
42
+ function retryWaitMs(attempt, backoff, nowMs) {
43
+ if (!attempt.ok)
44
+ return isRetryableNetworkError(attempt.error) ? backoff.nextDelayMs() : null;
45
+ if (!RETRYABLE_STATUSES.has(attempt.response.status))
46
+ return null;
47
+ return Math.max(retryAfterMs(attempt.response, nowMs) ?? 0, backoff.nextDelayMs());
48
+ }
49
+ function settle(attempt) {
50
+ if (attempt.ok)
51
+ return attempt.response;
52
+ throw attempt.error;
53
+ }
54
+ export const relayHttp = new RelayHttpClient();
55
+ function retryAfterMs(response, nowMs) {
56
+ const raw = response.headers?.get?.('retry-after')?.trim();
57
+ if (!raw)
58
+ return null;
59
+ if (/^\d+$/.test(raw))
60
+ return Number(raw) * 1_000;
61
+ const at = Date.parse(raw);
62
+ return Number.isNaN(at) ? null : Math.max(0, at - nowMs);
63
+ }
64
+ function isRetryableNetworkError(err, depth = 0) {
65
+ if (depth > 4 || typeof err !== 'object' || err === null)
66
+ return false;
67
+ const { code, cause, errors } = err;
68
+ if (typeof code === 'string' && RETRYABLE_ERROR_CODES.has(code))
69
+ return true;
70
+ if (Array.isArray(errors) && errors.some((inner) => isRetryableNetworkError(inner, depth + 1)))
71
+ return true;
72
+ return isRetryableNetworkError(cause, depth + 1);
73
+ }
74
+ function isReplayableBody(body) {
75
+ return (body === undefined ||
76
+ body === null ||
77
+ typeof body === 'string' ||
78
+ body instanceof ArrayBuffer ||
79
+ ArrayBuffer.isView(body) ||
80
+ body instanceof URLSearchParams ||
81
+ body instanceof Blob ||
82
+ body instanceof FormData);
83
+ }
84
+ async function discardBody(response) {
85
+ try {
86
+ await response.body?.cancel();
87
+ }
88
+ catch {
89
+ }
90
+ }
91
+ function sleep(ms, signal) {
92
+ return new Promise((resolve, reject) => {
93
+ if (signal?.aborted) {
94
+ reject(signal.reason);
95
+ return;
96
+ }
97
+ const onAbort = () => {
98
+ clearTimeout(timer);
99
+ reject(signal?.reason);
100
+ };
101
+ const timer = setTimeout(() => {
102
+ signal?.removeEventListener('abort', onAbort);
103
+ resolve();
104
+ }, ms);
105
+ signal?.addEventListener('abort', onAbort, { once: true });
106
+ });
107
+ }