@reefclaw/openclaw-plugin 0.1.23 → 0.1.25

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 (95) hide show
  1. package/bridge/bridge.js +72 -5
  2. package/bridge/connector.d.ts +3 -1
  3. package/bridge/connector.js +51 -4
  4. package/bridge/gateway/heartbeat-cron.js +31 -7
  5. package/bridge/gateway/poller.d.ts +5 -0
  6. package/bridge/gateway/poller.js +9 -0
  7. package/bridge/index.js +21 -0
  8. package/bridge/provider.d.ts +15 -0
  9. package/bridge/providers/connector-update.d.ts +89 -0
  10. package/bridge/providers/connector-update.js +212 -0
  11. package/bridge/providers/emergency-commands.d.ts +36 -0
  12. package/bridge/providers/emergency-commands.js +91 -0
  13. package/bridge/providers/gateway.d.ts +26 -1
  14. package/bridge/providers/gateway.js +159 -8
  15. package/bridge/providers/mock.js +1 -0
  16. package/bridge/shock-wake.d.ts +80 -0
  17. package/bridge/shock-wake.js +291 -0
  18. package/bridge/types.d.ts +5 -1
  19. package/bridge/types.js +5 -0
  20. package/bridge/utils/instance-id.d.ts +3 -0
  21. package/bridge/utils/instance-id.js +48 -0
  22. package/ccxt/binance-private.js +2 -1
  23. package/ccxt/binance-public.js +6 -1
  24. package/config/agent-config-client.d.ts +7 -2
  25. package/config/agent-config-client.js +17 -0
  26. package/config/agent-config-poller.js +5 -1
  27. package/config/brackets-config.d.ts +2 -1
  28. package/config/brackets-config.js +25 -3
  29. package/config/gate-store.d.ts +12 -0
  30. package/config/gate-store.js +26 -2
  31. package/config/loss-streak-config.d.ts +2 -0
  32. package/config/loss-streak-config.js +33 -0
  33. package/config/plugin-config-io.d.ts +19 -0
  34. package/config/plugin-config-io.js +24 -2
  35. package/config/reentry-cooldown-config.d.ts +7 -0
  36. package/config/reentry-cooldown-config.js +59 -0
  37. package/http/keepalive-fetch.d.ts +5 -0
  38. package/http/keepalive-fetch.js +50 -0
  39. package/index.js +77 -8
  40. package/ingest/position-auto-capture.js +49 -4
  41. package/ingest/position-decisions-client.d.ts +6 -0
  42. package/ingest/position-decisions-client.js +27 -9
  43. package/ingest/readiness-reporter.d.ts +23 -2
  44. package/ingest/readiness-reporter.js +56 -1
  45. package/live/approval-lifecycle.d.ts +10 -0
  46. package/live/approval-lifecycle.js +16 -2
  47. package/live/microstructure-assembler.js +11 -2
  48. package/live/proposal-decision-listener.d.ts +21 -0
  49. package/live/proposal-decision-listener.js +39 -0
  50. package/live/proposal-manager.d.ts +12 -0
  51. package/live/proposal-manager.js +47 -0
  52. package/live/stop-watcher.d.ts +16 -1
  53. package/live/stop-watcher.js +48 -8
  54. package/onboarding/runtime.js +4 -0
  55. package/openclaw.plugin.json +1 -1
  56. package/package.json +38 -38
  57. package/persistence/state-manager.d.ts +7 -0
  58. package/persistence/state-manager.js +28 -1
  59. package/portfolio/directional-scoreboard.d.ts +17 -0
  60. package/portfolio/directional-scoreboard.js +71 -0
  61. package/portfolio/reentry-tracker.d.ts +38 -1
  62. package/portfolio/reentry-tracker.js +49 -0
  63. package/signals/change-of-character.d.ts +38 -0
  64. package/signals/change-of-character.js +93 -0
  65. package/simulator/exchange-simulator.d.ts +27 -1
  66. package/simulator/exchange-simulator.js +98 -38
  67. package/simulator/types.d.ts +11 -0
  68. package/skills/reefclaw/SKILL.md +2 -2
  69. package/strategy/evaluator.d.ts +4 -0
  70. package/tools/audit-bracket-protection.js +11 -7
  71. package/tools/close-position.js +10 -1
  72. package/tools/create-order.js +121 -9
  73. package/tools/get-funding-context.js +6 -1
  74. package/tools/get-liquidation-levels.js +5 -1
  75. package/tools/get-liquidation-pulse.js +7 -1
  76. package/tools/get-market-intel.js +2 -1
  77. package/tools/get-relevant-learnings.js +20 -1
  78. package/tools/get-resting-liquidity.js +6 -1
  79. package/tools/get-wave9-status.js +17 -0
  80. package/tools/hl-provision-agent-wallet.js +29 -11
  81. package/tools/intel-api.d.ts +9 -0
  82. package/tools/intel-api.js +32 -1
  83. package/tools/record-position-reviews.js +2 -2
  84. package/tools/reentry-cooldown.d.ts +33 -0
  85. package/tools/reentry-cooldown.js +74 -0
  86. package/tools/scan-pairs.d.ts +7 -0
  87. package/tools/scan-pairs.js +67 -11
  88. package/tools/set-exchange-credentials.js +19 -0
  89. package/tools/set-trading-mode.d.ts +6 -0
  90. package/tools/set-trading-mode.js +48 -1
  91. package/types.d.ts +7 -0
  92. package/venues/hyperliquid/hl-agent-wallet.d.ts +26 -0
  93. package/venues/hyperliquid/hl-agent-wallet.js +32 -0
  94. package/venues/hyperliquid/hl-live-adapter.d.ts +27 -2
  95. package/venues/hyperliquid/hl-live-adapter.js +101 -13
