@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.
- package/bridge/bridge.js +72 -5
- package/bridge/connector.d.ts +3 -1
- package/bridge/connector.js +51 -4
- package/bridge/gateway/heartbeat-cron.js +31 -7
- package/bridge/gateway/poller.d.ts +5 -0
- package/bridge/gateway/poller.js +9 -0
- package/bridge/index.js +21 -0
- package/bridge/provider.d.ts +15 -0
- package/bridge/providers/connector-update.d.ts +89 -0
- package/bridge/providers/connector-update.js +212 -0
- package/bridge/providers/emergency-commands.d.ts +36 -0
- package/bridge/providers/emergency-commands.js +91 -0
- package/bridge/providers/gateway.d.ts +26 -1
- package/bridge/providers/gateway.js +159 -8
- package/bridge/providers/mock.js +1 -0
- package/bridge/shock-wake.d.ts +80 -0
- package/bridge/shock-wake.js +291 -0
- package/bridge/types.d.ts +5 -1
- package/bridge/types.js +5 -0
- package/bridge/utils/instance-id.d.ts +3 -0
- package/bridge/utils/instance-id.js +48 -0
- package/ccxt/binance-private.js +2 -1
- package/ccxt/binance-public.js +6 -1
- package/config/agent-config-client.d.ts +7 -2
- package/config/agent-config-client.js +17 -0
- package/config/agent-config-poller.js +5 -1
- package/config/brackets-config.d.ts +2 -1
- package/config/brackets-config.js +25 -3
- package/config/gate-store.d.ts +12 -0
- package/config/gate-store.js +26 -2
- package/config/loss-streak-config.d.ts +2 -0
- package/config/loss-streak-config.js +33 -0
- package/config/plugin-config-io.d.ts +19 -0
- package/config/plugin-config-io.js +24 -2
- package/config/reentry-cooldown-config.d.ts +7 -0
- package/config/reentry-cooldown-config.js +59 -0
- package/http/keepalive-fetch.d.ts +5 -0
- package/http/keepalive-fetch.js +50 -0
- package/index.js +77 -8
- package/ingest/position-auto-capture.js +49 -4
- package/ingest/position-decisions-client.d.ts +6 -0
- package/ingest/position-decisions-client.js +27 -9
- package/ingest/readiness-reporter.d.ts +23 -2
- package/ingest/readiness-reporter.js +56 -1
- package/live/approval-lifecycle.d.ts +10 -0
- package/live/approval-lifecycle.js +16 -2
- package/live/microstructure-assembler.js +11 -2
- package/live/proposal-decision-listener.d.ts +21 -0
- package/live/proposal-decision-listener.js +39 -0
- package/live/proposal-manager.d.ts +12 -0
- package/live/proposal-manager.js +47 -0
- package/live/stop-watcher.d.ts +16 -1
- package/live/stop-watcher.js +48 -8
- package/onboarding/runtime.js +4 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +38 -38
- package/persistence/state-manager.d.ts +7 -0
- package/persistence/state-manager.js +28 -1
- package/portfolio/directional-scoreboard.d.ts +17 -0
- package/portfolio/directional-scoreboard.js +71 -0
- package/portfolio/reentry-tracker.d.ts +38 -1
- package/portfolio/reentry-tracker.js +49 -0
- package/signals/change-of-character.d.ts +38 -0
- package/signals/change-of-character.js +93 -0
- package/simulator/exchange-simulator.d.ts +27 -1
- package/simulator/exchange-simulator.js +98 -38
- package/simulator/types.d.ts +11 -0
- package/skills/reefclaw/SKILL.md +2 -2
- package/strategy/evaluator.d.ts +4 -0
- package/tools/audit-bracket-protection.js +11 -7
- package/tools/close-position.js +10 -1
- package/tools/create-order.js +121 -9
- package/tools/get-funding-context.js +6 -1
- package/tools/get-liquidation-levels.js +5 -1
- package/tools/get-liquidation-pulse.js +7 -1
- package/tools/get-market-intel.js +2 -1
- package/tools/get-relevant-learnings.js +20 -1
- package/tools/get-resting-liquidity.js +6 -1
- package/tools/get-wave9-status.js +17 -0
- package/tools/hl-provision-agent-wallet.js +29 -11
- package/tools/intel-api.d.ts +9 -0
- package/tools/intel-api.js +32 -1
- package/tools/record-position-reviews.js +2 -2
- package/tools/reentry-cooldown.d.ts +33 -0
- package/tools/reentry-cooldown.js +74 -0
- package/tools/scan-pairs.d.ts +7 -0
- package/tools/scan-pairs.js +67 -11
- package/tools/set-exchange-credentials.js +19 -0
- package/tools/set-trading-mode.d.ts +6 -0
- package/tools/set-trading-mode.js +48 -1
- package/types.d.ts +7 -0
- package/venues/hyperliquid/hl-agent-wallet.d.ts +26 -0
- package/venues/hyperliquid/hl-agent-wallet.js +32 -0
- package/venues/hyperliquid/hl-live-adapter.d.ts +27 -2
- 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
|
-
|
|
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
|
-
|
|
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 = [
|
package/bridge/providers/mock.js
CHANGED