@reefclaw/connect 0.1.25 → 0.1.27

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.
@@ -1,7 +1,10 @@
1
1
  import { type ReconnectConfig as BaseReconnectConfig } from '../utils/reconnect.js';
2
2
  import type { GatewayConfig } from './gateway-config.js';
3
3
  interface ReconnectConfig extends BaseReconnectConfig {
4
- maxAttempts: number;
4
+ /** Fast-backoff attempts before falling back to the slow retry loop */
5
+ maxFastAttempts: number;
6
+ /** Slow retry interval once the fast attempts are exhausted (ms) */
7
+ slowRetryMs: number;
5
8
  }
6
9
  /** hello-ok response payload from gateway */
7
10
  export interface HelloOkPayload {
@@ -56,6 +59,9 @@ export declare class GatewayWsClient {
56
59
  private ws;
57
60
  private state;
58
61
  private attempt;
62
+ /** True once the fast burst is spent and we are in the slow retry loop
63
+ * (kept so the "switching to slow retry" warning logs once, not per attempt). */
64
+ private slowRetryActive;
59
65
  private reconnectTimer;
60
66
  private staleWarnTimer;
61
67
  private staleReconnectTimer;
@@ -30,11 +30,23 @@ const MIN_PROTOCOL_VERSION = 3;
30
30
  const MAX_PROTOCOL_VERSION = 4;
31
31
  const CLIENT_ID = 'openclaw-tui';
32
32
  const CLIENT_VERSION = '0.1.0';
33
+ // Mirrors the relay connector's proven shape (see connector.ts DEFAULT_RECONNECT):
34
+ // a bounded fast-backoff burst, then a slow retry that NEVER gives up.
35
+ //
36
+ // This client used to have `maxAttempts: 20` and go terminal ('failed') when it
37
+ // ran out — roughly 9 minutes of budget (2+4+8+16s, then 16 x 30s). On
38
+ // 2026-07-31 an OpenClaw upgrade left the gateway crash-looping for 56 minutes;
39
+ // the bridge burned its 20 attempts in the first 9, gave up permanently, and
40
+ // stayed dark even AFTER the gateway recovered. The dashboard — the operator's
41
+ // window and one of the emergency-control paths — required a manual bridge
42
+ // restart to come back. Any gateway downtime over ~9 min (upgrade, reboot,
43
+ // crash-loop) reproduced it, on every deployment.
33
44
  const DEFAULT_RECONNECT = {
34
45
  baseDelayMs: 2_000,
35
46
  maxDelayMs: 30_000,
36
- maxAttempts: 20,
47
+ maxFastAttempts: 20,
37
48
  jitterFactor: 0.3,
49
+ slowRetryMs: 60_000,
38
50
  };
39
51
  // ---- Fatal close codes ----
40
52
  /** Auth errors — do not reconnect */
@@ -45,6 +57,9 @@ export class GatewayWsClient {
45
57
  ws = null;
46
58
  state = 'disconnected';
47
59
  attempt = 0;
60
+ /** True once the fast burst is spent and we are in the slow retry loop
61
+ * (kept so the "switching to slow retry" warning logs once, not per attempt). */
62
+ slowRetryActive = false;
48
63
  reconnectTimer = null;
49
64
  staleWarnTimer = null;
50
65
  staleReconnectTimer = null;
@@ -351,6 +366,7 @@ export class GatewayWsClient {
351
366
  logger.info(TAG, `Connected to gateway (protocol=${payload.protocol})`);
352
367
  this.lastHelloOk = payload;
353
368
  this.attempt = 0; // Reset reconnect counter
369
+ this.slowRetryActive = false; // Back on the fast budget for the next drop
354
370
  // Parse policy
355
371
  if (payload.policy?.tickIntervalMs) {
356
372
  this.tickIntervalMs = payload.policy.tickIntervalMs;
@@ -403,14 +419,21 @@ export class GatewayWsClient {
403
419
  // ---- Reconnection ----
404
420
  scheduleReconnect() {
405
421
  this.attempt++;
406
- if (this.attempt > this.reconnectConfig.maxAttempts) {
407
- logger.error(TAG, `Max reconnection attempts (${this.reconnectConfig.maxAttempts}) exceeded`);
408
- this.setState('failed');
409
- this.emit('failed', { reason: 'Max reconnection attempts exceeded' });
410
- return;
422
+ const { maxFastAttempts, slowRetryMs } = this.reconnectConfig;
423
+ // Exhausting the fast burst is NOT terminal — a gateway that is down for an
424
+ // hour (upgrade, reboot, crash-loop) must still be picked up when it
425
+ // returns. Only a fatal close code (4001 auth) ends the client, because a
426
+ // rejected token will never start working and must not be retried forever.
427
+ const exhausted = this.attempt > maxFastAttempts;
428
+ if (exhausted && !this.slowRetryActive) {
429
+ this.slowRetryActive = true;
430
+ logger.warn(TAG, `Fast reconnect budget (${maxFastAttempts} attempts) exhausted — switching to slow retry every ` +
431
+ `${Math.round(slowRetryMs / 1000)}s. Still trying; will reconnect on its own when the gateway returns.`);
411
432
  }
412
- const delay = computeDelay(this.attempt - 1, this.reconnectConfig);
413
- logger.info(TAG, `Reconnecting in ${Math.round(delay)}ms (attempt ${this.attempt}/${this.reconnectConfig.maxAttempts})`);
433
+ const delay = exhausted ? slowRetryMs : computeDelay(this.attempt - 1, this.reconnectConfig);
434
+ logger.info(TAG, exhausted
435
+ ? `Reconnecting in ${Math.round(delay)}ms (slow retry, attempt ${this.attempt})`
436
+ : `Reconnecting in ${Math.round(delay)}ms (attempt ${this.attempt}/${maxFastAttempts})`);
414
437
  this.setState('reconnecting');
415
438
  this.emit('reconnecting', { attempt: this.attempt, delayMs: Math.round(delay) });
416
439
  this.reconnectTimer = setTimeout(() => {
@@ -124,6 +124,25 @@ export async function onCreateOrderFilled(ctx, inputs, order) {
124
124
  // scale-ins with WS-exact data; capturing here would double-post).
125
125
  const existing = ctx.stateStore.get(inputs.symbol);
126
126
  if (existing) {
127
+ // ★ SAME-ORDER GUARD — the twin of the dedup in onWsFillObserved (which
128
+ // returns when `stateEntry.openedFromExchangeTradeId === fill.exchangeOrderId`).
129
+ // That one covers create_order-then-WS; this covers WS-then-create_order,
130
+ // which is the ORDINARY ordering for a market order: the WS fill routinely
131
+ // beats the REST response (measured ~270-415ms on prod).
132
+ //
133
+ // Without it the stale probe below decides this case, and it CANNOT: its
134
+ // test is "does the exchange position equal this fill?", which is equally
135
+ // true of a genuinely stale mapping AND of the mapping the WS path just
136
+ // wrote for THIS VERY FILL. It chose 'stale', dropped the fresh mapping and
137
+ // journaled a second position — 38 duplicate pairs in 9 days (~11/day), each
138
+ // orphaning the WS row at 0 reviews while the exchange held ONE position.
139
+ // The twins were undedupable downstream because they key on different ids
140
+ // (WS = trade id, here = order id).
141
+ const orderId = typeof order.id === 'string' ? order.id : undefined;
142
+ if (orderId && existing.openedFromExchangeTradeId === orderId) {
143
+ logger.info(TAG, `onCreateOrderFilled ${inputs.symbol}: WS fill path already captured this order (${orderId}) — skipping duplicate capture`);
144
+ return;
145
+ }
127
146
  const probe = await probeStaleStateEntry(ctx, inputs.symbol, filledQty);
128
147
  if (probe === 'stale') {
129
148
  logger.warn(TAG, `onCreateOrderFilled ${inputs.symbol}: state-store entry (openedAt=${existing.openedAt}) is STALE — ` +
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "id": "reefclaw-paper-trading",
3
3
  "name": "ReefClaw Trading",
4
- "version": "0.1.18",
5
- "description": "Supervised trading plugin for the ReefClaw dashboard: paper trading with real market data (no API keys required), and optional live trading on Binance or Hyperliquid behind explicit operator opt-in, exchange API credentials, and always-on protective stop brackets. Includes the dashboard connector bridge, heartbeat automation, and remote SKILL.md instruction updates from the ReefClaw webapp.",
4
+ "version": "0.1.20",
5
+ "description": "Supervised trading plugin for the ReefClaw dashboard. It runs on YOUR machine and starts in PAPER mode with no API keys. It cannot trade real funds until you supply exchange credentials and step PAPER→MICRO_LIVE→LIVE yourself from the dashboard the agent cannot make that change (the tool is refused without operator provenance). Exchange keys stay local, are used only to sign requests to the exchange, and are never transmitted to ReefClaw (asserted by a test in this package). Trading telemetry — positions, fills, decision journal — is sent to ReefClaw to render the dashboard. Every live position carries exchange-native protective stops. Remote updates to the agent's trading instructions are applied only after an Ed25519 signature is verified against a public key pinned in this build.",
6
6
  "author": "ReefClaw",
7
7
  "activation": {
8
8
  "onStartup": true
@@ -1,10 +1,9 @@
1
1
  ---
2
2
  name: reefclaw
3
- version: 0.0.7
4
- description: ReefClaw trading control room — bootstrap (connects your agent; full trading instructions arrive automatically after first connect)
3
+ version: 0.0.9
4
+ description: ReefClaw trading control room — bootstrap (connects your agent; the full trading instructions arrive after first connect as a signed, signature-verified update)
5
5
  author: ReefClaw
6
6
  homepage: https://reefclaw.com
7
- repository: https://github.com/reefclaw/reefclaw
8
7
  license: MIT
9
8
  runtime: node
10
9
  entry: dist/index.js
@@ -28,7 +27,7 @@ config:
28
27
 
29
28
  ## About this file
30
29
 
31
- This is the thin BOOTSTRAP copy shipped in the `@reefclaw/connect` npm package. It covers **installing the ReefClaw plugin and connecting to the user's ReefClaw account** — nothing else. The FULL trading instructions (versioned 2.x.y) are delivered automatically by the ReefClaw connector minutes after the first successful connect, over the authenticated update channel — they replace this file in place (a 0.0.x version always loses the semver comparison). Maintainers: the "Connecting (first run)" section below MUST stay in sync with the same section in `skill/SKILL.md`.
30
+ This is the thin BOOTSTRAP copy shipped in the `@reefclaw/connect` npm package. It covers **installing the ReefClaw plugin and connecting to the user's ReefClaw account** — nothing else. The FULL trading instructions (versioned 2.x.y) are delivered by the ReefClaw connector minutes after the first successful connect and replace this file in place (a 0.0.x version always loses the semver comparison). That replacement is **cryptographically verified before it is applied** — see "How instruction updates are verified" below. Maintainers: the "Connecting (first run)" section below MUST stay in sync with the same section in `skill/SKILL.md`.
32
31
 
33
32
  You are being connected to **ReefClaw**, a supervised trading control room. Your operator watches everything you do on the ReefClaw dashboard.
34
33
 
@@ -36,11 +35,39 @@ You are being connected to **ReefClaw**, a supervised trading control room. Your
36
35
 
37
36
  **Do not attempt to trade before the full instructions arrive.** If trading tools are visible but this file is still a bootstrap version (0.0.x), the connection has not completed — finish connecting first.
38
37
 
38
+ ## What this can and cannot do
39
+
40
+ Worth stating exactly, because "a trading plugin" sounds like more authority than this actually has. Every line below is checkable in the installed package.
41
+
42
+ | | |
43
+ |---|---|
44
+ | **Where it runs** | Entirely on the user's own machine, inside their OpenClaw. ReefClaw's servers host a dashboard, a relay and a market-data API — they do not execute trades. |
45
+ | **Out of the box** | **PAPER mode**: real market data, simulated fills, no exchange account, no API keys. That is the default and it needs no credentials at all. |
46
+ | **To trade real money** | The **user** adds exchange API credentials and then moves PAPER → MICRO_LIVE → LIVE one rung at a time from the ReefClaw dashboard. **The agent cannot do this**: the mode and credential tools are refused unless the call carries operator provenance from the dashboard, which an agent cannot mint conversationally. |
47
+ | **Exchange API keys** | Kept **only on the user's machine** (`~/.reefclaw/plugin-config.json`, owner-only permissions) and used **only** to sign requests to the exchange. **They are never sent to ReefClaw** — a test in this package drives the real outbound clients and asserts no credential reaches the wire. On Hyperliquid the key is an *agent* key, which by design cannot withdraw or transfer funds. |
48
+ | **What IS sent to ReefClaw** | Trading telemetry, so the dashboard can display it: positions, fills, balances and the decision journal. That is the product's purpose, and it is not optional while connected. |
49
+ | **Protective stops** | Every live position carries exchange-native stop orders — enforced by the exchange itself, not by this software staying alive. |
50
+ | **Kill switch** | The operator can pause, flatten or kill from the dashboard at any time. Those controls are never gated behind subscription state. |
51
+ | **Instruction updates** | Signature-verified before they are applied — see the next section. |
52
+
53
+ ## How instruction updates are verified
54
+
55
+ This file is replaced, in place, by the full trading instructions once you connect — so it is fair to ask what stops someone else from putting instructions on your machine. The answer is that **an update is applied only if it carries a valid Ed25519 signature made with ReefClaw's private signing key**, which exists only offline on the maintainer's machine — never on a server, in CI, or on the relay.
56
+
57
+ Concretely, before any update is written:
58
+
59
+ - The content is hashed and the signature is checked against a **public key pinned inside the installed build**. No signature, a signature that does not verify, content that does not match its hash, or a version that disagrees with the signed version ⇒ **the update is refused and the current instructions stay in place**.
60
+ - A replay guard requires each accepted update to be strictly newer than the last one applied, so an old signed copy cannot be pushed back onto you.
61
+ - The servers in the path are **carriers, not signers**: neither the ReefClaw webapp nor the relay holds the signing key, so compromising either is not enough to change what your agent is told to do.
62
+ - Enforcement is **on by default**. The `SKILL_OTA_REQUIRE_SIGNATURE=off` escape hatch exists for local debugging only; it lets an *unsigned* update through and never accepts a *forged* one — an update whose signature fails is refused either way. Leave it unset.
63
+
64
+ If an update is ever refused, that is the system working: the connector logs the reason and keeps the instructions you already have.
65
+
39
66
  ## Host requirements (check before connecting)
40
67
 
41
- ReefClaw trades on Binance USD-M Futures, so **the machine running this OpenClaw must be able to reach Binance** (`fapi.binance.com`). Binance geo-restricts many locations: cheap US cloud regions and some other jurisdictions return **HTTP 451** ("restricted location"), and the agent then cannot fetch market data or trade. This applies even in **paper mode**, which uses live Binance prices.
68
+ ReefClaw trades **Binance USD-M Futures or Hyperliquid perpetuals**, so the machine running this OpenClaw must be able to reach the venue you use. On Binance (`fapi.binance.com`) this matters more than people expect: Binance geo-restricts many locations cheap US cloud regions and some other jurisdictions return **HTTP 451** ("restricted location") and the agent then cannot fetch market data or trade. This applies even in **paper mode**, which uses live prices.
42
69
 
43
- If your market-data calls fail with `451`, this host is geo-blocked — move the agent to a Binance-permitted region (most EU and several Asia VPS regions work). This is a hosting requirement; ReefClaw cannot configure around it.
70
+ If your market-data calls fail with `451`, this host is geo-blocked — move the agent to a Binance-permitted region (most EU and several Asia VPS regions work), or use Hyperliquid, which does not geo-restrict this way. This is a hosting requirement; ReefClaw cannot configure around it.
44
71
 
45
72
  ## Connecting (first run) — saving the connect message
46
73
 
@@ -88,5 +115,5 @@ Whatever the install path, **save the connection settings anyway** — the conne
88
115
  ## After connecting
89
116
 
90
117
  - The ReefClaw dashboard flips to **Connected** on its own — no manual verification step.
91
- - Your full trading instructions (SKILL.md version 2.x) are delivered automatically over the authenticated connection and replace this file. You will be notified in chat when that happens.
118
+ - Your full trading instructions (SKILL.md version 2.x) arrive over the authenticated connection and replace this file — **after** their signature is verified against the pinned key (see "How instruction updates are verified"). You will be notified in chat when that happens.
92
119
  - If more than ~10 minutes pass after a successful connect and this file is still a 0.0.x bootstrap version, tell the user to check the dashboard connection status.
package/package.json CHANGED
@@ -1,32 +1,32 @@
1
- {
2
- "name": "@reefclaw/connect",
3
- "version": "0.1.25",
4
- "description": "One-command installer that connects your OpenClaw agent to ReefClaw (paper trading, no exchange keys).",
5
- "type": "module",
6
- "bin": {
7
- "reefclaw-connect": "dist/cli.js"
8
- },
9
- "files": [
10
- "dist",
11
- "assets"
12
- ],
13
- "engines": {
14
- "node": ">=20"
15
- },
16
- "scripts": {
17
- "bundle-assets": "node scripts/bundle-assets.mjs",
18
- "build": "tsc && node scripts/bundle-assets.mjs",
19
- "test": "vitest",
20
- "test:run": "vitest run"
21
- },
22
- "dependencies": {
23
- "json5": "2.2.3"
24
- },
25
- "devDependencies": {
26
- "@types/node": "^20",
27
- "typescript": "^5",
28
- "vitest": "^4.0.18"
29
- },
30
- "license": "MIT",
31
- "homepage": "https://reefclaw.com"
32
- }
1
+ {
2
+ "name": "@reefclaw/connect",
3
+ "version": "0.1.27",
4
+ "description": "One-command installer that connects your OpenClaw agent to ReefClaw (paper trading, no exchange keys).",
5
+ "type": "module",
6
+ "bin": {
7
+ "reefclaw-connect": "dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "assets"
12
+ ],
13
+ "engines": {
14
+ "node": ">=20"
15
+ },
16
+ "scripts": {
17
+ "bundle-assets": "node scripts/bundle-assets.mjs",
18
+ "build": "tsc && node scripts/bundle-assets.mjs",
19
+ "test": "vitest",
20
+ "test:run": "vitest run"
21
+ },
22
+ "dependencies": {
23
+ "json5": "2.2.3"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^20",
27
+ "typescript": "^5",
28
+ "vitest": "^4.0.18"
29
+ },
30
+ "license": "MIT",
31
+ "homepage": "https://reefclaw.com"
32
+ }
package/dist/daemon.js DELETED
@@ -1,104 +0,0 @@
1
- // Keep the bridge running across reboots. Linux/systemd-user is implemented
2
- // fully; macOS and Windows fall back to printing the manual run command (a
3
- // launchd/Task-Scheduler unit is a follow-up). The bridge reads its config from
4
- // ~/.openclaw/openclaw.json, so until the user pastes their connect message the
5
- // service will start, find no token, and restart — harmless; it connects within
6
- // seconds of the agent writing the config.
7
- import { writeFileSync, mkdirSync } from 'node:fs';
8
- import { join } from 'node:path';
9
- import { homedir, userInfo } from 'node:os';
10
- import { BRIDGE_DIR } from './paths.js';
11
- import { run, which } from './exec.js';
12
- import { step, ok, info, warn } from './ui.js';
13
- const SERVICE_NAME = 'reefclaw-bridge';
14
- const NODE = process.execPath; // absolute path to the node running the installer
15
- /**
16
- * Build the systemd user-unit text. Pure + exported so the path-quoting is
17
- * unit-testable. Both `node` (process.execPath) and `bridgeDir` (under the
18
- * user's home) can contain spaces. Quoting rules differ per directive:
19
- * - ExecStart= is parsed with shell-like word splitting, so an unquoted
20
- * `ExecStart=/home/a b/node …` reads the binary as `/home/a` — QUOTE both
21
- * the binary and the script path.
22
- * - WorkingDirectory= takes the raw value after `=` as a single path (no word
23
- * splitting) — spaces are safe UNQUOTED, and quotes are treated as literal
24
- * characters, failing the unit with "path is not absolute" (verified live
25
- * on systemd 255 / Ubuntu 24.04). Do NOT quote it.
26
- */
27
- export function buildSystemdUnit(node, bridgeDir) {
28
- const indexJs = join(bridgeDir, 'index.js');
29
- return `[Unit]
30
- Description=ReefClaw connector - bridges OpenClaw to the ReefClaw dashboard
31
- After=network-online.target
32
- Wants=network-online.target
33
-
34
- [Service]
35
- Type=simple
36
- WorkingDirectory=${bridgeDir}
37
- ExecStart="${node}" "${indexJs}" --provider gateway --log-level info
38
- Restart=always
39
- RestartSec=5s
40
-
41
- [Install]
42
- WantedBy=default.target
43
- `;
44
- }
45
- function manualHint() {
46
- warn('Could not set up an auto-start service on this OS yet.');
47
- info('Keep the connector running with this command (leave it open / use your own service manager):');
48
- info(` "${NODE}" "${join(BRIDGE_DIR, 'index.js')}" --provider gateway`);
49
- }
50
- function installSystemd() {
51
- if (!which('systemctl'))
52
- return false;
53
- const unitDir = join(homedir(), '.config', 'systemd', 'user');
54
- mkdirSync(unitDir, { recursive: true });
55
- const unit = buildSystemdUnit(NODE, BRIDGE_DIR);
56
- writeFileSync(join(unitDir, `${SERVICE_NAME}.service`), unit, 'utf-8');
57
- run('systemctl', ['--user', 'daemon-reload']);
58
- const enabled = run('systemctl', ['--user', 'enable', '--now', `${SERVICE_NAME}.service`]);
59
- if (!enabled.ok) {
60
- warn('systemd --user enable/start did not succeed:');
61
- if (enabled.stderr.trim())
62
- info(enabled.stderr.trim().split('\n').slice(-2).join('\n'));
63
- info(`Try: systemctl --user enable --now ${SERVICE_NAME}.service`);
64
- return false;
65
- }
66
- // `enable --now` can exit 0 while the unit failed to load (e.g. a bad unit
67
- // file setting) — verify the unit actually came up before claiming ✓.
68
- // 'active' = running; 'activating' = the expected pre-token restart loop
69
- // (the bridge exits until the user pastes their connect message, and
70
- // Restart=always re-launches it). Anything else (inactive/failed) means the
71
- // unit never loaded.
72
- const active = run('systemctl', ['--user', 'is-active', `${SERVICE_NAME}.service`]);
73
- const state = active.stdout.trim();
74
- if (state !== 'active' && state !== 'activating') {
75
- warn(`the service did not come up (state: ${state || 'unknown'}).`);
76
- info(`Inspect: systemctl --user status ${SERVICE_NAME}.service`);
77
- return false;
78
- }
79
- // Linger lets the user service run without an active login session (servers).
80
- // Best-effort: needs privileges; non-fatal if it fails.
81
- const linger = run('loginctl', ['enable-linger', userInfo().username]);
82
- if (linger.ok) {
83
- info('enabled linger (service survives logout / reboot)');
84
- }
85
- else {
86
- info('note: run `sudo loginctl enable-linger $USER` so the connector survives logout.');
87
- }
88
- return true;
89
- }
90
- export function installDaemon() {
91
- step('Starting the connector as a background service');
92
- if (process.platform === 'linux') {
93
- if (installSystemd()) {
94
- ok(`connector running as a systemd user service (${SERVICE_NAME})`);
95
- info(`logs: journalctl --user -u ${SERVICE_NAME} -f`);
96
- return true;
97
- }
98
- manualHint();
99
- return false;
100
- }
101
- // macOS / Windows: manual for now (launchd / Task Scheduler unit is a follow-up).
102
- manualHint();
103
- return false;
104
- }