@@ -0,0 +1,212 @@
1
+ // Operator-triggered connector update, driven over the OpenClaw gateway's
2
+ // terminal.* PTY methods.
3
+ //
4
+ // WHY A PTY AND NOT AN RPC: there is no plugin-install RPC on the gateway. The
5
+ // full method table of OpenClaw 2026.7.1-2 (197 handlers) has no
6
+ // `plugin.install` / `plugins.install`; that string exists only as an
7
+ // audit-operation label and a CLI flag. `skills.install` DOES accept
8
+ // `acknowledgeClawHubRisk` (it is what OpenClaw's own Control UI calls) but it
9
+ // is the SKILLS surface, and the ClawHub installer branches on package family —
10
+ // ReefClaw ships as `code-plugin`, which that path refuses by design. On this
11
+ // OpenClaw version a code-plugin can only be installed/updated from a shell.
12
+ // `terminal.*` is the gateway's shell.
13
+ //
14
+ // ★★ SECURITY — READ BEFORE CHANGING ANYTHING IN THIS FILE ★★
15
+ // This module can run a command on the trader's machine, initiated from our
16
+ // dashboard. That is only acceptable because the command is a FIXED CONSTANT:
17
+ //
18
+ // - `CONNECTOR_UPDATE_COMMAND` is a module constant. Nothing from the
19
+ // browser, the relay, the agent, or any config is interpolated into it.
20
+ // There is deliberately NO version/args/flags parameter — not even a
21
+ // validated one — because the moment a caller can influence the string this
22
+ // stops being an update button and becomes a remote shell.
23
+ // - The RPC that reaches this module carries NO command field. Adding one is
24
+ // not a feature request to accept; it is the vulnerability.
25
+ // - No secret is ever placed on the command line. `@reefclaw/connect` takes
26
+ // no token argument (the token is already on disk from the first install),
27
+ // so the process list and shell history stay clean.
28
+ //
29
+ // Two further facts that shape the design:
30
+ // - The installer calls `restartGateway()`. The gateway restart therefore
31
+ // KILLS THIS PTY AND OUR OWN TRANSPORT mid-run. Losing the session after
32
+ // the command started is the SUCCESS path, not an error — see
33
+ // `classifyTransportLoss`.
34
+ // - The PTY runs as the gateway's own user (`openclaw`), which is exactly the
35
+ // user the installer must run as. Correct by construction.
36
+ import { logger } from '../logger.js';
37
+ const TAG = 'connector-update';
38
+ /**
39
+ * The ONE command this feature can ever run.
40
+ *
41
+ * `-y` so npx never blocks on its install prompt; `@latest` because this is an
42
+ * update. No user input, no interpolation, no exceptions.
43
+ */
44
+ export const CONNECTOR_UPDATE_COMMAND = 'npx -y @reefclaw/connect@latest';
45
+ /** Marker printed after the command so we can detect completion + exit code. */
46
+ export const DONE_SENTINEL = '__RC_CONNECTOR_UPDATE_DONE__';
47
+ /**
48
+ * The exact line written to the PTY. Built once, from constants only.
49
+ * `$?` is expanded by the remote shell, not by us.
50
+ */
51
+ export function buildUpdateCommandLine() {
52
+ return `${CONNECTOR_UPDATE_COMMAND}; printf '\\n${DONE_SENTINEL}%s\\n' "$?"`;
53
+ }
54
+ /** Terminal grid we request. Wide enough that npm/npx output does not wrap into
55
+ * unreadable soup in the dashboard's output pane. */
56
+ const COLS = 120;
57
+ const ROWS = 40;
58
+ function isOpenResult(v) {
59
+ return !!v && typeof v === 'object' && typeof v.sessionId === 'string';
60
+ }
61
+ /**
62
+ * Map a gateway error from `terminal.open` onto an operator-readable outcome.
63
+ *
64
+ * The two refusals we know the gateway can produce are worth distinct copy —
65
+ * both are configuration facts on the trader's box that no retry will fix:
66
+ * - "terminal is not available" / "terminal is disabled"
67
+ * - agent runs in a sandbox → in-sandbox terminals are unsupported
68
+ */
69
+ export function describeOpenFailure(err) {
70
+ const raw = err instanceof Error ? err.message : String(err);
71
+ const lower = raw.toLowerCase();
72
+ if (lower.includes('terminal is disabled') || lower.includes('terminal is not available')) {
73
+ return {
74
+ ok: false,
75
+ status: 'blocked',
76
+ message: 'This box has the OpenClaw terminal disabled, so ReefClaw cannot run the update for you. '
77
+ + 'Update it from a shell on the box instead: npx -y @reefclaw/connect@latest',
78
+ };
79
+ }
80
+ if (lower.includes('sandbox')) {
81
+ return {
82
+ ok: false,
83
+ status: 'blocked',
84
+ message: 'This agent runs in a sandbox, and OpenClaw does not support terminals inside one yet. '
85
+ + 'Update from a shell on the box instead: npx -y @reefclaw/connect@latest',
86
+ };
87
+ }
88
+ return {
89
+ ok: false,
90
+ status: 'failed',
91
+ message: `Could not open a terminal on the box: ${raw}`,
92
+ };
93
+ }
94
+ /**
95
+ * Once the command is running, losing the transport is EXPECTED — the installer
96
+ * restarts the gateway, which is the process hosting both the PTY and our own
97
+ * connection. Treating that as a failure would report a successful update as
98
+ * broken, so the distinction is explicit and tested.
99
+ */
100
+ export function classifyTransportLoss(started) {
101
+ return started ? 'restarting' : 'failed';
102
+ }
103
+ /** Extract the exit code that follows the DONE sentinel, if it has appeared. */
104
+ export function parseDoneSentinel(screen) {
105
+ const idx = screen.lastIndexOf(DONE_SENTINEL);
106
+ if (idx === -1)
107
+ return { done: false, exitCode: null };
108
+ const after = screen.slice(idx + DONE_SENTINEL.length);
109
+ const m = /^(\d{1,3})/.exec(after.trim());
110
+ return { done: true, exitCode: m ? Number(m[1]) : null };
111
+ }
112
+ /**
113
+ * The sentinel is echoed by the shell as part of the command line BEFORE the
114
+ * command runs, so a naive `includes()` reports "done" immediately. Strip the
115
+ * echoed command line first: completion is only credible after the printf has
116
+ * actually executed, which is the LAST occurrence and is followed by a digit.
117
+ */
118
+ export function isCredibleCompletion(screen) {
119
+ const { done, exitCode } = parseDoneSentinel(screen);
120
+ return done && exitCode !== null;
121
+ }
122
+ /**
123
+ * Guard the restart hazard. The installer restarts the gateway; with positions
124
+ * open that means the agent stops managing them for the duration (exchange-side
125
+ * brackets still protect the book — they are enforced by the venue, not by us —
126
+ * but there is a known startup window where protective closes are refused).
127
+ * So: never silently update a live book. Demand an explicit acknowledgement.
128
+ */
129
+ export function checkPositionGuard(opts) {
130
+ if (opts.openPositionCount > 0 && opts.acknowledgeOpenPositions !== true) {
131
+ const n = opts.openPositionCount;
132
+ return {
133
+ ok: false,
134
+ status: 'blocked',
135
+ requiresPositionAck: true,
136
+ message: `${n} position${n === 1 ? ' is' : 's are'} open. Updating restarts the agent, so it will stop `
137
+ + 'managing the book for about a minute (exchange-side stops stay in force throughout). '
138
+ + 'Confirm again to update anyway, or flatten first.',
139
+ };
140
+ }
141
+ return null;
142
+ }
143
+ /**
144
+ * Open a PTY and write the update command to it. Returns as soon as the command
145
+ * is running — the caller polls `readTerminalText` for output.
146
+ */
147
+ export async function startConnectorUpdate(rpc, opts) {
148
+ const blocked = checkPositionGuard(opts);
149
+ if (blocked) {
150
+ logger.info(TAG, `update refused: ${opts.openPositionCount} open position(s), no acknowledgement`);
151
+ return blocked;
152
+ }
153
+ let opened;
154
+ try {
155
+ opened = await rpc('terminal.open', { cols: COLS, rows: ROWS });
156
+ }
157
+ catch (err) {
158
+ const outcome = describeOpenFailure(err);
159
+ logger.warn(TAG, `terminal.open failed: ${outcome.message}`);
160
+ return outcome;
161
+ }
162
+ if (!isOpenResult(opened)) {
163
+ return {
164
+ ok: false,
165
+ status: 'failed',
166
+ message: 'The gateway opened a terminal but did not return a session id.',
167
+ };
168
+ }
169
+ const { sessionId } = opened;
170
+ logger.info(TAG, `terminal opened (shell=${opened.shell} cwd=${opened.cwd} confined=${opened.confined})`);
171
+ try {
172
+ // `\r` submits the line — terminal.input is raw keystrokes, not a command API.
173
+ await rpc('terminal.input', { sessionId, data: `${buildUpdateCommandLine()}\r` });
174
+ }
175
+ catch (err) {
176
+ const reason = err instanceof Error ? err.message : String(err);
177
+ logger.warn(TAG, `terminal.input failed: ${reason}`);
178
+ // Best-effort cleanup so we do not leak a live PTY on the box.
179
+ await rpc('terminal.close', { sessionId }).catch(() => undefined);
180
+ return { ok: false, status: 'failed', message: `Could not start the update: ${reason}`, sessionId };
181
+ }
182
+ logger.info(TAG, `connector update started (session=${sessionId})`);
183
+ return {
184
+ ok: true,
185
+ status: 'started',
186
+ sessionId,
187
+ message: 'Update started. The agent restarts when it finishes, so the dashboard will reconnect on its own.',
188
+ };
189
+ }
190
+ /** Read the current screen contents. Returns null when the session is gone. */
191
+ export async function readTerminalText(rpc, sessionId) {
192
+ try {
193
+ const res = await rpc('terminal.text', { sessionId });
194
+ if (typeof res === 'string')
195
+ return res;
196
+ if (res && typeof res === 'object') {
197
+ const t = res;
198
+ if (typeof t.text === 'string')
199
+ return t.text;
200
+ if (typeof t.data === 'string')
201
+ return t.data;
202
+ }
203
+ return '';
204
+ }
205
+ catch {
206
+ return null;
207
+ }
208
+ }
209
+ /** Best-effort PTY cleanup. Never throws — cleanup failure must not mask an outcome. */
210
+ export async function closeTerminal(rpc, sessionId) {
211
+ await rpc('terminal.close', { sessionId }).catch(() => undefined);
212
+ }
@@ -44,6 +44,42 @@ export declare function executeKill(ctx: EmergencyContext, freshOrders: OrderDat
44
44
  * Caller must set agentMode = 'STOPPED' after this returns.
45
45
  */
46
46
  export declare function executeFlatten(ctx: EmergencyContext, cachedPositions: CcxtPosition[]): Promise<EmergencyResult>;
47
+ export interface ProposalCancelSummary {
48
+ /** Rows moved to `cancelled`. 0 is the normal result when approval mode is off. */
49
+ cancelled: number;
50
+ /** Approved proposals a listener had already claimed — these may already be
51
+ * at the exchange, so they are deliberately NOT cancelled. */
52
+ inFlight: number;
53
+ /** Operator-facing sentence for EmergencyResult.details, or null when there
54
+ * is nothing worth saying (feature off / not configured). */
55
+ detail: string | null;
56
+ }
57
+ /**
58
+ * Cancel the tenant's outstanding approval-mode proposals because trading is
59
+ * being halted.
60
+ *
61
+ * Kill / Flatten / Pause previously left pending proposals alone, so an
62
+ * approval landing seconds after a kill still opened a brand-new position —
63
+ * hard expiry was the only bound (design doc §10 row 13). The plugin holds no
64
+ * halt state and there is no skill→plugin channel for one, so the halt is
65
+ * applied at the webapp, where proposals actually live. That is sufficient:
66
+ * the plugin's listener can only fire rows returned by /pending-decisions,
67
+ * which requires status='approved', and a cancelled row can never be approved.
68
+ *
69
+ * ★ FAIL-SOFT, NEVER FAIL-BLOCKING. This must not delay or abort a kill. Any
70
+ * error returns a *reported* failure rather than throwing — a halt that
71
+ * cancelled positions but couldn't confirm proposals is still a halt, and the
72
+ * operator needs to be told which half is uncertain rather than getting a
73
+ * clean green.
74
+ *
75
+ * ★ Proposals a listener already CLAIMED are not cancelled — a claim is taken
76
+ * immediately before order submission, so such a row may already be live at
77
+ * the exchange. We surface the count instead of asserting a cancellation we
78
+ * cannot guarantee.
79
+ */
80
+ export declare function cancelPendingProposals(operatorToken: string | undefined, reason: 'kill_switch' | 'flatten' | 'pause' | 'mode_disabled', fetchImpl?: typeof fetch): Promise<ProposalCancelSummary>;
81
+ /** Fold a proposal-cancel summary into an EmergencyResult's details line. */
82
+ export declare function withProposalDetail(result: EmergencyResult, summary: ProposalCancelSummary): EmergencyResult;
47
83
  /**
48
84
  * Pause: prevent new entries, keep existing positions.
49
85
  * Returns the new agent mode if the transition is valid.
@@ -219,6 +219,97 @@ export async function executeFlatten(ctx, cachedPositions) {
219
219
  const details = `Closed ${succeeded}/${openPositions.length} positions`;
220
220
  return { action: 'flatten', executed: true, timestamp: new Date().toISOString(), details };
221
221
  }
222
+ const PROPOSAL_CANCEL_TIMEOUT_MS = 3_000;
223
+ /**
224
+ * Cancel the tenant's outstanding approval-mode proposals because trading is
225
+ * being halted.
226
+ *
227
+ * Kill / Flatten / Pause previously left pending proposals alone, so an
228
+ * approval landing seconds after a kill still opened a brand-new position —
229
+ * hard expiry was the only bound (design doc §10 row 13). The plugin holds no
230
+ * halt state and there is no skill→plugin channel for one, so the halt is
231
+ * applied at the webapp, where proposals actually live. That is sufficient:
232
+ * the plugin's listener can only fire rows returned by /pending-decisions,
233
+ * which requires status='approved', and a cancelled row can never be approved.
234
+ *
235
+ * ★ FAIL-SOFT, NEVER FAIL-BLOCKING. This must not delay or abort a kill. Any
236
+ * error returns a *reported* failure rather than throwing — a halt that
237
+ * cancelled positions but couldn't confirm proposals is still a halt, and the
238
+ * operator needs to be told which half is uncertain rather than getting a
239
+ * clean green.
240
+ *
241
+ * ★ Proposals a listener already CLAIMED are not cancelled — a claim is taken
242
+ * immediately before order submission, so such a row may already be live at
243
+ * the exchange. We surface the count instead of asserting a cancellation we
244
+ * cannot guarantee.
245
+ */
246
+ export async function cancelPendingProposals(operatorToken, reason, fetchImpl = fetch) {
247
+ const none = { cancelled: 0, inFlight: 0, detail: null };
248
+ if (!operatorToken)
249
+ return none;
250
+ // www is load-bearing: reefclaw.com 307-redirects and Node fetch strips the
251
+ // Authorization header on a cross-origin redirect (same as bridge.ts).
252
+ const base = (process.env.REEFCLAW_API_URL || 'https://www.reefclaw.com').replace(/\/$/, '');
253
+ try {
254
+ const res = await fetchImpl(`${base}/api/internal/proposed_orders/cancel-all`, {
255
+ method: 'POST',
256
+ headers: {
257
+ Authorization: `Bearer ${operatorToken}`,
258
+ 'Content-Type': 'application/json',
259
+ },
260
+ body: JSON.stringify({ reason }),
261
+ signal: AbortSignal.timeout(PROPOSAL_CANCEL_TIMEOUT_MS),
262
+ });
263
+ if (!res.ok) {
264
+ logger.warn(TAG, `proposal cancel-all returned ${res.status} (reason=${reason})`);
265
+ return {
266
+ ...none,
267
+ detail: `could not confirm pending trade proposals were cancelled (HTTP ${res.status}) — check the dashboard`,
268
+ };
269
+ }
270
+ const body = (await res.json());
271
+ const cancelled = typeof body.cancelled === 'number' ? body.cancelled : 0;
272
+ const inFlightRows = Array.isArray(body.inFlight) ? body.inFlight : [];
273
+ const parts = [];
274
+ if (cancelled > 0) {
275
+ parts.push(`${cancelled} pending trade proposal${cancelled === 1 ? '' : 's'} cancelled`);
276
+ }
277
+ if (inFlightRows.length > 0) {
278
+ const named = inFlightRows
279
+ .slice(0, 3)
280
+ .map((r) => `${r.symbol ?? '?'} ${r.side ?? '?'}`)
281
+ .join(', ');
282
+ parts.push(`⚠ ${inFlightRows.length} approved proposal${inFlightRows.length === 1 ? '' : 's'} ` +
283
+ `already executing and could NOT be cancelled (${named}) — verify on the exchange`);
284
+ }
285
+ if (cancelled > 0 || inFlightRows.length > 0) {
286
+ logger.info(TAG, `proposal cancel-all (${reason}): cancelled=${cancelled} inFlight=${inFlightRows.length}`);
287
+ }
288
+ return {
289
+ cancelled,
290
+ inFlight: inFlightRows.length,
291
+ detail: parts.length > 0 ? parts.join('; ') : null,
292
+ };
293
+ }
294
+ catch (err) {
295
+ // Timeout / network / malformed body. Report, never throw — the halt itself
296
+ // must complete regardless.
297
+ logger.warn(TAG, `proposal cancel-all failed (reason=${reason}): ${err instanceof Error ? err.message : String(err)}`);
298
+ return {
299
+ ...none,
300
+ detail: 'could not reach the server to cancel pending trade proposals — check the dashboard',
301
+ };
302
+ }
303
+ }
304
+ /** Fold a proposal-cancel summary into an EmergencyResult's details line. */
305
+ export function withProposalDetail(result, summary) {
306
+ if (!summary.detail)
307
+ return result;
308
+ return {
309
+ ...result,
310
+ details: result.details ? `${result.details}. ${summary.detail}` : summary.detail,
311
+ };
312
+ }
222
313
  // ---- Sync commands (pure state transitions) ----
223
314
  /**
224
315
  * Pause: prevent new entries, keep existing positions.
@@ -2,6 +2,7 @@ import type { OpenClawProvider, ProviderEvents, ProviderEventName, ExchangeCrede
2
2
  import type { EmergencyAction, ReconciliationSnapshot, TradingMode } from '../types.js';
3
3
  import type { GatewayConfig } from '../gateway/gateway-config.js';
4
4
  import { type GetBracketConfigOutcome, type SetBracketRequirementOutcome, type BracketRequirementFlag, type SetTradingModeOutcome, type SetExchangeCredentialsOutcome, type TestExchangeCredentialsOutcome, type ClearExchangeCredentialsOutcome } from './onboarding-commands.js';
5
+ import { type ConnectorUpdateOutcome } from './connector-update.js';
5
6
  /** Decide whether the session-start-NAV anchor needs to be (re)set. Pure so it
6
7
  * can be unit-tested without the full provider. Returns true when the anchor
7
8
  * has never been set, OR — for PAPER only — when it belongs to a previous UTC
@@ -239,6 +240,28 @@ export declare class GatewayProvider implements OpenClawProvider {
239
240
  v: number;
240
241
  };
241
242
  }): Promise<HlSubmitApprovalOutcome>;
243
+ /**
244
+ * Operator-only. Update the ReefClaw connector on this box to the latest
245
+ * published version.
246
+ *
247
+ * Runs `npx -y @reefclaw/connect@latest` over the gateway's PTY. There is no
248
+ * command/version/args parameter and there never should be — the whole
249
+ * security argument for this feature is that the string is a constant (see
250
+ * providers/connector-update.ts).
251
+ *
252
+ * The installer restarts the gateway when it finishes, which kills both the
253
+ * PTY and this process's own transport. That is the SUCCESS path: we report
254
+ * `restarting` and the dashboard waits for the reconnect.
255
+ */
256
+ updateConnector(args: {
257
+ acknowledgeOpenPositions?: boolean;
258
+ }): Promise<ConnectorUpdateOutcome>;
259
+ /**
260
+ * Poll the PTY and emit progress until the command finishes or the gateway
261
+ * restart takes the session (and us) down.
262
+ */
263
+ private streamConnectorUpdate;
264
+ private emitConnectorUpdate;
242
265
  /** Operator-only. Approval/balance status of the provisioned HL wallet. */
243
266
  getHlAgentWalletStatus(): Promise<HlAgentWalletStatusOutcome>;
244
267
  /** Operator-only. Read the current bracket-orders config from the plugin. */
@@ -414,7 +437,9 @@ export declare class GatewayProvider implements OpenClawProvider {
414
437
  * hardcoded 1800 and `agents.defaults.heartbeat` are both stale/wrong once
415
438
  * the operator edits the cron (`openclaw cron ... --every 15m`). Drives both
416
439
  * the "beat every Nm" label and the unresponsive (2×) threshold. Cached 60s;
417
- * returns undefined (caller defaults to 1800) when unreadable.
440
+ * returns undefined (caller defaults to 1800) when unreadable. On 2026.7.x
441
+ * (no jobs.json) the value is fed by refreshHeartbeatHealth() from the
442
+ * cron.list RPC instead; the file read here is the legacy fallback.
418
443
  */
419
444
  private resolveHeartbeatSeconds;
420
445
  /** Build agent state from internal fields (used by both emitAgentState and getSnapshot). */
@@ -16,8 +16,9 @@ import { Poller } from '../gateway/poller.js';
16
16
  import { ensureHeartbeatCron, isHeartbeatLikeName } from '../gateway/heartbeat-cron.js';
17
17
  import { parseIdentityName } from '../utils/identity-name.js';
18
18
  import { computeEquity as _computeEquity, computePositionNotional as _computePositionNotional, computeRiskMetrics as _computeRiskMetrics, DEFAULT_RISK_LIMITS, DRAWDOWN_ZONE_THRESHOLDS, TENANT_LIMIT_BOUNDS, boundedNum, validateDrawdownLadder, } from './risk-calculator.js';
19
- import { executeKill as _executeKill, executeFlatten as _executeFlatten, executePause as _executePause, executeResume as _executeResume, } from './emergency-commands.js';
19
+ import { executeKill as _executeKill, executeFlatten as _executeFlatten, executePause as _executePause, executeResume as _executeResume, cancelPendingProposals, withProposalDetail, } from './emergency-commands.js';
20
20
  import { executeSetTradingMode, executeGetBracketConfig, executeSetBracketRequirement, executeSetExchangeCredentials, executeTestExchangeCredentials, executeClearExchangeCredentials, executeProvisionHlAgentWallet, executeHlAgentWalletStatus, executeSubmitHlAgentApproval, } from './onboarding-commands.js';
21
+ import { startConnectorUpdate, readTerminalText, closeTerminal, classifyTransportLoss, isCredibleCompletion, parseDoneSentinel, } from './connector-update.js';
21
22
  const TAG = 'gateway';
22
23
  // ---- Day-start NAV persistence ----
23
24
  // Persists sessionStartNav (the UTC-day P&L anchor) per date so Day P&L
@@ -304,6 +305,7 @@ export class GatewayProvider {
304
305
  chatMessageChunk: new Set(),
305
306
  chatMessageEnd: new Set(),
306
307
  chatStreamingKeepAlive: new Set(),
308
+ connectorUpdate: new Set(),
307
309
  marketStructure: new Set(),
308
310
  cryptoMetrics: new Set(),
309
311
  volumeAnalysis: new Set(),
@@ -588,6 +590,13 @@ export class GatewayProvider {
588
590
  // ---- Emergency command implementations (delegated to emergency-commands.ts) ----
589
591
  async executeKill() {
590
592
  const ctx = { http: this.http, toolMap: this.toolMap, symbol: this.config.symbol };
593
+ // Approval-mode proposals are cancelled CONCURRENTLY with the exchange
594
+ // work, started first: a pending proposal approved mid-kill would open a
595
+ // brand-new position while we cancel orders. Kicking it off here (rather
596
+ // than awaiting it before the kill) means it wins essentially every race
597
+ // without adding a single millisecond to the kill path. Fail-soft — it
598
+ // reports into `details`, it can never throw or block.
599
+ const proposalCancel = cancelPendingProposals(this.config.connectionToken, 'kill_switch');
591
600
  // Pass a FRESH, all-symbols order snapshot so Kill cancels every working
592
601
  // order portfolio-wide and correctly identifies (and preserves) protective
593
602
  // reduceOnly brackets. On a failed fresh read Kill still runs against the
@@ -597,21 +606,28 @@ export class GatewayProvider {
597
606
  const result = await _executeKill(ctx, freshOrders, this.openOrders);
598
607
  this.agentMode = 'STOPPED';
599
608
  this.emitAgentState();
600
- return result;
609
+ return withProposalDetail(result, await proposalCancel);
601
610
  }
602
611
  async executeFlatten() {
603
612
  const ctx = { http: this.http, toolMap: this.toolMap, symbol: this.config.symbol };
613
+ // Started before the closes, awaited after — see executeKill.
614
+ const proposalCancel = cancelPendingProposals(this.config.connectionToken, 'flatten');
604
615
  const result = await _executeFlatten(ctx, this.positions);
605
616
  this.agentMode = 'STOPPED';
606
617
  this.emitAgentState();
607
- return result;
618
+ return withProposalDetail(result, await proposalCancel);
608
619
  }
609
- executePause() {
620
+ async executePause() {
610
621
  const { result, newMode } = _executePause(this.agentMode);
611
622
  if (newMode !== this.agentMode) {
612
623
  this.agentMode = newMode;
613
624
  this.emitAgentState();
614
625
  }
626
+ // A pending proposal IS a new entry, which is exactly what Pause forbids.
627
+ // No exchange work here, so awaiting directly costs nothing.
628
+ if (result.executed) {
629
+ return withProposalDetail(result, await cancelPendingProposals(this.config.connectionToken, 'pause'));
630
+ }
615
631
  return result;
616
632
  }
617
633
  executeResume() {
@@ -709,6 +725,105 @@ export class GatewayProvider {
709
725
  async submitHlAgentApproval(args) {
710
726
  return executeSubmitHlAgentApproval({ http: this.http, toolMap: this.toolMap, operatorToken: this.config.connectionToken }, args);
711
727
  }
728
+ /**
729
+ * Operator-only. Update the ReefClaw connector on this box to the latest
730
+ * published version.
731
+ *
732
+ * Runs `npx -y @reefclaw/connect@latest` over the gateway's PTY. There is no
733
+ * command/version/args parameter and there never should be — the whole
734
+ * security argument for this feature is that the string is a constant (see
735
+ * providers/connector-update.ts).
736
+ *
737
+ * The installer restarts the gateway when it finishes, which kills both the
738
+ * PTY and this process's own transport. That is the SUCCESS path: we report
739
+ * `restarting` and the dashboard waits for the reconnect.
740
+ */
741
+ async updateConnector(args) {
742
+ const ws = this.wsClient;
743
+ if (!ws) {
744
+ return { ok: false, status: 'failed', message: 'Not connected to the OpenClaw gateway.' };
745
+ }
746
+ const rpc = (method, params) => ws.sendRpc(method, params);
747
+ const openPositionCount = this.positions.filter((p) => Math.abs(Number(p.contracts) || 0) > 0).length;
748
+ const started = await startConnectorUpdate(rpc, {
749
+ acknowledgeOpenPositions: args.acknowledgeOpenPositions,
750
+ openPositionCount,
751
+ });
752
+ if (started.status !== 'started' || !started.sessionId)
753
+ return started;
754
+ // Stream progress in the background; the RPC returns as soon as the command
755
+ // is running so the browser is never left waiting on a minutes-long call.
756
+ void this.streamConnectorUpdate(rpc, started.sessionId);
757
+ return started;
758
+ }
759
+ /**
760
+ * Poll the PTY and emit progress until the command finishes or the gateway
761
+ * restart takes the session (and us) down.
762
+ */
763
+ async streamConnectorUpdate(rpc, sessionId) {
764
+ const POLL_MS = 1500;
765
+ // Generous: an npm install on a small VPS is slow. The gateway restart
766
+ // normally ends this loop long before the cap.
767
+ const MAX_POLLS = 240;
768
+ let lastEmitted = '';
769
+ // A single failed read is not proof the gateway went away — it could be a
770
+ // transient RPC error under install load. Require two in a row before
771
+ // declaring the restart, so we do not flip the dashboard into "restarting"
772
+ // while the update is in fact still running.
773
+ let consecutiveDeadReads = 0;
774
+ for (let i = 0; i < MAX_POLLS; i++) {
775
+ await new Promise((r) => setTimeout(r, POLL_MS));
776
+ const screen = await readTerminalText(rpc, sessionId);
777
+ if (screen !== null)
778
+ consecutiveDeadReads = 0;
779
+ else if (++consecutiveDeadReads < 2)
780
+ continue;
781
+ if (screen === null) {
782
+ // Session (or the whole gateway) is gone. After a successful start this
783
+ // is the restart landing, not a failure.
784
+ const status = classifyTransportLoss(true);
785
+ this.emitConnectorUpdate({
786
+ ok: true,
787
+ status,
788
+ sessionId,
789
+ output: lastEmitted,
790
+ message: 'The agent is restarting to load the new version. The dashboard reconnects on its own.',
791
+ });
792
+ return;
793
+ }
794
+ if (screen !== lastEmitted) {
795
+ lastEmitted = screen;
796
+ this.emitConnectorUpdate({ ok: true, status: 'started', sessionId, output: screen, message: 'Updating…' });
797
+ }
798
+ if (isCredibleCompletion(screen)) {
799
+ const { exitCode } = parseDoneSentinel(screen);
800
+ const ok = exitCode === 0;
801
+ this.emitConnectorUpdate({
802
+ ok,
803
+ status: 'completed',
804
+ sessionId,
805
+ exitCode,
806
+ output: screen,
807
+ message: ok
808
+ ? 'Connector updated. The agent restarts to load it.'
809
+ : `The updater exited with code ${exitCode}. The connector was left as it was.`,
810
+ });
811
+ await closeTerminal(rpc, sessionId);
812
+ return;
813
+ }
814
+ }
815
+ this.emitConnectorUpdate({
816
+ ok: false,
817
+ status: 'failed',
818
+ sessionId,
819
+ output: lastEmitted,
820
+ message: 'The update did not finish in time. Check the box directly before retrying.',
821
+ });
822
+ await closeTerminal(rpc, sessionId);
823
+ }
824
+ emitConnectorUpdate(payload) {
825
+ this.fire('connectorUpdate', payload);
826
+ }
712
827
  /** Operator-only. Approval/balance status of the provisioned HL wallet. */
713
828
  async getHlAgentWalletStatus() {
714
829
  return executeHlAgentWalletStatus({
@@ -1135,7 +1250,14 @@ export class GatewayProvider {
1135
1250
  return new Poller(this.http, this.toolMap, this.config.symbol, {
1136
1251
  onTicker: (data) => this.onPollerTicker(data),
1137
1252
  onCandle: (data) => this.fire('candle', data),
1138
- onPositions: (positions) => this.onPollerPositions(positions),
1253
+ // onPollerPositions is the only async poller handler — without the
1254
+ // catch, a throw anywhere in fill-detection/NAV/risk becomes an
1255
+ // unhandled rejection and kills the bridge process.
1256
+ onPositions: (positions) => {
1257
+ void this.onPollerPositions(positions).catch((err) => {
1258
+ logger.error(TAG, `positions handler failed: ${formatError(err)}`);
1259
+ });
1260
+ },
1139
1261
  onBalance: (balance) => this.onPollerBalance(balance),
1140
1262
  onOpenOrders: (orders) => this.onPollerOpenOrders(orders),
1141
1263
  onMarketStructure: (data) => this.onPollerMarketStructure(data),
@@ -1395,7 +1517,12 @@ export class GatewayProvider {
1395
1517
  if (lastPrice <= 0)
1396
1518
  return;
1397
1519
  this.applyMarkPrice(pos, lastPrice);
1398
- }).catch(() => { });
1520
+ }).catch((err) => {
1521
+ // These fetches run OUTSIDE guardedExec — a swallowed 418/429
1522
+ // here defeats the ban gate (every request during a 418 extends
1523
+ // the per-IP ban). Feed rate-limit-shaped errors to the breaker.
1524
+ this.poller?.noteExternalRateLimit('mtm_ticker', formatError(err));
1525
+ });
1399
1526
  updatePromises.push(p);
1400
1527
  }
1401
1528
  }
@@ -1973,6 +2100,24 @@ export class GatewayProvider {
1973
2100
  if (!Array.isArray(jobs))
1974
2101
  return; // unknown shape — leave last-known
1975
2102
  const hb = jobs.find((j) => isHeartbeatLikeName(j?.name));
2103
+ // Cadence from the same RPC row: on 2026.7.x the filesystem jobs.json
2104
+ // that resolveHeartbeatSeconds() reads is gone (cron store migrated),
2105
+ // so without this the "beat every Nm" label and the 2× unresponsive
2106
+ // threshold silently fall back to the hardcoded 1800s.
2107
+ const row = hb;
2108
+ const sched = row?.schedule;
2109
+ if (row?.enabled !== false &&
2110
+ sched?.kind === 'every' &&
2111
+ typeof sched.everyMs === 'number' &&
2112
+ sched.everyMs > 0) {
2113
+ const secs = Math.round(sched.everyMs / 1000);
2114
+ if (secs !== this.heartbeatSeconds && !this.loggedHeartbeat) {
2115
+ this.loggedHeartbeat = true;
2116
+ logger.info(TAG, `Heartbeat cadence resolved: ${secs}s (~${Math.round(secs / 60)}m) from cron.list`);
2117
+ }
2118
+ this.heartbeatSeconds = secs;
2119
+ this.heartbeatReadAtMs = Date.now();
2120
+ }
1976
2121
  const state = hb?.state;
1977
2122
  if (!state || typeof state !== 'object')
1978
2123
  return;
@@ -2112,11 +2257,17 @@ export class GatewayProvider {
2112
2257
  * hardcoded 1800 and `agents.defaults.heartbeat` are both stale/wrong once
2113
2258
  * the operator edits the cron (`openclaw cron ... --every 15m`). Drives both
2114
2259
  * the "beat every Nm" label and the unresponsive (2×) threshold. Cached 60s;
2115
- * returns undefined (caller defaults to 1800) when unreadable.
2260
+ * returns undefined (caller defaults to 1800) when unreadable. On 2026.7.x
2261
+ * (no jobs.json) the value is fed by refreshHeartbeatHealth() from the
2262
+ * cron.list RPC instead; the file read here is the legacy fallback.
2116
2263
  */
2117
2264
  resolveHeartbeatSeconds() {
2118
2265
  const now = Date.now();
2119
- if (this.heartbeatSeconds !== undefined && now - this.heartbeatReadAtMs < 60_000) {
2266
+ // TTL must also cover the absent-file case (jobs.json doesn't exist on
2267
+ // 2026.7.x) — gating on `heartbeatSeconds !== undefined` meant the cache
2268
+ // never engaged there and buildAgentState did 2 blocking readFileSync
2269
+ // ENOENT probes every 5s tick, forever.
2270
+ if (now - this.heartbeatReadAtMs < 60_000) {
2120
2271
  return this.heartbeatSeconds;
2121
2272
  }
2122
2273
  const candidates = [
@@ -25,6 +25,7 @@ export class MockProvider {
25
25
  chatMessageStart: new Set(),
26
26
  chatMessageChunk: new Set(),
27
27
  chatMessageEnd: new Set(),
28
+ connectorUpdate: new Set(),
28
29
  chatStreamingKeepAlive: new Set(),
29
30
  marketStructure: new Set(),
30
31
  cryptoMetrics: new Set(),